Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/test-action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,10 @@ jobs:
echo "::error::Version mismatch! Expected v0.7.0, got ${{ steps.setup-awf.outputs.version }}"
exit 1
fi
# Verify image tag is set correctly (without 'v' prefix)
if [[ "${{ steps.setup-awf.outputs.image-tag }}" != "0.7.0" ]]; then
echo "::error::Image tag mismatch! Expected 0.7.0, got ${{ steps.setup-awf.outputs.image-tag }}"
# Verify image tag metadata starts with the expected base tag (without 'v' prefix)
# and may include optional digest metadata entries.
if [[ "${{ steps.setup-awf.outputs.image-tag }}" != 0.7.0* ]]; then
echo "::error::Image tag metadata mismatch! Expected prefix 0.7.0, got ${{ steps.setup-awf.outputs.image-tag }}"
exit 1
fi

Expand Down
74 changes: 61 additions & 13 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ outputs:
description: 'The version of awf that was installed'
value: ${{ steps.install.outputs.version }}
image-tag:
description: 'The image tag that matches the installed version (without the v prefix)'
description: 'The image tag metadata for awf runtime images (base tag plus optional per-image digests)'
value: ${{ steps.install.outputs.image_tag }}

runs:
Expand Down Expand Up @@ -99,14 +99,15 @@ runs:

echo "version=$VERSION" >> "$GITHUB_OUTPUT"

# Extract image tag (version without 'v' prefix)
IMAGE_TAG="${VERSION#v}"
echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"

# Download URLs
BASE_URL="https://github.com/${REPO}/releases/download/${VERSION}"
BINARY_URL="${BASE_URL}/${BINARY_NAME}"
CHECKSUMS_URL="${BASE_URL}/checksums.txt"
CONTAINERS_URL="${BASE_URL}/containers.txt"

# Extract image tag (version without 'v' prefix), then augment with digest pins when available
IMAGE_TAG="${VERSION#v}"
IMAGE_TAG_WITH_DIGESTS="$IMAGE_TAG"

# Download binary
echo "Downloading awf ${VERSION}..."
Expand All @@ -122,6 +123,43 @@ runs:
exit 1
fi

# Download optional containers digest manifest
echo "Downloading containers manifest (optional)..."
if curl -fsSL "$CONTAINERS_URL" -o "$INSTALL_DIR/containers.txt"; then
extract_digest() {
local image_name="$1"
grep -E "^ghcr\\.io/${REPO}/${image_name}@sha256:[a-fA-F0-9]{64}$" "$INSTALL_DIR/containers.txt" \
| sed -E 's#.*@(sha256:[a-fA-F0-9]{64})#\1#' \
| tr '[:upper:]' '[:lower:]' \
| head -n 1
}

DIGEST_ENTRIES=()
SQUID_DIGEST="$(extract_digest squid || true)"
AGENT_DIGEST="$(extract_digest agent || true)"
AGENT_ACT_DIGEST="$(extract_digest agent-act || true)"
API_PROXY_DIGEST="$(extract_digest api-proxy || true)"
CLI_PROXY_DIGEST="$(extract_digest cli-proxy || true)"

[ -n "${SQUID_DIGEST:-}" ] && DIGEST_ENTRIES+=("squid=${SQUID_DIGEST}")
[ -n "${AGENT_DIGEST:-}" ] && DIGEST_ENTRIES+=("agent=${AGENT_DIGEST}")
[ -n "${AGENT_ACT_DIGEST:-}" ] && DIGEST_ENTRIES+=("agent-act=${AGENT_ACT_DIGEST}")
[ -n "${API_PROXY_DIGEST:-}" ] && DIGEST_ENTRIES+=("api-proxy=${API_PROXY_DIGEST}")
[ -n "${CLI_PROXY_DIGEST:-}" ] && DIGEST_ENTRIES+=("cli-proxy=${CLI_PROXY_DIGEST}")

if [ "${#DIGEST_ENTRIES[@]}" -gt 0 ]; then
DIGEST_CSV="$(IFS=,; echo "${DIGEST_ENTRIES[*]}")"
IMAGE_TAG_WITH_DIGESTS="${IMAGE_TAG},${DIGEST_CSV}"
echo "Discovered digest pins for ${#DIGEST_ENTRIES[@]} image(s)"
else
echo "::warning::containers.txt downloaded but no valid digest entries matched ghcr.io/${REPO}/<image>@sha256:<digest>; falling back to tag-only image metadata."
fi
else
echo "::warning::No containers.txt found for ${VERSION}; falling back to tag-only image metadata"
fi

echo "image_tag=$IMAGE_TAG_WITH_DIGESTS" >> "$GITHUB_OUTPUT"

# Verify checksum
echo "Verifying SHA256 checksum..."

Expand Down Expand Up @@ -166,8 +204,8 @@ runs:
# Make executable
chmod +x "$INSTALL_DIR/awf"

# Clean up checksums file
rm -f "$INSTALL_DIR/checksums.txt"
# Clean up downloaded metadata files
rm -f "$INSTALL_DIR/checksums.txt" "$INSTALL_DIR/containers.txt"

# Add to PATH
echo "$INSTALL_DIR" >> "$GITHUB_PATH"
Expand All @@ -184,20 +222,30 @@ runs:
set -euo pipefail

REGISTRY="ghcr.io/github/gh-aw-firewall"

BASE_TAG="${IMAGE_TAG%%,*}"
extract_digest_from_tag() {
local key="$1"
echo "$IMAGE_TAG" | tr ',' '\n' | grep -E "^${key}=sha256:[a-fA-F0-9]{64}$" | cut -d'=' -f2 | tr '[:upper:]' '[:lower:]' | head -n 1
}
SQUID_DIGEST="$(extract_digest_from_tag squid || true)"
AGENT_DIGEST="$(extract_digest_from_tag agent || true)"
SQUID_REF="${REGISTRY}/squid:${BASE_TAG}${SQUID_DIGEST:+@${SQUID_DIGEST}}"
AGENT_REF="${REGISTRY}/agent:${BASE_TAG}${AGENT_DIGEST:+@${AGENT_DIGEST}}"

echo "Pulling awf Docker images with tag: ${IMAGE_TAG}"

# Pull squid image
echo "Pulling ${REGISTRY}/squid:${IMAGE_TAG}..."
if ! docker pull "${REGISTRY}/squid:${IMAGE_TAG}"; then
echo "::warning::Failed to pull squid image with tag ${IMAGE_TAG}, trying 'latest'"
echo "Pulling ${SQUID_REF}..."
if ! docker pull "${SQUID_REF}"; then
echo "::warning::Failed to pull squid image ${SQUID_REF}, trying 'latest'"
docker pull "${REGISTRY}/squid:latest"
fi

# Pull agent image
echo "Pulling ${REGISTRY}/agent:${IMAGE_TAG}..."
if ! docker pull "${REGISTRY}/agent:${IMAGE_TAG}"; then
echo "::warning::Failed to pull agent image with tag ${IMAGE_TAG}, trying 'latest'"
echo "Pulling ${AGENT_REF}..."
if ! docker pull "${AGENT_REF}"; then
echo "::warning::Failed to pull agent image ${AGENT_REF}, trying 'latest'"
docker pull "${REGISTRY}/agent:latest"
fi

Expand Down
2 changes: 1 addition & 1 deletion docs/github_actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ The action:
| Output | Description |
|--------|-------------|
| `version` | The version that was installed (e.g., `v0.7.0`) |
| `image-tag` | The image tag matching the version (e.g., `0.7.0`) |
| `image-tag` | Image tag metadata for runtime containers. Format: `0.7.0` or `0.7.0,squid=sha256:...,agent=sha256:...,api-proxy=sha256:...,agent-act=sha256:...,cli-proxy=sha256:...`. Supported digest keys currently include `squid`, `agent`, `api-proxy`, `agent-act`, and `cli-proxy`; additional keys may appear in future releases. |

#### Pinning Docker Image Versions

Expand Down
9 changes: 6 additions & 3 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,12 @@ Options:
ghcr.io/catthehacker/ubuntu:full-XX.XX
--image-registry <registry> Container image registry (default: ghcr.io/github/gh-aw-firewall)
--image-tag <tag> Container image tag (default: latest)
Image name varies by --agent-image preset:
default → agent:<tag>
act → agent-act:<tag>
Optional digest metadata:
<tag>,squid=sha256:...,agent=sha256:...,agent-act=sha256:...,api-proxy=sha256:...,cli-proxy=sha256:...
Supported digest metadata keys: squid, agent, agent-act, api-proxy, cli-proxy
Image name varies by --agent-image preset:
default → agent:<tag>
act → agent-act:<tag>
--skip-pull Use local images without pulling from registry (requires images to be
pre-downloaded) (default: false)
-e, --env <KEY=VALUE> Additional environment variables to pass to container (can be
Expand Down
10 changes: 8 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1369,7 +1369,9 @@ program
)
.option(
'--image-tag <tag>',
'Container image tag (applies to both squid and agent images)\n' +
'Container image tag (applies to squid, agent/agent-act, api-proxy, and cli-proxy when enabled)\n' +
' Optional digest metadata format:\n' +
' <tag>,squid=sha256:...,agent=sha256:...,agent-act=sha256:...,api-proxy=sha256:...,cli-proxy=sha256:...\n' +
' Image name varies by --agent-image preset:\n' +
' default → agent:<tag>\n' +
' act → agent-act:<tag>',
Expand Down Expand Up @@ -2270,7 +2272,11 @@ program
'Container image registry',
'ghcr.io/github/gh-aw-firewall'
)
.option('--image-tag <tag>', 'Container image tag (applies to squid, agent, and api-proxy images)', 'latest')
.option(
'--image-tag <tag>',
'Container image tag. Supports optional digest metadata: <tag>,squid=sha256:...,agent=sha256:...,api-proxy=sha256:...',
'latest'
)
.option(
'--agent-image <value>',
'Agent image preset (default, act) or custom image',
Expand Down
27 changes: 27 additions & 0 deletions src/commands/predownload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,27 @@ describe('predownload', () => {
]);
});

it('should append per-image digests from image-tag metadata', () => {
const images = resolveImages({
...defaults,
imageTag: [
'0.25.18',
'squid=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'agent=sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
'api-proxy=sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
'cli-proxy=sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
].join(','),
enableApiProxy: true,
difcProxy: true,
});
expect(images).toEqual([
'ghcr.io/github/gh-aw-firewall/squid:0.25.18@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'ghcr.io/github/gh-aw-firewall/agent:0.25.18@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
'ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
'ghcr.io/github/gh-aw-firewall/cli-proxy:0.25.18@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
]);
});

it('should use custom agent image as-is', () => {
const images = resolveImages({ ...defaults, agentImage: 'ubuntu:22.04' });
expect(images).toEqual([
Expand All @@ -93,6 +114,12 @@ describe('predownload', () => {
'must not contain whitespace',
);
});

it('should reject invalid image-tag digest metadata', () => {
expect(() =>
resolveImages({ ...defaults, imageTag: '0.25.18,squid=sha256:not-a-real-digest' })
).toThrow('Invalid --image-tag digest');
});
});

describe('predownloadCommand', () => {
Expand Down
10 changes: 6 additions & 4 deletions src/commands/predownload.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import execa from 'execa';
import { logger } from '../logger';
import { buildRuntimeImageRef, parseImageTag } from '../image-tag';

export interface PredownloadOptions {
imageRegistry: string;
Expand Down Expand Up @@ -27,16 +28,17 @@ function validateImageReference(image: string): void {
*/
export function resolveImages(options: PredownloadOptions): string[] {
const { imageRegistry, imageTag, agentImage, enableApiProxy } = options;
const parsedImageTag = parseImageTag(imageTag);
const images: string[] = [];

// Always pull squid
images.push(`${imageRegistry}/squid:${imageTag}`);
images.push(buildRuntimeImageRef(imageRegistry, 'squid', parsedImageTag));

// Pull agent image based on preset
const isPreset = agentImage === 'default' || agentImage === 'act';
if (isPreset) {
const imageName = agentImage === 'act' ? 'agent-act' : 'agent';
images.push(`${imageRegistry}/${imageName}:${imageTag}`);
images.push(buildRuntimeImageRef(imageRegistry, imageName, parsedImageTag));
} else {
// Custom image - validate and pull as-is
validateImageReference(agentImage);
Expand All @@ -45,12 +47,12 @@ export function resolveImages(options: PredownloadOptions): string[] {

// Optionally pull api-proxy
if (enableApiProxy) {
images.push(`${imageRegistry}/api-proxy:${imageTag}`);
images.push(buildRuntimeImageRef(imageRegistry, 'api-proxy', parsedImageTag));
}

// Optionally pull cli-proxy (mcpg is now started externally by the compiler)
if (options.difcProxy) {
images.push(`${imageRegistry}/cli-proxy:${imageTag}`);
images.push(buildRuntimeImageRef(imageRegistry, 'cli-proxy', parsedImageTag));
}

return images;
Expand Down
31 changes: 31 additions & 0 deletions src/docker-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,37 @@ describe('docker-manager', () => {
expect(result.services.agent.build).toBeUndefined();
});

it('should append per-image digests from image-tag metadata', () => {
const customConfig = {
...mockConfig,
enableApiProxy: true,
imageTag: [
'v1.0.0',
'squid=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'agent=sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
'api-proxy=sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
].join(','),
};
const networkWithProxy = {
...mockNetworkConfig,
proxyIp: '172.30.0.30',
};
const result = generateDockerCompose(customConfig, networkWithProxy);

expect(result.services['squid-proxy'].image).toBe(
'ghcr.io/github/gh-aw-firewall/squid:v1.0.0@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
);
expect(result.services.agent.image).toBe(
'ghcr.io/github/gh-aw-firewall/agent:v1.0.0@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
);
expect(result.services['iptables-init'].image).toBe(
'ghcr.io/github/gh-aw-firewall/agent:v1.0.0@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
);
expect(result.services['api-proxy'].image).toBe(
'ghcr.io/github/gh-aw-firewall/api-proxy:v1.0.0@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'
);
});

it('should build locally with custom catthehacker full image', () => {
const customConfig = {
...mockConfig,
Expand Down
13 changes: 7 additions & 6 deletions src/docker-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { generateSquidConfig, generatePolicyManifest } from './squid-config';
import { generateSessionCa, initSslDb, CaFiles, parseUrlPatterns, cleanupSslKeyMaterial, unmountSslTmpfs } from './ssl-bump';
import { DEFAULT_DNS_SERVERS } from './dns-resolver';
import { PROXY_ENV_VARS } from './upstream-proxy';
import { parseImageTag, buildRuntimeImageRef } from './image-tag';

const SQUID_PORT = 3128;

Expand Down Expand Up @@ -618,7 +619,7 @@ export function generateDockerCompose(
// Default to GHCR images unless buildLocal is explicitly set
const useGHCR = !config.buildLocal;
const registry = config.imageRegistry || 'ghcr.io/github/gh-aw-firewall';
const tag = config.imageTag || 'latest';
const parsedImageTag = parseImageTag(config.imageTag || 'latest');

// Squid logs path: use proxyLogsDir if specified (direct write), otherwise workDir/squid-logs
const squidLogsPath = config.proxyLogsDir || `${config.workDir}/squid-logs`;
Expand Down Expand Up @@ -726,7 +727,7 @@ export function generateDockerCompose(
// Use GHCR image or build locally
// For SSL Bump, we always build locally to include OpenSSL tools
if (useGHCR && !config.sslBump) {
squidService.image = `${registry}/squid:${tag}`;
squidService.image = buildRuntimeImageRef(registry, 'squid', parsedImageTag);
} else {
squidService.build = {
context: path.join(projectRoot, 'containers/squid'),
Expand Down Expand Up @@ -1590,8 +1591,8 @@ export function generateDockerCompose(
// Use pre-built GHCR image for preset images
// The GHCR images already have the necessary setup for chroot mode
const imageName = agentImage === 'act' ? 'agent-act' : 'agent';
agentService.image = `${registry}/${imageName}:${tag}`;
logger.debug(`Using GHCR image ${imageName}:${tag}`);
agentService.image = buildRuntimeImageRef(registry, imageName, parsedImageTag);
logger.debug(`Using GHCR image ${agentService.image}`);
} else if (config.buildLocal || !isPreset) {
// Build locally when:
// 1. --build-local is explicitly specified, OR
Expand Down Expand Up @@ -1781,7 +1782,7 @@ export function generateDockerCompose(

// Use GHCR image or build locally
if (useGHCR) {
proxyService.image = `${registry}/api-proxy:${tag}`;
proxyService.image = buildRuntimeImageRef(registry, 'api-proxy', parsedImageTag);
} else {
proxyService.build = {
context: path.join(projectRoot, 'containers/api-proxy'),
Expand Down Expand Up @@ -1993,7 +1994,7 @@ export function generateDockerCompose(

// Use GHCR image or build locally for the Node.js HTTP server container
if (useGHCR) {
cliProxyService.image = `${registry}/cli-proxy:${tag}`;
cliProxyService.image = buildRuntimeImageRef(registry, 'cli-proxy', parsedImageTag);
} else {
cliProxyService.build = {
context: path.join(projectRoot, 'containers/cli-proxy'),
Expand Down
Loading
Loading