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
2 changes: 1 addition & 1 deletion .github/workflows/test-integration-suite.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ jobs:
run: |
echo "=== Running network tests ==="
npm run test:integration -- \
--testPathPatterns="(ipv6|localhost-access|network-security)" \
--testPathPatterns="(ipv6|localhost-access|network-security|topology-compose)" \
--verbose
env:
JEST_TIMEOUT: 180000
Expand Down
168 changes: 156 additions & 12 deletions docs-site/src/content/docs/guides/playwright-testing.md
Original file line number Diff line number Diff line change
@@ -1,32 +1,157 @@
---
title: Playwright Testing with Localhost
description: Test local web applications with Playwright through the firewall
title: Playwright Testing with Local Services
description: Test in-sandbox servers, host services, and Docker Compose stacks through the firewall
---

The firewall makes it easy to test local web applications with Playwright using the `localhost` keyword.
Choose the address according to where the server runs:

## Quick Start
| Server location | Browser URL |
| --- | --- |
| Same agent sandbox as Playwright | `http://127.0.0.1:3000` |
| Host-run Compose container attached to `awf-net` | `http://awf-compose-web:80` (container name and container port) |
| Runner host, with host access enabled | `http://host.docker.internal:3000` (host port, through Squid in topology mode) |

Test a local development server with Playwright:
`localhost` inside the browser is the **sandbox's loopback**, not the runner's.
The standalone AWF CLI's `--allow-domains localhost` keyword configures host
access; it does **not** rewrite `page.goto()` URLs or Playwright's `baseURL`.
For gh-aw's usual in-sandbox development server, use loopback without enabling
host access.

## Host-run Docker Compose stack (topology attachment)

Attach only the trusted frontend container, not the whole stack. AWF adds a
second interface on `awf-net`; the frontend keeps its original Compose network
and can still reach its backend services there. The agent does not join the
Compose network. This recipe uses the Compose-managed Docker agent runtime,
not a microVM runtime.

Start this minimal stack **on the runner host before AWF starts**:

```yaml
# compose.yml
services:
web:
image: nginx:stable-alpine
container_name: awf-compose-web
```

```bash
docker compose -f compose.yml up -d --wait
```

No published host port is needed. In an existing stack, give the frontend a
stable `container_name` and leave its Compose networks and backend configuration
unchanged. It must listen on its container interface (for example `0.0.0.0:80`),
not only its own loopback. Containers using `network_mode: host` or `none` cannot
be attached this way.

Prepare Playwright and its browser before entering the sandbox. For a project
with `@playwright/test` already installed, add:

```typescript
// tests/compose.spec.ts
import { test, expect } from '@playwright/test';

test('host-run frontend is reachable', async ({ page }) => {
await page.goto('http://awf-compose-web:80');
await expect(page).toHaveTitle('Welcome to nginx!');
});
```

```bash
awf --network-isolation \
--topology-attach awf-compose-web \
--allow-domains awf-compose-web \
-- npx playwright test tests/compose.spec.ts

# After AWF exits, remove the host-run stack.
docker compose -f compose.yml down
```

Use the **Docker container name**, not the Compose service key (`web`) or a
host-published port. AWF attaches the existing container after creating
`awf-net` and installs its name-to-IP mapping in both the agent's chroot hosts
file and Squid's hosts file. Prefer the name over the dynamically assigned
`awf-net` IP; do not use the container's Compose-network IP. While AWF is running,
inspect the address with:

```bash
docker inspect --format '{{(index .NetworkSettings.Networks "awf-net").IPAddress}}' awf-compose-web
```

Keep the frontend name in the allowlist, including any additional browser-visible
asset/API names. In current AWF, attached peer names are also automatically
included in `NO_PROXY` and trusted Squid peer rules, so both direct clients and
clients that ignore `NO_PROXY` can reach them. Those peer rules permit **any
port**; `--allow-host-ports` does not restrict an attached peer. Attachment is a
trust grant, not a domain/port-isolated tunnel.

This is a **standalone AWF CLI** recipe. Its configuration file is:

```yaml
network:
isolation: true
topologyAttach: [awf-compose-web]
allowDomains: [awf-compose-web]
```

The current gh-aw compiler-launched AWF handoff does not support arbitrary
`topologyAttach` entries: it attaches its managed peers (such as `awmg-mcpg` and
`awmg-cli-proxy`) and provides no workflow field for `awf-compose-web`. Starting
the stack in a host-side `steps:` block does not make it available to that AWF
run. Use this recipe only when the standalone AWF CLI consumes the configuration
directly; gh-aw needs compiler support for arbitrary attachments first.

:::caution
Only attach a frontend you trust. A dual-homed container can reach the agent
network, its Compose backends, and potentially the internet independently of
AWF. An arbitrary forward proxy, SSRF endpoint, or compromised frontend can
therefore become a firewall bypass. Do not execute untrusted PR-controlled
Compose files, images, entrypoints, or builds in privileged host steps with
secrets or the Docker socket. Use reviewed infrastructure and disposable test
data, and expose no generic forwarding endpoint. Keep the agent firewall
enabled: `sandbox.agent: false` removes its protection rather than fixing
connectivity.
:::

## Quick Start: Host Development Server

For a host development server, configure Playwright's URL and browser proxy
explicitly (the remaining host-access examples use this configuration):

```typescript
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
use: {
baseURL: 'http://host.docker.internal:3000',
proxy: { server: process.env.HTTP_PROXY! },
},
});
```

```bash
# Start your dev server (e.g., npm run dev on port 3000)
# Start your dev server on the host, listening on a Docker-reachable interface
# (e.g., npm run dev -- --host 0.0.0.0 on port 3000)

# Run Playwright tests through the firewall
sudo awf --allow-domains localhost,playwright.dev -- npx playwright test
```

The `localhost` keyword automatically configures everything needed for local testing - no manual setup required.
The `localhost` keyword configures AWF's host mapping and development ports.
Tests can then navigate using relative paths such as `page.goto('/')`.

## What the localhost Keyword Does

When you include `localhost` in `--allow-domains`, awf automatically:

1. **Enables host access** - Activates `--enable-host-access` flag
2. **Maps to host.docker.internal** - Replaces `localhost` with Docker's host gateway
2. **Maps the allowlist entry to host.docker.internal** - Does not rewrite browser URLs
3. **Allows development ports** - Opens common dev ports: 3000, 3001, 4000, 4200, 5000, 5173, 8000, 8080, 8081, 8888, 9000, 9090

This means your Playwright tests inside the container can reach services running on your host machine's localhost.
Your tests must use `http://host.docker.internal:<port>` to reach the host.
The server must listen on an address reachable from Docker, not only `127.0.0.1`.

## Protocol Prefixes

Expand Down Expand Up @@ -78,7 +203,8 @@ sudo awf \
-- npx playwright test
```

Your Playwright tests can now access `http://localhost:3000` and also fetch from `vercel.app` and `next.js.org`.
Set Playwright's `baseURL` to `http://host.docker.internal:3000`; the tests can
also fetch from `vercel.app` and `next.js.org`.

## Example: Testing a React App with External APIs

Expand Down Expand Up @@ -132,8 +258,26 @@ If Playwright can't connect to your local server:

If you see DNS resolution errors:

- Use `localhost` (not `127.0.0.1` or your machine's hostname)
- The `localhost` keyword maps to `host.docker.internal` which resolves to the host
- Use the attached container name for a Compose frontend, or
`host.docker.internal` for a host service, not the browser's `localhost`.
- For `503 ERR_DNS_FAIL` from Squid on `host.docker.internal`, enable
`--enable-host-access` and verify the effective config and Squid's `/etc/hosts`.
This flag adds Docker's `host-gateway` mapping, which Squid consults before
upstream DNS. The current CLI also auto-enables host access for an exact
`host.docker.internal` allowlist entry (with or without an HTTP/HTTPS prefix)
or its `localhost` keyword. Those entries are themselves host-access grants;
only `localhost` also supplies default development ports. Upstream DNS cannot
resolve the Docker-only hostname.
- Specify the host-published port with `--allow-host-ports` and allow
`host.docker.internal`. In topology mode, the agent has no direct host route;
the HTTP client/browser must use Squid. For Playwright Test, explicitly
configure `use.proxy: { server: process.env.HTTP_PROXY! }` for this host-service
path; proxy environment variables alone are not a browser proxy configuration.
A loopback-only published port such as `127.0.0.1:8080:80` is not reachable via
the host gateway. Prefer topology attachment instead of broadening host exposure.
- On ARC/DinD, `host-gateway` refers to the Docker daemon's host, not necessarily
the runner container. Start and attach the Compose stack on the same daemon
AWF uses.

### Server binds to 127.0.0.1 only

Expand Down
20 changes: 16 additions & 4 deletions docs-site/src/content/docs/guides/server-connectivity.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,27 @@ The firewall only allows ports 80 (HTTP) and 443 (HTTPS). Non-standard gRPC port
Use `host.docker.internal` to connect from inside awf to services running on your host machine:

```bash
# Connect to a server running on the host (e.g., localhost:3000)
sudo awf --allow-domains host.docker.internal -- \
curl http://host.docker.internal:3000/api
# Connect through Squid to a host listener reachable on the Docker gateway
awf --enable-host-access --allow-host-ports 3000 \
--allow-domains host.docker.internal -- \
'curl --noproxy "" --proxy "$HTTP_PROXY" http://host.docker.internal:3000/api'
```

:::tip
`host.docker.internal` is automatically configured in awf containers and resolves to the host machine.
`--enable-host-access` installs Docker's `host-gateway` mapping in Squid.
The current CLI also auto-enables host access for an exact `host.docker.internal`
allowlist entry; it is itself a host-access grant. If you see `503 ERR_DNS_FAIL`,
check Squid's `/etc/hosts` mapping and the effective host-access config rather
than relying on upstream DNS to resolve this Docker-only name.
The host listener must not bind only to `127.0.0.1`; in topology mode clients
must use the HTTP proxy because the agent has no direct host route.
:::

For a host-run Docker Compose stack, prefer
[attaching its trusted frontend to `awf-net`](/gh-aw-firewall/guides/playwright-testing#host-run-docker-compose-stack-topology-attachment).
Use its container name and container port while retaining the Compose backend
network; no host-published port is needed.

## Server inside, client outside

To run a server inside awf that accepts external connections, use `--keep-containers` and connect via Docker:
Expand Down
9 changes: 5 additions & 4 deletions docs-site/src/content/docs/reference/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -563,12 +563,13 @@ Enable access to host services via `host.docker.internal`. This allows container

```bash
# Access local development server
sudo awf --enable-host-access --allow-domains host.docker.internal \
sudo awf --enable-host-access --allow-host-ports 3000 \
--allow-domains host.docker.internal \
-- curl http://host.docker.internal:3000
```

:::danger[Security Warning]
When `--enable-host-access` is enabled, containers can access services on the host machine. By default, only ports 80 and 443 are allowed. Use `--allow-host-ports` to allow additional ports.
When `--enable-host-access` is enabled, containers can access services on the host machine. Ports 80 and 443 are allowed by default; use `--allow-host-ports` to add non-standard ports. In network-isolation mode, host HTTP/HTTPS requests go through Squid; this does not provide a direct host route for raw-protocol clients.
:::

**See also:** [Host Access Configuration](/gh-aw-firewall/docs/usage/#host-access)
Expand All @@ -590,8 +591,8 @@ sudo awf --allow-domains localhost --allow-host-ports 3000 \
```

**Default behavior:**
- Without `--allow-host-ports`: Only ports 80 and 443 are allowed
- With `--allow-host-ports`: Only the specified ports are allowed
- Ports 80 and 443 remain allowed by default
- `--allow-host-ports` adds ports to Squid's allowed-port list; it does not replace 80 and 443

### `--allow-host-service-ports <ports>`

Expand Down
73 changes: 64 additions & 9 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,9 @@ Options:
--network-isolation Experimental: enforce egress via Docker network topology (an
internal network with no internet route plus a dual-homed Squid
proxy) instead of host iptables. Requires no sudo / NET_ADMIN, so it
works inside ARC / Kubernetes DinD runners. Not yet supported with
--dns-over-https or --enable-host-access. (default: false)
works inside ARC / Kubernetes DinD runners. Host HTTP(S) access uses
Squid; no direct host route is provided. Not yet supported with
--dns-over-https. (default: false)
--verify-sbx-egress Fail before agent startup unless Docker sbx blocks direct
non-proxy HTTPS egress. Requires --container-runtime sbx.
--topology-attach <name> With --network-isolation, attach an externally-launched trusted
Expand Down Expand Up @@ -453,25 +454,79 @@ sudo awf \

When running MCP gateways or other services on your host machine that need to be accessible from inside the firewall, use the `--enable-host-access` flag.

### Host-run Docker Compose frontends

For a Compose stack started on the runner host, prefer attaching its trusted
frontend container to AWF's internal network over exposing host ports:

```bash
# Run on the host before AWF; the frontend has container_name: awf-compose-web.
docker compose -f compose.yml up -d --wait
awf --network-isolation \
--topology-attach awf-compose-web \
--allow-domains awf-compose-web \
-- 'curl --fail http://awf-compose-web:80'
docker compose -f compose.yml down
```

The frontend retains its Compose network and backend connectivity. AWF connects
only the named container to `awf-net`, then maps that name to its `awf-net` IP
for both the agent and Squid. Use the container name and **container port**, not
`localhost`, the Compose service key, or the published host port. The agent's
loopback is not the host's. Attached peers are trusted: current AWF adds their
names to `NO_PROXY` and Squid peer allow rules on **any port**, independently of
`--allow-host-ports`. Do not attach untrusted PR-controlled services or generic
forward proxies, which could provide an alternate egress path.

See the [Compose + Playwright example](../docs-site/src/content/docs/guides/playwright-testing.md#host-run-docker-compose-stack-topology-attachment)
for the minimal Compose file, browser test, AWF config, cleanup, and trust-boundary
guidance. This path requires the stack and AWF to use the same Docker daemon.

### Host-gateway DNS and browser URLs

Squid needs Docker's `host-gateway` mapping to resolve `host.docker.internal`.
`--enable-host-access` adds `host.docker.internal:host-gateway` to Squid's
`extra_hosts`; the current CLI also automatically enables host access for an
exact `host.docker.internal` allowlist entry (including HTTP/HTTPS-prefixed
forms). Thus the hostname itself is a host-access opt-in, not just a DNS name.
Squid reads `/etc/hosts` before upstream DNS. If a proxied request fails with
`503 ERR_DNS_FAIL`, check the effective host-access config and Squid's
`/etc/hosts` mapping. An upstream DNS server cannot resolve this Docker-only
name. Specify the host-access intent, domain, and required port explicitly:

```bash
awf --enable-host-access --allow-host-ports 8080 \
--allow-domains host.docker.internal \
-- 'curl --fail --noproxy "" --proxy "$HTTP_PROXY" http://host.docker.internal:8080'
```

In topology mode this is an HTTP/HTTPS **proxy path**, not a direct host route.
Playwright's browser must use Squid and navigate to
`http://host.docker.internal:8080`; neither the CLI's `localhost` keyword nor
`--allow-host-ports` rewrites browser URLs. The host listener must be reachable
on the gateway interface, not bound only to `127.0.0.1`. Under DinD, the gateway
is the Docker daemon's host, which may differ from the runner.

### Enabling Host Access

```bash
# Enable access to services running on the host via host.docker.internal
sudo awf \
--enable-host-access \
--allow-domains host.docker.internal \
-- curl http://host.docker.internal:8080
--allow-host-ports 8080 \
-- 'curl --noproxy "" --proxy "$HTTP_PROXY" http://host.docker.internal:8080'
```

### Security Considerations

> ⚠️ **Security Warning**: When `--enable-host-access` is enabled, containers can currently access ANY port on services running on the host machine via `host.docker.internal`. This includes databases, admin panels, and other sensitive services.
> ⚠️ **Security Warning**: Host access exposes services reachable on the permitted host ports, including unrelated services on those ports. Treat the host as a trust boundary.
>
> **Port restrictions:** Use `--allow-host-ports` to explicitly restrict which ports can be accessed (e.g., `--allow-host-ports 80,443,8080`). A future update will make port restrictions the default behavior.
> **Port restrictions:** Squid permits ports 80 and 443 by default. `--allow-host-ports` adds ports to Squid's `Safe_ports` for allowlisted destinations; it does not restrict attached peers or create a direct host route in topology mode.
>
> Only enable this for trusted workloads like MCP gateways or local testing with Playwright.

**Why opt-in?** By default, `host.docker.internal` hostname resolution is disabled to prevent containers from accessing host services. This is a defense-in-depth measure against malicious code attempting to access local resources.
**Why opt-in?** By default, `host.docker.internal` hostname resolution is disabled to prevent containers from accessing host services. Passing `--enable-host-access` or an exact host-gateway keyword in the allowlist opts in to this access.

### Example: MCP Gateway on Host

Expand All @@ -487,9 +542,9 @@ sudo awf \
-- 'copilot --mcp-gateway http://host.docker.internal:8080 --prompt "test"'
```

**Note:** When `--enable-host-access` is enabled without `--allow-host-ports`, all ports on `host.docker.internal` are currently allowed. Use `--allow-host-ports` to explicitly restrict which ports can be accessed (e.g., `--allow-host-ports 80,443,8080` for web services and an MCP gateway).

> **Security Note:** A future update will change the default behavior to only allow ports 80 and 443 unless `--allow-host-ports` is specified. Explicitly set `--allow-host-ports` now to ensure consistent behavior across versions.
**Note:** Non-standard HTTP/HTTPS host ports require `--allow-host-ports`
(for example `8080` for an MCP gateway). This proxy configuration does not make
raw-protocol clients such as `psql` proxy-aware.

### Example: GitHub Actions `services:` Container in Strict Mode

Expand Down
3 changes: 2 additions & 1 deletion src/cli-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,8 @@ program
'--network-isolation',
'Enforce egress via Docker network topology (internal network +\n' +
' dual-homed proxy) instead of iptables. Requires no sudo/NET_ADMIN.\n' +
' Not yet supported with --dns-over-https or --enable-host-access.\n' +
' Host HTTP(S) access uses Squid; no direct host route is provided.\n' +
' Not yet supported with --dns-over-https.\n' +
' Enabled by default (strict security).'
)
.option(
Expand Down
Loading
Loading