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
5 changes: 3 additions & 2 deletions .github/scripts/podman-host-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -267,9 +267,10 @@ let mounts = [];
try {
mounts = JSON.parse(await inspect(proxy, "{{json .Mounts}}"));
} catch {}
// Both of the proxy's sources; the receiver's triggers.json carries the same option but is not started here.
// Each of the proxy's sources (the third, the model endpoints' include, since issue #503); the receiver's triggers.json
// carries the same option but is not started here.
const composeSources = [];
for (const destination of ["/etc/squid/squid.conf", "/etc/pi-dispatch/allowlist.conf"]) {
for (const destination of ["/etc/squid/squid.conf", "/etc/pi-dispatch/allowlist.conf", "/etc/pi-dispatch/model-endpoints.conf"]) {
const source = mounts.find((m) => m.Destination === destination)?.Source;
composeSources.push({ source: source ?? `no ${destination} mount`, label: source ? await labelOf(source) : "" });
}
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/deploy-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,9 @@ jobs:
set -eu
units="$RUNNER_TEMP/quadlet"
mkdir -p "$units"
# A deployment folder the planner accepts, with the allowlist the proxy mounts.
# A deployment folder the planner accepts, with the allowlist and the model endpoints' include the proxy mounts.
deploy="$RUNNER_TEMP/deploy"
mkdir -p "$deploy" && : > "$deploy/egress-allowlist.conf"
mkdir -p "$deploy" && : > "$deploy/egress-allowlist.conf" && : > "$deploy/model-endpoints.conf"
node --input-type=module -e "
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { basename, join, resolve } from 'node:path';
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/podman-conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ jobs:
as-pdjob podman run -d --name pi-dispatch-egress-proxy --network pi-dispatch-egress-out \
-v "/home/$PDJOB/repo/deploy/egress-proxy.conf:/etc/squid/squid.conf:ro,z" \
-v "/home/$PDJOB/deploy/egress-allowlist.conf:/etc/pi-dispatch/allowlist.conf:ro,z" \
-v "/home/$PDJOB/deploy/model-endpoints.conf:/etc/pi-dispatch/model-endpoints.conf:ro,z" \
"docker.io/$image"
for i in $(seq 1 60); do
if as-pdjob podman exec pi-dispatch-egress-proxy bash -c 'exec 3<>/dev/tcp/127.0.0.1/3128' 2>/dev/null; then
Expand Down
3 changes: 2 additions & 1 deletion admin/src/setup-wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1021,7 +1021,8 @@ async function offerTriggerEdge(
//
// Into `<dir>/deploy/`, the layout the file is written for: its relative paths resolve against its OWN directory
// (the Compose spec, for `env_file` and bind mounts alike), and it names `../.env`, `../triggers.json`,
// `../egress-allowlist.conf` and `./egress-proxy.conf`. Copied to `<dir>/docker-compose.yml`, as it was until issue
// `../egress-allowlist.conf`, `../model-endpoints.conf` (issue #503, scaffolded by `init`, which `up` runs earlier in
// this wizard) and `./egress-proxy.conf`. Copied to `<dir>/docker-compose.yml`, as it was until issue
// #468's follow-up, every `../` reached the folder ABOVE the deployment, so the receiver container read another
// directory's .env and triggers. Beside it goes the proxy's rules file its `./egress-proxy.conf` names, also
// create-only, so `--profile egress` finds it too. The command is then the one the docs give, run from `dir`:
Expand Down
4 changes: 3 additions & 1 deletion admin/test/setup-wizard.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -934,7 +934,7 @@ test("wizard: every relative path in the compose file it writes resolves inside
const rel = [];
for (const m of code.matchAll(/^\s*env_file:\s*(\S+)\s*$/gm)) rel.push(m[1]);
for (const m of code.matchAll(/^\s*-\s*"?(\.{1,2}\/[^:"\s]+):/gm)) rel.push(m[1]);
assert.deepEqual(rel.sort(), ["../.env", "../egress-allowlist.conf", "../triggers.json", "./egress-proxy.conf"], "the scan reaches every relative path the shipped file names");
assert.deepEqual(rel.sort(), ["../.env", "../egress-allowlist.conf", "../model-endpoints.conf", "../triggers.json", "./egress-proxy.conf"], "the scan reaches every relative path the shipped file names");
const { resolve, dirname, sep } = await import("node:path");
for (const p of rel) {
const at = resolve(dirname(written), p);
Expand All @@ -943,6 +943,8 @@ test("wizard: every relative path in the compose file it writes resolves inside
// And each lands on what the deployment holds under that name.
assert.ok(existsSync(resolve(dirname(written), "./egress-proxy.conf")), "the rules file beside it");
assert.equal(resolve(dirname(written), "../.env"), join(dir, ".env"), "the .env `--env-file .env` names from the folder");
// Issue #503: the model endpoints' include is the deployment folder's, where `init` scaffolds it.
assert.equal(resolve(dirname(written), "../model-endpoints.conf"), join(dir, "model-endpoints.conf"));
});

test("wizard: the edge's compose answer degrades when the runtime ships no compose file", async () => {
Expand Down
12 changes: 12 additions & 0 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,18 @@ services:
# never clobbers an edited allowlist. Mounting a path that does not exist makes Docker create it as a
# DIRECTORY and squid then fails confusingly, which is why init writes it first.
- ../egress-allowlist.conf:/etc/pi-dispatch/allowlist.conf:ro,z
# The declared model endpoints' rules (issue #503), rendered from model-endpoints.json by
# `pi-dispatch egress render` and read by the rules' `include`. `init` scaffolds it empty. It must exist:
# squid refuses to start when an included file is missing. After a render, reload with
# `docker exec pi-dispatch-egress-proxy squid -k reconfigure`, not a restart.
- ../model-endpoints.conf:/etc/pi-dispatch/model-endpoints.conf:ro,z
# How the proxy reaches a model server on this host (issue #503). On Docker Engine (Linux) this name does
# not exist without the line, and host-gateway is the bridge gateway (docker0, 172.17.0.1). Docker
# Desktop already resolves the name, and host-gateway there is the same host address, so the line is kept
# for both. It is on the PROXY only: a job never gets it, and reaches the host only through a declared
# endpoint's CONNECT rule.
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- egress-out
restart: unless-stopped
Expand Down
11 changes: 10 additions & 1 deletion deploy/egress-proxy.conf
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
# on port 443, or a plain (untunnelled) request to a listed host on port 80. Nothing else. The
# provider, the forge and the registry are ordinary entries in the allowlist, and nothing is
# special. The one address-based rule is a DENY: a listed name that resolves to a fixed loopback
# or link-local address of this host is refused.
# or link-local address of this host is refused. Beside those, a model server you declare (issue
# #503) is a CONNECT tunnel to its one host and port, from the include below, and nothing else.
# - TLS is NEVER terminated. This proxy sees the name a client asks for and no byte inside the tunnel,
# so it cannot read a credential and cannot account for a token. A proxy that decrypts provider
# traffic is OQ-011's mechanism, a materially larger change, and it is not this.
Expand Down Expand Up @@ -71,6 +72,14 @@ acl to_host_local dst 127.0.0.0/8 0.0.0.0/32 169.254.0.0/16 10.0.2.2/32 ::1 ::/1
# so no unlisted name is resolved by it. It never matches a CONNECT, so the tunnel rules (including a
# CONNECT allow later placed between the first two rules, see #503) are untouched by it.
http_access deny allowed to_host_local
# The declared model endpoints (issue #503): `model-endpoints.conf` in your deployment folder, rendered
# from model-endpoints.json by `pi-dispatch egress render`, empty by default. It only adds a CONNECT
# allow for each declared host and port pair, and a deny for such a host that resolves to loopback.
# It sits BEFORE the next line on purpose: pi tunnels every provider call, plain http:// included, so a
# call to a model server arrives as a CONNECT to its port, and first match wins, so the port-443 deny
# below would refuse it before any later allow was read. The file must exist (squid will not start
# without it). After a render, reload with `squid -k reconfigure` in this container, not a restart.
include /etc/pi-dispatch/model-endpoints.conf
http_access deny CONNECT !SSL_ports
http_access allow CONNECT allowed
http_access deny !Safe_ports !CONNECT
Expand Down
10 changes: 8 additions & 2 deletions deploy/pi-dispatch-egress-proxy.container
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
# this account's own rootless Podman.
#
# Installed by `pi-dispatch service install` (user scope, with podman in PI_BACKENDS and PI_EGRESS armed) and by
# `pi-dispatch up`. Unlike the Valkey unit this one is RENDERED: the two /opt/pi-dispatch paths below are
# `pi-dispatch up`. Unlike the Valkey unit this one is RENDERED: the three /opt/pi-dispatch paths below are
# placeholders, replaced with an account-owned copy of the package's egress-proxy.conf (written beside this account's
# config, because `z` cannot relabel a root-owned package file: measured, exit 126) and with the deployment folder's
# egress-allowlist.conf. Never `systemctl --user enable` it (a generated unit refuses that); the [Install] section is
# egress-allowlist.conf and model-endpoints.conf. Never `systemctl --user enable` it (a generated unit refuses that); the [Install] section is
# read by the generator itself.
#
# A RESTART DROPS EVERY PER-JOB NETWORK (measured on Podman 5.8.1). The generated unit runs `podman run --replace
Expand Down Expand Up @@ -34,6 +34,12 @@ Network=pi-dispatch-egress-out.network
# on a host that also runs the compose proxy the same allowlist file is mounted by both.
Volume=/opt/pi-dispatch/deploy/egress-proxy.conf:/etc/squid/squid.conf:ro,z
Volume=/opt/pi-dispatch/egress-allowlist.conf:/etc/pi-dispatch/allowlist.conf:ro,z
# The declared model endpoints' rules (issue #503), the deployment folder's own file like the list, never a copy:
# `pi-dispatch egress render` rewrites it in place, and a copy would not be the file the render wrote. `init`
# scaffolds it empty. It must exist, since squid will not start when an included file is missing. After a render,
# `podman exec pi-dispatch-egress-proxy squid -k reconfigure` reloads it; a restart is not needed and drops every job
# network (above). No host-gateway line: rootless Podman resolves host.containers.internal with no flag.
Volume=/opt/pi-dispatch/model-endpoints.conf:/etc/pi-dispatch/model-endpoints.conf:ro,z
# The exec form, a JSON array, for the reason the compose file gives: a plain string runs under /bin/sh, which on
# this image is dash, where /dev/tcp does not exist and the check would fail forever while squid serves.
HealthCmd=["bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/3128"]
Expand Down
73 changes: 68 additions & 5 deletions docs/egress.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ pi-dispatch doctor
```

`up` shows the proxy's `docker run` and asks before it runs it, and it works in any folder `pi-dispatch init`
made, since init writes both files the proxy mounts. From a clone, or a folder holding `deploy/docker-compose.yml`,
made, since init writes the three files the proxy mounts. From a clone, or a folder holding `deploy/docker-compose.yml`,
compose starts the same proxy instead:

```bash
Expand Down Expand Up @@ -54,7 +54,11 @@ symlink. squid reads its rules only at start, and a running or paused container
started with, so `up` then offers `docker restart pi-dispatch-egress-proxy` for a proxy already running, and
`docker unpause` followed by that restart for a paused one (asked, not taken by `--yes`, while jobs are
attached to it); a stopped proxy it starts, or one it creates or replaces, in the same pass reads the new
file anyway. On the Podman venue the rules live in an account-owned
file anyway. **Rules that include `model-endpoints.conf` (issue #503) go in only together with a proxy that
mounts it**: a proxy made before #503 mounts two files, and a restart on the new rules would exit on the
missing include. So for such a proxy `up` asks once for both, the refresh and the proxy's replacement. If you
decline, or the replace cannot happen, the rules are not written; if the new proxy fails to start, the old
rules are put back. On the Podman venue the rules live in an account-owned
copy instead, which `doctor` compares the same way and `pi-dispatch service install --force` refreshes
([`podman.md`](podman.md)).

Expand All @@ -77,12 +81,66 @@ calls an API you added, or installs from a private registry, reaches hosts that
`doctor` names what it can. The rest you have to know, and a browsing flow will drive an allowlist wider
than everything else combined.

## Local model servers

A job can reach a model server on your own machine or LAN (Ollama, vLLM, llama.cpp, LM Studio) through the
proxy, one host and one port at a time. A fuller guide comes with the doctor probes for it; this is the short
form.

**Jobs cannot use a local model yet.** This part of #503 opens the route through the proxy. The keyless credential
gate and the slot leases come in later parts of the same issue; until then a job's provider check still refuses
a provider without a key, and a declared endpoint takes no slot.

1. **Declare it** in `model-endpoints.json` in the deployment folder. `pi-dispatch init` writes it empty.

```json
{ "version": 1, "endpoints": [ { "id": "mac-ollama", "host": "host.docker.internal", "port": 11434, "slots": 2 } ] }
```

2. **Render** the proxy's rules for it, from the deployment folder:

```sh
pi-dispatch egress render
```

It writes `model-endpoints.conf` in the deployment folder (also when `PI_MODEL_ENDPOINTS_FILE` names a JSON
file elsewhere), in place, and prints the reload. The file is the proxy's
include: every endpoint becomes a CONNECT tunnel to exactly that host and port, and nothing else. It must
exist even with nothing declared, because squid will not start without it. `init` writes it.

3. **Reload** the proxy with the command it printed:

```sh
docker exec pi-dispatch-egress-proxy squid -k reconfigure # Docker
podman exec pi-dispatch-egress-proxy squid -k reconfigure # the rootless podman venue
```

A reload keeps running jobs and their tunnels. Do not restart the proxy for this: a restart cuts every running
job off.

How the proxy reaches your own machine:

- **Docker Desktop**: `host.docker.internal` just works, and it reaches the Mac's loopback too, so a server bound
to `127.0.0.1` answers (measured).
- **Docker Engine (Linux)**: the proxy is started with `host.docker.internal:host-gateway`, which is the bridge
gateway, so the server must listen on that address (`172.17.0.1`, for Ollama `OLLAMA_HOST=172.17.0.1:11434`) or
on `0.0.0.0`. A server bound to `127.0.0.1` cannot be reached.
- **Rootless Podman**: use `host.containers.internal`, with the server bound to the host's LAN address or to
`0.0.0.0`. A server bound to `127.0.0.1` cannot be reached.

An existing deployment needs the new file, the new rules and the new mount: run `pi-dispatch init` (it only adds
what is missing), then `pi-dispatch up`, which offers the rules refresh and the proxy's replacement as one step.
On the rootless podman venue `pi-dispatch service install --force` refreshes the account's rules and the unit
together. With compose, use the new compose file (see "Right after an upgrade" below), then
`docker compose ... --profile egress up -d` recreates the proxy. Until the rules include the file, `up`, `doctor`
and `egress render` all say the endpoints stay unreachable until the rules are refreshed.

## The shape

| | |
|---|---|
| One `--internal` network **per job** | `pi-job-<id>-net`, created at job start and removed at job end. Holds exactly two endpoints: the container and the proxy. If the worker dies before it can remove one, the next boot removes it, detaching whatever is still on it first, and says so in the log if it cannot. |
| One long-lived proxy | `pi-dispatch-egress-proxy`, squid, filtering by hostname. Exactly two shapes pass, both to a listed host: a `CONNECT` tunnel to port 443, or a plain (untunnelled) request to port 80. Nothing else passes (issue #508). "A plain request to port 80" also covers squid's own gateways for other schemes, but only to port 80 (`ftp://host:80/`, `https://host:80/`); every other scheme and port is refused. A plain forward request for an `https://` URL on 443, where the proxy would open the TLS itself, is refused too. A listed name that resolves to one of this host's fixed loopback or link-local addresses (or slirp4netns's `10.0.2.2`) is refused (issue #428). The allowlist is matched as written (`dstdomain -n`): an IP-literal request is refused unless that literal is itself listed, and the proxy makes no reverse lookup of it. A host mapped to another address (pasta's `--map-host-loopback <address>` or `--map-gw`, slirp4netns's `cidr=`) is not covered here; on the `podman` venue the worker refuses the containers.conf that would do that, and the account's running rootless network while it still does after the key is gone (issue #450). Publishes no port. |
| One long-lived proxy | `pi-dispatch-egress-proxy`, squid, filtering by hostname. Exactly two shapes pass, both to a listed host: a `CONNECT` tunnel to port 443, or a plain (untunnelled) request to port 80. Nothing else passes (issue #508), except a model server you declare, which is a `CONNECT` tunnel to its one host and port ("Local model servers" above, issue #503). "A plain request to port 80" also covers squid's own gateways for other schemes, but only to port 80 (`ftp://host:80/`, `https://host:80/`); every other scheme and port is refused. A plain forward request for an `https://` URL on 443, where the proxy would open the TLS itself, is refused too. A listed name that resolves to one of this host's fixed loopback or link-local addresses (or slirp4netns's `10.0.2.2`) is refused (issue #428). The allowlist is matched as written (`dstdomain -n`): an IP-literal request is refused unless that literal is itself listed, and the proxy makes no reverse lookup of it. A host mapped to another address (pasta's `--map-host-loopback <address>` or `--map-gw`, slirp4netns's `cidr=`) is not covered here; on the `podman` venue the worker refuses the containers.conf that would do that, and the account's running rootless network while it still does after the key is gone (issue #450). Publishes no port. |
| One upstream network | `pi-dispatch-egress-out`. Only the proxy is on it. |

**Per job, not one shared network**, and that is the part worth understanding. A shared network is a shared
Expand Down Expand Up @@ -211,8 +269,13 @@ refused, which needs control of the provider's own answer.
**Right after an upgrade this line can fail.** squid reads its rules only at start, so until the proxy restarts on
the refreshed `egress-proxy.conf` it still runs the old rules: the line reads `Egress policy lets plain HTTP through
to api.anthropic.com on port 443, ...` and `--live`'s egress verdict fails. `pi-dispatch up` refreshes the file and
restarts the proxy; on the `podman` venue `pi-dispatch service install --force` does. A proxy you started by hand
needs the updated file in its mount and a restart (`docker restart pi-dispatch-egress-proxy`, or `podman restart`).
restarts the proxy (or replaces it, when it lacks the third mount); on the `podman` venue `pi-dispatch service
install --force` does. A proxy you started by hand must be RECREATED, never just restarted, since the new rules
include `model-endpoints.conf` and it does not mount that file: remove it (`docker rm -f -v
pi-dispatch-egress-proxy`, or `podman rm -f -v`) and start it again with the third mount (`pi-dispatch up` does
this, or the recipe in [podman.md](podman.md)). With compose, the compose file must be the new one too: in a
folder `/dispatch setup` laid out, `deploy/docker-compose.yml` is a create-only copy no upgrade refreshes, so copy
it again from the installed package (or pull the clone), or use `pi-dispatch up` instead.

**With the policy on, a forge must be served over `https://` on port 443.** A job gets the proxy variables in
uppercase only, and git ignores `HTTP_PROXY` for an `http://` remote, so it never sends one through the proxy: from
Expand Down
Loading
Loading