feat(container): run as an unprivileged container with a guest forge relay and first-run registration
- commit
- 08776ef1212e2cb6d9cc2ee6ccbe8303fe79ab78
- author
- brice · · 2026-10-18 20:41 UTC
- parent
- f9de495
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
15 files · +672 -18
A contrib/container/Containerfile +50 -0
| @@ -0,0 +1,50 @@ | |||
| 1 | + | # SPDX-License-Identifier: MIT | |
| 2 | + | # | |
| 3 | + | # ermite-runner as an unprivileged container (vm isolation, no jailer), for a | |
| 4 | + | # rootless Podman host. See docs/container.md. | |
| 5 | + | # | |
| 6 | + | # Build from the repository root, after scripts/build-kernel.sh and | |
| 7 | + | # scripts/build-image.sh have written tmp/image/{vmlinux,base.ext4}: | |
| 8 | + | # | |
| 9 | + | # docker build -f contrib/container/Containerfile --build-arg VERSION=<tag> -t ermite-runner:<tag> . | |
| 10 | + | # | |
| 11 | + | # The guest kernel and the base image are part of the image: the runner then | |
| 12 | + | # needs nothing on the host but /dev/kvm, /dev/net/tun and NET_ADMIN. | |
| 13 | + | ||
| 14 | + | ARG GO_VERSION=1.27 | |
| 15 | + | ||
| 16 | + | FROM docker.io/library/golang:${GO_VERSION} AS build | |
| 17 | + | WORKDIR /src | |
| 18 | + | COPY go.mod go.sum ./ | |
| 19 | + | RUN go mod download | |
| 20 | + | COPY . . | |
| 21 | + | ARG VERSION=dev | |
| 22 | + | RUN CGO_ENABLED=0 GOWORK=off go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/ermite-runner . | |
| 23 | + | ||
| 24 | + | FROM docker.io/library/alpine:3.22 AS firecracker | |
| 25 | + | ARG FIRECRACKER_VERSION=v1.17.0 | |
| 26 | + | ARG FIRECRACKER_SHA256=06094a1108ae9e82aa4c23a775aa92758f53f1175d422270d9d6162cb9ade558 | |
| 27 | + | RUN apk add --no-cache curl \ | |
| 28 | + | && curl -fsSL -o /tmp/fc.tgz "https://github.com/firecracker-microvm/firecracker/releases/download/${FIRECRACKER_VERSION}/firecracker-${FIRECRACKER_VERSION}-x86_64.tgz" \ | |
| 29 | + | && echo "${FIRECRACKER_SHA256} /tmp/fc.tgz" | sha256sum -c - \ | |
| 30 | + | && tar -xzf /tmp/fc.tgz -C /tmp \ | |
| 31 | + | && install -m 0755 "/tmp/release-${FIRECRACKER_VERSION}-x86_64/firecracker-${FIRECRACKER_VERSION}-x86_64" /out-firecracker | |
| 32 | + | ||
| 33 | + | FROM docker.io/library/alpine:3.22 | |
| 34 | + | # ip (TAP devices), nft (job network policy), mkfs.ext4 (scratch and exec | |
| 35 | + | # disks; e2fsprogs >= 1.47), ca-certificates (Node.js externals downloads). | |
| 36 | + | RUN apk add --no-cache iproute2 nftables e2fsprogs ca-certificates tini | |
| 37 | + | COPY --from=firecracker /out-firecracker /usr/local/bin/firecracker | |
| 38 | + | COPY --from=build /out/ermite-runner /usr/local/bin/ermite-runner | |
| 39 | + | COPY tmp/image/vmlinux tmp/image/base.ext4 /opt/ermite-runner/image/ | |
| 40 | + | ||
| 41 | + | ENV ERMITE_RUNNER_ISOLATION=vm \ | |
| 42 | + | ERMITE_RUNNER_JAILER_MODE=off \ | |
| 43 | + | ERMITE_RUNNER_IMAGE=/opt/ermite-runner/image \ | |
| 44 | + | ERMITE_RUNNER_STATE_DIR=/var/lib/ermite-runner \ | |
| 45 | + | ERMITE_RUNNER_LOCK_FILE=/run/ermite-runner.lock \ | |
| 46 | + | ERMITE_RUNNER_LOG_FORMAT=json | |
| 47 | + | ||
| 48 | + | VOLUME /var/lib/ermite-runner | |
| 49 | + | ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/ermite-runner"] | |
| 50 | + | CMD ["run"] | |
| @@ -0,0 +1,50 @@ | |||||
| 1 | + | # SPDX-License-Identifier: MIT | |||
| 2 | + | # | |||
| 3 | + | # ermite-runner as an unprivileged container (vm isolation, no jailer), for a | |||
| 4 | + | # rootless Podman host. See docs/container.md. | |||
| 5 | + | # | |||
| 6 | + | # Build from the repository root, after scripts/build-kernel.sh and | |||
| 7 | + | # scripts/build-image.sh have written tmp/image/{vmlinux,base.ext4}: | |||
| 8 | + | # | |||
| 9 | + | # docker build -f contrib/container/Containerfile --build-arg VERSION=<tag> -t ermite-runner:<tag> . | |||
| 10 | + | # | |||
| 11 | + | # The guest kernel and the base image are part of the image: the runner then | |||
| 12 | + | # needs nothing on the host but /dev/kvm, /dev/net/tun and NET_ADMIN. | |||
| 13 | + | ||||
| 14 | + | ARG GO_VERSION=1.27 | |||
| 15 | + | ||||
| 16 | + | FROM docker.io/library/golang:${GO_VERSION} AS build | |||
| 17 | + | WORKDIR /src | |||
| 18 | + | COPY go.mod go.sum ./ | |||
| 19 | + | RUN go mod download | |||
| 20 | + | COPY . . | |||
| 21 | + | ARG VERSION=dev | |||
| 22 | + | RUN CGO_ENABLED=0 GOWORK=off go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/ermite-runner . | |||
| 23 | + | ||||
| 24 | + | FROM docker.io/library/alpine:3.22 AS firecracker | |||
| 25 | + | ARG FIRECRACKER_VERSION=v1.17.0 | |||
| 26 | + | ARG FIRECRACKER_SHA256=06094a1108ae9e82aa4c23a775aa92758f53f1175d422270d9d6162cb9ade558 | |||
| 27 | + | RUN apk add --no-cache curl \ | |||
| 28 | + | && curl -fsSL -o /tmp/fc.tgz "https://github.com/firecracker-microvm/firecracker/releases/download/${FIRECRACKER_VERSION}/firecracker-${FIRECRACKER_VERSION}-x86_64.tgz" \ | |||
| 29 | + | && echo "${FIRECRACKER_SHA256} /tmp/fc.tgz" | sha256sum -c - \ | |||
| 30 | + | && tar -xzf /tmp/fc.tgz -C /tmp \ | |||
| 31 | + | && install -m 0755 "/tmp/release-${FIRECRACKER_VERSION}-x86_64/firecracker-${FIRECRACKER_VERSION}-x86_64" /out-firecracker | |||
| 32 | + | ||||
| 33 | + | FROM docker.io/library/alpine:3.22 | |||
| 34 | + | # ip (TAP devices), nft (job network policy), mkfs.ext4 (scratch and exec | |||
| 35 | + | # disks; e2fsprogs >= 1.47), ca-certificates (Node.js externals downloads). | |||
| 36 | + | RUN apk add --no-cache iproute2 nftables e2fsprogs ca-certificates tini | |||
| 37 | + | COPY --from=firecracker /out-firecracker /usr/local/bin/firecracker | |||
| 38 | + | COPY --from=build /out/ermite-runner /usr/local/bin/ermite-runner | |||
| 39 | + | COPY tmp/image/vmlinux tmp/image/base.ext4 /opt/ermite-runner/image/ | |||
| 40 | + | ||||
| 41 | + | ENV ERMITE_RUNNER_ISOLATION=vm \ | |||
| 42 | + | ERMITE_RUNNER_JAILER_MODE=off \ | |||
| 43 | + | ERMITE_RUNNER_IMAGE=/opt/ermite-runner/image \ | |||
| 44 | + | ERMITE_RUNNER_STATE_DIR=/var/lib/ermite-runner \ | |||
| 45 | + | ERMITE_RUNNER_LOCK_FILE=/run/ermite-runner.lock \ | |||
| 46 | + | ERMITE_RUNNER_LOG_FORMAT=json | |||
| 47 | + | ||||
| 48 | + | VOLUME /var/lib/ermite-runner | |||
| 49 | + | ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/ermite-runner"] | |||
| 50 | + | CMD ["run"] | |||
M docs/configuration.md +4 -2
| @@ -12,7 +12,8 @@ Sizes accept `M`, `G` and `T`. | |||
| 12 | 12 | | `ERMITE_RUNNER_NAME` | host name | Runner name: 1 to 64 letters, digits, `.`, `_` and `-` | | |
| 13 | 13 | | `ERMITE_RUNNER_LABELS` | | Extra labels, comma separated, same characters as the name | | |
| 14 | 14 | | `ERMITE_RUNNER_ENVIRONMENT` | `github-hosted` | `github-hosted` or `self-hosted`. Sets the `runner.environment` value that jobs see. `self-hosted` also makes "Set up job" print the runner and machine name | | |
| 15 | - | | `ERMITE_RUNNER_REGISTER_TOKEN` | | Token for `register` | | |
| 15 | + | | `ERMITE_RUNNER_REGISTER_TOKEN` | | Token for `register`, and for the first start of `run` when no registration exists | | |
| 16 | + | | `ERMITE_RUNNER_REGISTER_TOKEN_FILE` | | File holding the register token. Wins over `ERMITE_RUNNER_REGISTER_TOKEN` for the first start of `run`. See [container](container.md#first-start) | | |
| 16 | 17 | | `ERMITE_RUNNER_INSECURE_HTTP` | `0` | `1` allows a non-loopback `http://` URL. Every token then travels in clear. For tests and trusted networks | | |
| 17 | 18 | ||
| 18 | 19 | ### Server URL | |
| @@ -42,7 +43,7 @@ The token has the scope `runner:register`. It can read the code and the secrets | |||
| 42 | 43 | | `ERMITE_RUNNER_ISOLATION` | `auto` | `auto`, `vm` or `container` | | |
| 43 | 44 | | `ERMITE_RUNNER_STATE_DIR` | `/var/lib/ermite-runner` | Absolute path. See the ownership rules in [sandboxes](sandboxes.md#state-directory) | | |
| 44 | 45 | | `ERMITE_RUNNER_CAPACITY` | `1` | Jobs in parallel. One sandbox, one uid, one `/30` network and one disk per slot. The runner polls the server only when a slot is free | | |
| 45 | - | | `ERMITE_RUNNER_IMAGE` | | `vm`: image directory with the kernel and `base.ext4` (default `<state dir>/image`). `container`: OCI reference (default `ermite-runner-ubuntu-24.04:latest`) | | |
| 46 | + | | `ERMITE_RUNNER_IMAGE` | | `vm`: image directory with the kernel and `base.ext4` (default `<state dir>/image`). `container`: OCI reference (default `ermite-runner-ubuntu-26.04:latest`) | | |
| 46 | 47 | | `ERMITE_RUNNER_DENY_PRIVILEGED` | `0` | `1` refuses `container` mode without sysbox | | |
| 47 | 48 | | `ERMITE_RUNNER_NETWORK` | `10.200.0.0/16` | Pool of `/30` subnets, one per slot | | |
| 48 | 49 | | `ERMITE_RUNNER_UID_BASE` | `900000` | First uid of the jailer pool. A VM runs as this value plus its slot | | |
| @@ -79,6 +80,7 @@ The token has the scope `runner:register`. It can read the code and the secrets | |||
| 79 | 80 | | `ERMITE_RUNNER_ARTIFACTS` | `1` | `0` turns artifacts v4 off. Artifacts need the cache service | | |
| 80 | 81 | | `ERMITE_RUNNER_NODE` | `1` | `0` turns the Node.js externals off. See [externals](externals.md) | | |
| 81 | 82 | | `ERMITE_RUNNER_GUEST_FORGE` | `0` | `1` lets sandboxes reach the server host and port. Staging only. See [sandboxes](sandboxes.md#guest-forge-access) | | |
| 83 | + | | `ERMITE_RUNNER_GUEST_FORGE_TARGET` | | `host:port` the runner relays guest forge traffic to. Empty: no relay, a service must listen on the gateway port | | |
| 82 | 84 | ||
| 83 | 85 | ## Logging | |
| 84 | 86 | ||
| @@ -12,7 +12,8 @@ Sizes accept `M`, `G` and `T`. | |||||
| 12 | | `ERMITE_RUNNER_NAME` | host name | Runner name: 1 to 64 letters, digits, `.`, `_` and `-` | | 12 | | `ERMITE_RUNNER_NAME` | host name | Runner name: 1 to 64 letters, digits, `.`, `_` and `-` | | ||
| 13 | | `ERMITE_RUNNER_LABELS` | | Extra labels, comma separated, same characters as the name | | 13 | | `ERMITE_RUNNER_LABELS` | | Extra labels, comma separated, same characters as the name | | ||
| 14 | | `ERMITE_RUNNER_ENVIRONMENT` | `github-hosted` | `github-hosted` or `self-hosted`. Sets the `runner.environment` value that jobs see. `self-hosted` also makes "Set up job" print the runner and machine name | | 14 | | `ERMITE_RUNNER_ENVIRONMENT` | `github-hosted` | `github-hosted` or `self-hosted`. Sets the `runner.environment` value that jobs see. `self-hosted` also makes "Set up job" print the runner and machine name | | ||
| 15 | - | | `ERMITE_RUNNER_REGISTER_TOKEN` | | Token for `register` | | 15 | + | | `ERMITE_RUNNER_REGISTER_TOKEN` | | Token for `register`, and for the first start of `run` when no registration exists | |
| 16 | + | | `ERMITE_RUNNER_REGISTER_TOKEN_FILE` | | File holding the register token. Wins over `ERMITE_RUNNER_REGISTER_TOKEN` for the first start of `run`. See [container](container.md#first-start) | | |||
| 16 | | `ERMITE_RUNNER_INSECURE_HTTP` | `0` | `1` allows a non-loopback `http://` URL. Every token then travels in clear. For tests and trusted networks | | 17 | | `ERMITE_RUNNER_INSECURE_HTTP` | `0` | `1` allows a non-loopback `http://` URL. Every token then travels in clear. For tests and trusted networks | | ||
| 17 | 18 | ||||
| 18 | ### Server URL | 19 | ### Server URL | ||
| @@ -42,7 +43,7 @@ The token has the scope `runner:register`. It can read the code and the secrets | |||||
| 42 | | `ERMITE_RUNNER_ISOLATION` | `auto` | `auto`, `vm` or `container` | | 43 | | `ERMITE_RUNNER_ISOLATION` | `auto` | `auto`, `vm` or `container` | | ||
| 43 | | `ERMITE_RUNNER_STATE_DIR` | `/var/lib/ermite-runner` | Absolute path. See the ownership rules in [sandboxes](sandboxes.md#state-directory) | | 44 | | `ERMITE_RUNNER_STATE_DIR` | `/var/lib/ermite-runner` | Absolute path. See the ownership rules in [sandboxes](sandboxes.md#state-directory) | | ||
| 44 | | `ERMITE_RUNNER_CAPACITY` | `1` | Jobs in parallel. One sandbox, one uid, one `/30` network and one disk per slot. The runner polls the server only when a slot is free | | 45 | | `ERMITE_RUNNER_CAPACITY` | `1` | Jobs in parallel. One sandbox, one uid, one `/30` network and one disk per slot. The runner polls the server only when a slot is free | | ||
| 45 | - | | `ERMITE_RUNNER_IMAGE` | | `vm`: image directory with the kernel and `base.ext4` (default `<state dir>/image`). `container`: OCI reference (default `ermite-runner-ubuntu-24.04:latest`) | | 46 | + | | `ERMITE_RUNNER_IMAGE` | | `vm`: image directory with the kernel and `base.ext4` (default `<state dir>/image`). `container`: OCI reference (default `ermite-runner-ubuntu-26.04:latest`) | |
| 46 | | `ERMITE_RUNNER_DENY_PRIVILEGED` | `0` | `1` refuses `container` mode without sysbox | | 47 | | `ERMITE_RUNNER_DENY_PRIVILEGED` | `0` | `1` refuses `container` mode without sysbox | | ||
| 47 | | `ERMITE_RUNNER_NETWORK` | `10.200.0.0/16` | Pool of `/30` subnets, one per slot | | 48 | | `ERMITE_RUNNER_NETWORK` | `10.200.0.0/16` | Pool of `/30` subnets, one per slot | | ||
| 48 | | `ERMITE_RUNNER_UID_BASE` | `900000` | First uid of the jailer pool. A VM runs as this value plus its slot | | 49 | | `ERMITE_RUNNER_UID_BASE` | `900000` | First uid of the jailer pool. A VM runs as this value plus its slot | | ||
| @@ -79,6 +80,7 @@ The token has the scope `runner:register`. It can read the code and the secrets | |||||
| 79 | | `ERMITE_RUNNER_ARTIFACTS` | `1` | `0` turns artifacts v4 off. Artifacts need the cache service | | 80 | | `ERMITE_RUNNER_ARTIFACTS` | `1` | `0` turns artifacts v4 off. Artifacts need the cache service | | ||
| 80 | | `ERMITE_RUNNER_NODE` | `1` | `0` turns the Node.js externals off. See [externals](externals.md) | | 81 | | `ERMITE_RUNNER_NODE` | `1` | `0` turns the Node.js externals off. See [externals](externals.md) | | ||
| 81 | | `ERMITE_RUNNER_GUEST_FORGE` | `0` | `1` lets sandboxes reach the server host and port. Staging only. See [sandboxes](sandboxes.md#guest-forge-access) | | 82 | | `ERMITE_RUNNER_GUEST_FORGE` | `0` | `1` lets sandboxes reach the server host and port. Staging only. See [sandboxes](sandboxes.md#guest-forge-access) | | ||
| 83 | + | | `ERMITE_RUNNER_GUEST_FORGE_TARGET` | | `host:port` the runner relays guest forge traffic to. Empty: no relay, a service must listen on the gateway port | | |||
| 82 | 84 | ||||
| 83 | ## Logging | 85 | ## Logging | ||
| 84 | 86 | ||||
A docs/container.md +52 -0
| @@ -0,0 +1,52 @@ | |||
| 1 | + | # Unprivileged container | |
| 2 | + | ||
| 3 | + | `contrib/container/Containerfile` packages the runner for a rootless container host (Podman or Docker). Jobs run in Firecracker microVMs (`vm` isolation) inside the container. Nothing runs as root on the host. | |
| 4 | + | ||
| 5 | + | ## What the image holds | |
| 6 | + | ||
| 7 | + | - `ermite-runner`, Firecracker and `tini`. | |
| 8 | + | - The guest kernel (`vmlinux`) and the base disk (`base.ext4`), from [images](images.md). | |
| 9 | + | - Defaults: `ERMITE_RUNNER_ISOLATION=vm`, `ERMITE_RUNNER_JAILER_MODE=off`, `ERMITE_RUNNER_STATE_DIR=/var/lib/ermite-runner`, JSON logs. | |
| 10 | + | ||
| 11 | + | ## Build | |
| 12 | + | ||
| 13 | + | ```sh | |
| 14 | + | scripts/build-kernel.sh tmp/image | |
| 15 | + | scripts/build-image.sh tmp/image | |
| 16 | + | docker build -f contrib/container/Containerfile --build-arg VERSION=<tag> -t ermite-runner:<tag> . | |
| 17 | + | ``` | |
| 18 | + | ||
| 19 | + | ## What the container needs | |
| 20 | + | ||
| 21 | + | | Grant | Why | | |
| 22 | + | | --- | --- | | |
| 23 | + | | `/dev/kvm` | Firecracker | | |
| 24 | + | | `/dev/net/tun` | One TAP device per job | | |
| 25 | + | | `CAP_NET_ADMIN` | TAP devices and nftables rules, in the container's own network namespace only | | |
| 26 | + | | A volume on `/var/lib/ermite-runner` | Registration, caches and job disks | | |
| 27 | + | ||
| 28 | + | No `--privileged`, no host network, no other capability. With rootless Podman, a CDI spec can expose the two device nodes. | |
| 29 | + | ||
| 30 | + | The jailer is off: it needs root to chroot and switch uid. The container and the user namespace are the outer boundary instead, and each job still runs in its own microVM. | |
| 31 | + | ||
| 32 | + | ## First start | |
| 33 | + | ||
| 34 | + | With no registration in the state directory, the runner registers itself when a register token is set: | |
| 35 | + | ||
| 36 | + | - `ERMITE_RUNNER_REGISTER_TOKEN_FILE`: path of a file holding the token (a container secret), or | |
| 37 | + | - `ERMITE_RUNNER_REGISTER_TOKEN`. | |
| 38 | + | ||
| 39 | + | The token is read only on that first start. Revoke it afterwards. Labels are fixed at registration: to change them, remove the runner on the server, delete `runner.json` from the state directory and restart with a fresh token. | |
| 40 | + | ||
| 41 | + | ## Reaching the server | |
| 42 | + | ||
| 43 | + | When the server runs on the same container network, set `ERMITE_RUNNER_URL` to its internal address and turn on [guest forge access](sandboxes.md#guest-forge-access) with a relay: | |
| 44 | + | ||
| 45 | + | ```sh | |
| 46 | + | ERMITE_RUNNER_URL=http://forge:8080 | |
| 47 | + | ERMITE_RUNNER_INSECURE_HTTP=1 | |
| 48 | + | ERMITE_RUNNER_GUEST_FORGE=1 | |
| 49 | + | ERMITE_RUNNER_GUEST_FORGE_TARGET=forge:8080 | |
| 50 | + | ``` | |
| 51 | + | ||
| 52 | + | Jobs then clone from `http://forge:8080`, which the runner relays to the server. | |
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | # Unprivileged container | |||
| 2 | + | ||||
| 3 | + | `contrib/container/Containerfile` packages the runner for a rootless container host (Podman or Docker). Jobs run in Firecracker microVMs (`vm` isolation) inside the container. Nothing runs as root on the host. | |||
| 4 | + | ||||
| 5 | + | ## What the image holds | |||
| 6 | + | ||||
| 7 | + | - `ermite-runner`, Firecracker and `tini`. | |||
| 8 | + | - The guest kernel (`vmlinux`) and the base disk (`base.ext4`), from [images](images.md). | |||
| 9 | + | - Defaults: `ERMITE_RUNNER_ISOLATION=vm`, `ERMITE_RUNNER_JAILER_MODE=off`, `ERMITE_RUNNER_STATE_DIR=/var/lib/ermite-runner`, JSON logs. | |||
| 10 | + | ||||
| 11 | + | ## Build | |||
| 12 | + | ||||
| 13 | + | ```sh | |||
| 14 | + | scripts/build-kernel.sh tmp/image | |||
| 15 | + | scripts/build-image.sh tmp/image | |||
| 16 | + | docker build -f contrib/container/Containerfile --build-arg VERSION=<tag> -t ermite-runner:<tag> . | |||
| 17 | + | ``` | |||
| 18 | + | ||||
| 19 | + | ## What the container needs | |||
| 20 | + | ||||
| 21 | + | | Grant | Why | | |||
| 22 | + | | --- | --- | | |||
| 23 | + | | `/dev/kvm` | Firecracker | | |||
| 24 | + | | `/dev/net/tun` | One TAP device per job | | |||
| 25 | + | | `CAP_NET_ADMIN` | TAP devices and nftables rules, in the container's own network namespace only | | |||
| 26 | + | | A volume on `/var/lib/ermite-runner` | Registration, caches and job disks | | |||
| 27 | + | ||||
| 28 | + | No `--privileged`, no host network, no other capability. With rootless Podman, a CDI spec can expose the two device nodes. | |||
| 29 | + | ||||
| 30 | + | The jailer is off: it needs root to chroot and switch uid. The container and the user namespace are the outer boundary instead, and each job still runs in its own microVM. | |||
| 31 | + | ||||
| 32 | + | ## First start | |||
| 33 | + | ||||
| 34 | + | With no registration in the state directory, the runner registers itself when a register token is set: | |||
| 35 | + | ||||
| 36 | + | - `ERMITE_RUNNER_REGISTER_TOKEN_FILE`: path of a file holding the token (a container secret), or | |||
| 37 | + | - `ERMITE_RUNNER_REGISTER_TOKEN`. | |||
| 38 | + | ||||
| 39 | + | The token is read only on that first start. Revoke it afterwards. Labels are fixed at registration: to change them, remove the runner on the server, delete `runner.json` from the state directory and restart with a fresh token. | |||
| 40 | + | ||||
| 41 | + | ## Reaching the server | |||
| 42 | + | ||||
| 43 | + | When the server runs on the same container network, set `ERMITE_RUNNER_URL` to its internal address and turn on [guest forge access](sandboxes.md#guest-forge-access) with a relay: | |||
| 44 | + | ||||
| 45 | + | ```sh | |||
| 46 | + | ERMITE_RUNNER_URL=http://forge:8080 | |||
| 47 | + | ERMITE_RUNNER_INSECURE_HTTP=1 | |||
| 48 | + | ERMITE_RUNNER_GUEST_FORGE=1 | |||
| 49 | + | ERMITE_RUNNER_GUEST_FORGE_TARGET=forge:8080 | |||
| 50 | + | ``` | |||
| 51 | + | ||||
| 52 | + | Jobs then clone from `http://forge:8080`, which the runner relays to the server. | |||
M docs/sandboxes.md +2 -1
| @@ -108,7 +108,8 @@ For every sandbox: | |||
| 108 | 108 | ||
| 109 | 109 | Security notes: | |
| 110 | 110 | ||
| 111 | - | - The runner does not proxy. A service must listen on that port on an address the gateway reaches (`*`, not `127.0.0.1`). | |
| 111 | + | - Without `ERMITE_RUNNER_GUEST_FORGE_TARGET`, the runner does not proxy. A service must listen on that port on an address the gateway reaches (`*`, not `127.0.0.1`). | |
| 112 | + | - With `ERMITE_RUNNER_GUEST_FORGE_TARGET=host:port`, the runner listens on each sandbox gateway on that port and relays TCP to the target. Use it when the server is only reachable from the runner, for example on a container network. See [container](container.md#reaching-the-server). | |
| 112 | 113 | - A shared reverse proxy routes by `Host` header. A job can reach every app that proxy serves on that port. | |
| 113 | 114 | - Traffic comes from a private address. Anything behind the proxy that trusts private sources trusts job code too. | |
| 114 | 115 | - Jobs see the server over plain HTTP in this setup. | |
| @@ -108,7 +108,8 @@ For every sandbox: | |||||
| 108 | 108 | ||||
| 109 | Security notes: | 109 | Security notes: | ||
| 110 | 110 | ||||
| 111 | - | - The runner does not proxy. A service must listen on that port on an address the gateway reaches (`*`, not `127.0.0.1`). | 111 | + | - Without `ERMITE_RUNNER_GUEST_FORGE_TARGET`, the runner does not proxy. A service must listen on that port on an address the gateway reaches (`*`, not `127.0.0.1`). |
| 112 | + | - With `ERMITE_RUNNER_GUEST_FORGE_TARGET=host:port`, the runner listens on each sandbox gateway on that port and relays TCP to the target. Use it when the server is only reachable from the runner, for example on a container network. See [container](container.md#reaching-the-server). | |||
| 112 | - A shared reverse proxy routes by `Host` header. A job can reach every app that proxy serves on that port. | 113 | - A shared reverse proxy routes by `Host` header. A job can reach every app that proxy serves on that port. | ||
| 113 | - Traffic comes from a private address. Anything behind the proxy that trusts private sources trusts job code too. | 114 | - Traffic comes from a private address. Anything behind the proxy that trusts private sources trusts job code too. | ||
| 114 | - Jobs see the server over plain HTTP in this setup. | 115 | - Jobs see the server over plain HTTP in this setup. | ||
M host_cmd.go +50 -1
| @@ -193,6 +193,50 @@ func registerCmd(ctx context.Context, args []string) int { | |||
| 193 | 193 | return 0 | |
| 194 | 194 | } | |
| 195 | 195 | ||
| 196 | + | // registerOnFirstRun registers the runner when `run` finds no registration and | |
| 197 | + | // a register token is given in ERMITE_RUNNER_REGISTER_TOKEN or in the file | |
| 198 | + | // named by ERMITE_RUNNER_REGISTER_TOKEN_FILE. Without a token it returns | |
| 199 | + | // host.ErrNotRegistered, as before. | |
| 200 | + | func registerOnFirstRun(ctx context.Context, scfg sandbox.Config, hcfg host.Config) (host.State, error) { | |
| 201 | + | tok, err := firstRunToken(os.Getenv) | |
| 202 | + | if err != nil || tok == "" { | |
| 203 | + | return host.State{}, errors.Join(host.ErrNotRegistered, err) | |
| 204 | + | } | |
| 205 | + | if hcfg.URL == "" { | |
| 206 | + | return host.State{}, fmt.Errorf("%s is not set", host.EnvURL) | |
| 207 | + | } | |
| 208 | + | drv, err := buildDriver(ctx, scfg) | |
| 209 | + | if err != nil { | |
| 210 | + | return host.State{}, err | |
| 211 | + | } | |
| 212 | + | iso := drv.Isolation() | |
| 213 | + | _ = drv.Close() | |
| 214 | + | st, err := host.Register(ctx, hcfg, hcfg.Client(hcfg.URL, tok), iso, false) | |
| 215 | + | if err != nil { | |
| 216 | + | return host.State{}, fmt.Errorf("first-run registration: %w", err) | |
| 217 | + | } | |
| 218 | + | log := zerowrap.FromCtx(ctx) | |
| 219 | + | log.Info().Str("runner", st.Name).Str("isolation", string(iso)).Msg("registered on first run; the register token is no longer needed") | |
| 220 | + | return st, nil | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // envRegisterTokenFile names a file holding the register token (a container | |
| 224 | + | // secret delivered as a file). | |
| 225 | + | const envRegisterTokenFile = "ERMITE_RUNNER_REGISTER_TOKEN_FILE" | |
| 226 | + | ||
| 227 | + | // firstRunToken reads the register token of a first run: the file of | |
| 228 | + | // envRegisterTokenFile when set, else host.EnvRegisterToken. "" means none. | |
| 229 | + | func firstRunToken(getenv func(string) string) (string, error) { | |
| 230 | + | if p := strings.TrimSpace(getenv(envRegisterTokenFile)); p != "" { | |
| 231 | + | b, err := os.ReadFile(p) | |
| 232 | + | if err != nil { | |
| 233 | + | return "", fmt.Errorf("%s: %w", envRegisterTokenFile, err) | |
| 234 | + | } | |
| 235 | + | return strings.TrimSpace(string(b)), nil | |
| 236 | + | } | |
| 237 | + | return strings.TrimSpace(getenv(host.EnvRegisterToken)), nil | |
| 238 | + | } | |
| 239 | + | ||
| 196 | 240 | // runCmd is `ermite-runner run`: poll ermite for jobs and run each one in its | |
| 197 | 241 | // own sandbox until SIGINT or SIGTERM, then cancel the running jobs with | |
| 198 | 242 | // runner_shutdown and report them. | |
| @@ -204,6 +248,11 @@ func runCmd(ctx context.Context) int { | |||
| 204 | 248 | return 2 | |
| 205 | 249 | } | |
| 206 | 250 | st, err := host.LoadState(hcfg.StateDir) | |
| 251 | + | if errors.Is(err, host.ErrNotRegistered) { | |
| 252 | + | // A container service has no separate install step: it registers on its | |
| 253 | + | // first start when it is given a register token. | |
| 254 | + | st, err = registerOnFirstRun(ctx, scfg, hcfg) | |
| 255 | + | } | |
| 207 | 256 | if err != nil { | |
| 208 | 257 | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | |
| 209 | 258 | return 2 | |
| @@ -219,7 +268,7 @@ func runCmd(ctx context.Context) int { | |||
| 219 | 268 | if scfg.GuestForgeEnabled { | |
| 220 | 269 | // The sandboxes reach the forge the way the runner does: by the host name | |
| 221 | 270 | // and port of the URL the runner itself uses. | |
| 222 | - | gf, err := sandbox.ResolveGuestForge(ctx, url, hcfg.InsecureHTTP, net.DefaultResolver.LookupHost) | |
| 271 | + | gf, err := sandbox.ResolveGuestForge(ctx, url, os.Getenv(sandbox.EnvGuestForgeTarget), hcfg.InsecureHTTP, net.DefaultResolver.LookupHost) | |
| 223 | 272 | if err != nil { | |
| 224 | 273 | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | |
| 225 | 274 | return 2 | |
| @@ -193,6 +193,50 @@ func registerCmd(ctx context.Context, args []string) int { | |||||
| 193 | return 0 | 193 | return 0 | ||
| 194 | } | 194 | } | ||
| 195 | 195 | ||||
| 196 | + | // registerOnFirstRun registers the runner when `run` finds no registration and | |||
| 197 | + | // a register token is given in ERMITE_RUNNER_REGISTER_TOKEN or in the file | |||
| 198 | + | // named by ERMITE_RUNNER_REGISTER_TOKEN_FILE. Without a token it returns | |||
| 199 | + | // host.ErrNotRegistered, as before. | |||
| 200 | + | func registerOnFirstRun(ctx context.Context, scfg sandbox.Config, hcfg host.Config) (host.State, error) { | |||
| 201 | + | tok, err := firstRunToken(os.Getenv) | |||
| 202 | + | if err != nil || tok == "" { | |||
| 203 | + | return host.State{}, errors.Join(host.ErrNotRegistered, err) | |||
| 204 | + | } | |||
| 205 | + | if hcfg.URL == "" { | |||
| 206 | + | return host.State{}, fmt.Errorf("%s is not set", host.EnvURL) | |||
| 207 | + | } | |||
| 208 | + | drv, err := buildDriver(ctx, scfg) | |||
| 209 | + | if err != nil { | |||
| 210 | + | return host.State{}, err | |||
| 211 | + | } | |||
| 212 | + | iso := drv.Isolation() | |||
| 213 | + | _ = drv.Close() | |||
| 214 | + | st, err := host.Register(ctx, hcfg, hcfg.Client(hcfg.URL, tok), iso, false) | |||
| 215 | + | if err != nil { | |||
| 216 | + | return host.State{}, fmt.Errorf("first-run registration: %w", err) | |||
| 217 | + | } | |||
| 218 | + | log := zerowrap.FromCtx(ctx) | |||
| 219 | + | log.Info().Str("runner", st.Name).Str("isolation", string(iso)).Msg("registered on first run; the register token is no longer needed") | |||
| 220 | + | return st, nil | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // envRegisterTokenFile names a file holding the register token (a container | |||
| 224 | + | // secret delivered as a file). | |||
| 225 | + | const envRegisterTokenFile = "ERMITE_RUNNER_REGISTER_TOKEN_FILE" | |||
| 226 | + | ||||
| 227 | + | // firstRunToken reads the register token of a first run: the file of | |||
| 228 | + | // envRegisterTokenFile when set, else host.EnvRegisterToken. "" means none. | |||
| 229 | + | func firstRunToken(getenv func(string) string) (string, error) { | |||
| 230 | + | if p := strings.TrimSpace(getenv(envRegisterTokenFile)); p != "" { | |||
| 231 | + | b, err := os.ReadFile(p) | |||
| 232 | + | if err != nil { | |||
| 233 | + | return "", fmt.Errorf("%s: %w", envRegisterTokenFile, err) | |||
| 234 | + | } | |||
| 235 | + | return strings.TrimSpace(string(b)), nil | |||
| 236 | + | } | |||
| 237 | + | return strings.TrimSpace(getenv(host.EnvRegisterToken)), nil | |||
| 238 | + | } | |||
| 239 | + | ||||
| 196 | // runCmd is `ermite-runner run`: poll ermite for jobs and run each one in its | 240 | // runCmd is `ermite-runner run`: poll ermite for jobs and run each one in its | ||
| 197 | // own sandbox until SIGINT or SIGTERM, then cancel the running jobs with | 241 | // own sandbox until SIGINT or SIGTERM, then cancel the running jobs with | ||
| 198 | // runner_shutdown and report them. | 242 | // runner_shutdown and report them. | ||
| @@ -204,6 +248,11 @@ func runCmd(ctx context.Context) int { | |||||
| 204 | return 2 | 248 | return 2 | ||
| 205 | } | 249 | } | ||
| 206 | st, err := host.LoadState(hcfg.StateDir) | 250 | st, err := host.LoadState(hcfg.StateDir) | ||
| 251 | + | if errors.Is(err, host.ErrNotRegistered) { | |||
| 252 | + | // A container service has no separate install step: it registers on its | |||
| 253 | + | // first start when it is given a register token. | |||
| 254 | + | st, err = registerOnFirstRun(ctx, scfg, hcfg) | |||
| 255 | + | } | |||
| 207 | if err != nil { | 256 | if err != nil { | ||
| 208 | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | 257 | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | ||
| 209 | return 2 | 258 | return 2 | ||
| @@ -219,7 +268,7 @@ func runCmd(ctx context.Context) int { | |||||
| 219 | if scfg.GuestForgeEnabled { | 268 | if scfg.GuestForgeEnabled { | ||
| 220 | // The sandboxes reach the forge the way the runner does: by the host name | 269 | // The sandboxes reach the forge the way the runner does: by the host name | ||
| 221 | // and port of the URL the runner itself uses. | 270 | // and port of the URL the runner itself uses. | ||
| 222 | - | gf, err := sandbox.ResolveGuestForge(ctx, url, hcfg.InsecureHTTP, net.DefaultResolver.LookupHost) | 271 | + | gf, err := sandbox.ResolveGuestForge(ctx, url, os.Getenv(sandbox.EnvGuestForgeTarget), hcfg.InsecureHTTP, net.DefaultResolver.LookupHost) |
| 223 | if err != nil { | 272 | if err != nil { | ||
| 224 | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | 273 | fmt.Fprintln(os.Stderr, "ermite-runner:", err) | ||
| 225 | return 2 | 274 | return 2 | ||
A host_cmd_test.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package main | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite-runner/internal/host" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestFirstRunToken(t *testing.T) { | |
| 14 | + | env := func(m map[string]string) func(string) string { | |
| 15 | + | return func(k string) string { return m[k] } | |
| 16 | + | } | |
| 17 | + | if tok, err := firstRunToken(env(nil)); err != nil || tok != "" { | |
| 18 | + | t.Fatalf("no token: %q %v", tok, err) | |
| 19 | + | } | |
| 20 | + | if tok, err := firstRunToken(env(map[string]string{host.EnvRegisterToken: " t1 \n"})); err != nil || tok != "t1" { | |
| 21 | + | t.Fatalf("env: %q %v", tok, err) | |
| 22 | + | } | |
| 23 | + | p := filepath.Join(t.TempDir(), "token") | |
| 24 | + | if err := os.WriteFile(p, []byte("t2\n"), 0o600); err != nil { | |
| 25 | + | t.Fatal(err) | |
| 26 | + | } | |
| 27 | + | // The file wins over the environment. | |
| 28 | + | if tok, err := firstRunToken(env(map[string]string{envRegisterTokenFile: p, host.EnvRegisterToken: "t1"})); err != nil || tok != "t2" { | |
| 29 | + | t.Fatalf("file: %q %v", tok, err) | |
| 30 | + | } | |
| 31 | + | if _, err := firstRunToken(env(map[string]string{envRegisterTokenFile: filepath.Join(t.TempDir(), "missing")})); err == nil { | |
| 32 | + | t.Fatal("a missing token file was accepted") | |
| 33 | + | } | |
| 34 | + | } | |
| @@ -0,0 +1,34 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package main | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite-runner/internal/host" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestFirstRunToken(t *testing.T) { | |||
| 14 | + | env := func(m map[string]string) func(string) string { | |||
| 15 | + | return func(k string) string { return m[k] } | |||
| 16 | + | } | |||
| 17 | + | if tok, err := firstRunToken(env(nil)); err != nil || tok != "" { | |||
| 18 | + | t.Fatalf("no token: %q %v", tok, err) | |||
| 19 | + | } | |||
| 20 | + | if tok, err := firstRunToken(env(map[string]string{host.EnvRegisterToken: " t1 \n"})); err != nil || tok != "t1" { | |||
| 21 | + | t.Fatalf("env: %q %v", tok, err) | |||
| 22 | + | } | |||
| 23 | + | p := filepath.Join(t.TempDir(), "token") | |||
| 24 | + | if err := os.WriteFile(p, []byte("t2\n"), 0o600); err != nil { | |||
| 25 | + | t.Fatal(err) | |||
| 26 | + | } | |||
| 27 | + | // The file wins over the environment. | |||
| 28 | + | if tok, err := firstRunToken(env(map[string]string{envRegisterTokenFile: p, host.EnvRegisterToken: "t1"})); err != nil || tok != "t2" { | |||
| 29 | + | t.Fatalf("file: %q %v", tok, err) | |||
| 30 | + | } | |||
| 31 | + | if _, err := firstRunToken(env(map[string]string{envRegisterTokenFile: filepath.Join(t.TempDir(), "missing")})); err == nil { | |||
| 32 | + | t.Fatal("a missing token file was accepted") | |||
| 33 | + | } | |||
| 34 | + | } | |||
M internal/sandbox/container/container_test.go +3 -3
| @@ -1014,10 +1014,10 @@ func TestSocketPathTooLongIsExplained(t *testing.T) { | |||
| 1014 | 1014 | // GO-002: only a podman daemon gets a DNS listener that may precede the gateway | |
| 1015 | 1015 | // address; Docker keeps a normal bind. | |
| 1016 | 1016 | func TestOnlyPodmanGetsAFreeBindDNSListener(t *testing.T) { | |
| 1017 | - | if newNet(nil, 0, false, "").DNS.FreeBind { | |
| 1017 | + | if newNet(nil, 0, false, "", "").DNS.FreeBind { | |
| 1018 | 1018 | t.Fatal("Docker must keep a normal bind") | |
| 1019 | 1019 | } | |
| 1020 | - | if !newNet(nil, 0, true, "").DNS.FreeBind { | |
| 1020 | + | if !newNet(nil, 0, true, "", "").DNS.FreeBind { | |
| 1021 | 1021 | t.Fatal("podman needs the listener before the bridge address") | |
| 1022 | 1022 | } | |
| 1023 | 1023 | } | |
| @@ -1080,7 +1080,7 @@ func TestNoGuestForgeByDefault(t *testing.T) { | |||
| 1080 | 1080 | if strings.Contains(buf.String(), "guest forge") { | |
| 1081 | 1081 | t.Fatal("a guest forge warning without the setting") | |
| 1082 | 1082 | } | |
| 1083 | - | if n := newNet(nil, 0, false, "ermite.example.org").DNS.Override; n != "ermite.example.org" { | |
| 1083 | + | if n := newNet(nil, 0, false, "ermite.example.org", "").DNS.Override; n != "ermite.example.org" { | |
| 1084 | 1084 | t.Fatalf("override %q", n) | |
| 1085 | 1085 | } | |
| 1086 | 1086 | } | |
| @@ -1014,10 +1014,10 @@ func TestSocketPathTooLongIsExplained(t *testing.T) { | |||||
| 1014 | // GO-002: only a podman daemon gets a DNS listener that may precede the gateway | 1014 | // GO-002: only a podman daemon gets a DNS listener that may precede the gateway | ||
| 1015 | // address; Docker keeps a normal bind. | 1015 | // address; Docker keeps a normal bind. | ||
| 1016 | func TestOnlyPodmanGetsAFreeBindDNSListener(t *testing.T) { | 1016 | func TestOnlyPodmanGetsAFreeBindDNSListener(t *testing.T) { | ||
| 1017 | - | if newNet(nil, 0, false, "").DNS.FreeBind { | 1017 | + | if newNet(nil, 0, false, "", "").DNS.FreeBind { |
| 1018 | t.Fatal("Docker must keep a normal bind") | 1018 | t.Fatal("Docker must keep a normal bind") | ||
| 1019 | } | 1019 | } | ||
| 1020 | - | if !newNet(nil, 0, true, "").DNS.FreeBind { | 1020 | + | if !newNet(nil, 0, true, "", "").DNS.FreeBind { |
| 1021 | t.Fatal("podman needs the listener before the bridge address") | 1021 | t.Fatal("podman needs the listener before the bridge address") | ||
| 1022 | } | 1022 | } | ||
| 1023 | } | 1023 | } | ||
| @@ -1080,7 +1080,7 @@ func TestNoGuestForgeByDefault(t *testing.T) { | |||||
| 1080 | if strings.Contains(buf.String(), "guest forge") { | 1080 | if strings.Contains(buf.String(), "guest forge") { | ||
| 1081 | t.Fatal("a guest forge warning without the setting") | 1081 | t.Fatal("a guest forge warning without the setting") | ||
| 1082 | } | 1082 | } | ||
| 1083 | - | if n := newNet(nil, 0, false, "ermite.example.org").DNS.Override; n != "ermite.example.org" { | 1083 | + | if n := newNet(nil, 0, false, "ermite.example.org", "").DNS.Override; n != "ermite.example.org" { |
| 1084 | t.Fatalf("override %q", n) | 1084 | t.Fatalf("override %q", n) | ||
| 1085 | } | 1085 | } | ||
| 1086 | } | 1086 | } | ||
M internal/sandbox/container/driver.go +6 -2
| @@ -38,6 +38,7 @@ import ( | |||
| 38 | 38 | "bnema.dev/ermite-runner/internal/sandbox" | |
| 39 | 39 | "bnema.dev/ermite-runner/internal/sandbox/disk" | |
| 40 | 40 | "bnema.dev/ermite-runner/internal/sandbox/netsetup" | |
| 41 | + | "bnema.dev/ermite-runner/internal/tcprelay" | |
| 41 | 42 | ) | |
| 42 | 43 | ||
| 43 | 44 | // SysboxRuntime is the name of the sysbox runtime in `docker info`. | |
| @@ -163,7 +164,7 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) | |||
| 163 | 164 | } | |
| 164 | 165 | d.net = opt.Net | |
| 165 | 166 | if d.net == nil { | |
| 166 | - | n := newNet(opt.Run, cfg.DNSPort, info.Podman, cfg.GuestForge.DNSName()) | |
| 167 | + | n := newNet(opt.Run, cfg.DNSPort, info.Podman, cfg.GuestForge.DNSName(), cfg.GuestForge.RelayTarget()) | |
| 167 | 168 | d.net = n | |
| 168 | 169 | } | |
| 169 | 170 | if err := d.net.EnableForwarding(); err != nil { | |
| @@ -178,10 +179,13 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) | |||
| 178 | 179 | // newNet builds the host network plumbing. Only podman creates the bridge | |
| 179 | 180 | // address late (see daemonInfo.Podman), so only there the DNS listener may come | |
| 180 | 181 | // before the address; on Docker a missing gateway address stays a startup error. | |
| 181 | - | func newNet(run cmdrun.Runner, dnsPort int, podman bool, forgeHost string) *netsetup.Net { | |
| 182 | + | func newNet(run cmdrun.Runner, dnsPort int, podman bool, forgeHost, relay string) *netsetup.Net { | |
| 182 | 183 | n := netsetup.New(run, dnsPort, "") | |
| 183 | 184 | n.DNS.FreeBind = podman | |
| 184 | 185 | n.DNS.Override = forgeHost | |
| 186 | + | if relay != "" { | |
| 187 | + | n.Relay = tcprelay.New(relay) | |
| 188 | + | } | |
| 185 | 189 | return n | |
| 186 | 190 | } | |
| 187 | 191 | ||
| @@ -38,6 +38,7 @@ import ( | |||||
| 38 | "bnema.dev/ermite-runner/internal/sandbox" | 38 | "bnema.dev/ermite-runner/internal/sandbox" | ||
| 39 | "bnema.dev/ermite-runner/internal/sandbox/disk" | 39 | "bnema.dev/ermite-runner/internal/sandbox/disk" | ||
| 40 | "bnema.dev/ermite-runner/internal/sandbox/netsetup" | 40 | "bnema.dev/ermite-runner/internal/sandbox/netsetup" | ||
| 41 | + | "bnema.dev/ermite-runner/internal/tcprelay" | |||
| 41 | ) | 42 | ) | ||
| 42 | 43 | ||||
| 43 | // SysboxRuntime is the name of the sysbox runtime in `docker info`. | 44 | // SysboxRuntime is the name of the sysbox runtime in `docker info`. | ||
| @@ -163,7 +164,7 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) | |||||
| 163 | } | 164 | } | ||
| 164 | d.net = opt.Net | 165 | d.net = opt.Net | ||
| 165 | if d.net == nil { | 166 | if d.net == nil { | ||
| 166 | - | n := newNet(opt.Run, cfg.DNSPort, info.Podman, cfg.GuestForge.DNSName()) | 167 | + | n := newNet(opt.Run, cfg.DNSPort, info.Podman, cfg.GuestForge.DNSName(), cfg.GuestForge.RelayTarget()) |
| 167 | d.net = n | 168 | d.net = n | ||
| 168 | } | 169 | } | ||
| 169 | if err := d.net.EnableForwarding(); err != nil { | 170 | if err := d.net.EnableForwarding(); err != nil { | ||
| @@ -178,10 +179,13 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) | |||||
| 178 | // newNet builds the host network plumbing. Only podman creates the bridge | 179 | // newNet builds the host network plumbing. Only podman creates the bridge | ||
| 179 | // address late (see daemonInfo.Podman), so only there the DNS listener may come | 180 | // address late (see daemonInfo.Podman), so only there the DNS listener may come | ||
| 180 | // before the address; on Docker a missing gateway address stays a startup error. | 181 | // before the address; on Docker a missing gateway address stays a startup error. | ||
| 181 | - | func newNet(run cmdrun.Runner, dnsPort int, podman bool, forgeHost string) *netsetup.Net { | 182 | + | func newNet(run cmdrun.Runner, dnsPort int, podman bool, forgeHost, relay string) *netsetup.Net { |
| 182 | n := netsetup.New(run, dnsPort, "") | 183 | n := netsetup.New(run, dnsPort, "") | ||
| 183 | n.DNS.FreeBind = podman | 184 | n.DNS.FreeBind = podman | ||
| 184 | n.DNS.Override = forgeHost | 185 | n.DNS.Override = forgeHost | ||
| 186 | + | if relay != "" { | |||
| 187 | + | n.Relay = tcprelay.New(relay) | |||
| 188 | + | } | |||
| 185 | return n | 189 | return n | ||
| 186 | } | 190 | } | ||
| 187 | 191 | ||||
M internal/sandbox/guestforge.go +59 -4
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "context" | |
| 7 | 7 | "errors" | |
| 8 | 8 | "fmt" | |
| 9 | + | "net" | |
| 9 | 10 | "net/netip" | |
| 10 | 11 | "net/url" | |
| 11 | 12 | "strconv" | |
| @@ -19,6 +20,13 @@ import ( | |||
| 19 | 20 | // internet side of the NAT (see the README). Off by default. | |
| 20 | 21 | const EnvGuestForge = "ERMITE_RUNNER_GUEST_FORGE" | |
| 21 | 22 | ||
| 23 | + | // EnvGuestForgeTarget is ERMITE_RUNNER_GUEST_FORGE_TARGET=host:port: with the | |
| 24 | + | // guest forge access on, the runner relays the forge port of each sandbox's | |
| 25 | + | // gateway to that address instead of letting the host's own listener answer. | |
| 26 | + | // For a runner in a container, where the forge is another container reached | |
| 27 | + | // by name (ermite:7341) and nothing listens on the forge port locally. | |
| 28 | + | const EnvGuestForgeTarget = "ERMITE_RUNNER_GUEST_FORGE_TARGET" | |
| 29 | + | ||
| 22 | 30 | // GuestForge is the one forge endpoint that sandboxes may reach on the host. | |
| 23 | 31 | // It is derived from the server URL by ParseGuestForge. | |
| 24 | 32 | type GuestForge struct { | |
| @@ -30,6 +38,39 @@ type GuestForge struct { | |||
| 30 | 38 | // Origin is scheme://host[:port] of the URL: the jobs' server URLs are | |
| 31 | 39 | // rewritten to it. | |
| 32 | 40 | Origin string | |
| 41 | + | // Target, when not empty, is the host:port the runner relays the sandboxes' | |
| 42 | + | // forge connections to (EnvGuestForgeTarget). | |
| 43 | + | Target string | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // RelayTarget is Target, "" when the access is off or not relayed. Safe on nil. | |
| 47 | + | func (g *GuestForge) RelayTarget() string { | |
| 48 | + | if g == nil { | |
| 49 | + | return "" | |
| 50 | + | } | |
| 51 | + | return g.Target | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // ParseGuestForgeTarget checks a relay target: host:port with a host name or | |
| 55 | + | // an IP address and a port in 1-65535. "" is accepted (no relay). | |
| 56 | + | func ParseGuestForgeTarget(raw string) (string, error) { | |
| 57 | + | raw = strings.TrimSpace(raw) | |
| 58 | + | if raw == "" { | |
| 59 | + | return "", nil | |
| 60 | + | } | |
| 61 | + | h, p, err := net.SplitHostPort(raw) | |
| 62 | + | if err != nil || h == "" { | |
| 63 | + | return "", fmt.Errorf("%s: %q is not host:port", EnvGuestForgeTarget, raw) | |
| 64 | + | } | |
| 65 | + | if n, err := strconv.Atoi(p); err != nil || n < 1 || n > 65535 { | |
| 66 | + | return "", fmt.Errorf("%s: port %q is not in 1-65535", EnvGuestForgeTarget, p) | |
| 67 | + | } | |
| 68 | + | if _, err := netip.ParseAddr(h); err != nil { | |
| 69 | + | if err := CheckForgeHostname(strings.ToLower(h)); err != nil { | |
| 70 | + | return "", fmt.Errorf("%s: %w", EnvGuestForgeTarget, err) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | return raw, nil | |
| 33 | 74 | } | |
| 34 | 75 | ||
| 35 | 76 | // Warning is the line logged at start and for each job. | |
| @@ -138,19 +179,33 @@ func CheckForgeHostname(h string) error { | |||
| 138 | 179 | // resolves on the host (lookup is net.DefaultResolver.LookupHost in | |
| 139 | 180 | // production): the runner reaches the forge by that name, so a name that | |
| 140 | 181 | // resolves to nothing is a configuration error to report at start. | |
| 141 | - | func ResolveGuestForge(ctx context.Context, raw string, insecureHTTP bool, lookup func(ctx context.Context, host string) ([]string, error)) (*GuestForge, error) { | |
| 182 | + | // | |
| 183 | + | // target is the relay target (EnvGuestForgeTarget, "" for none). With a | |
| 184 | + | // target, the sandboxes still see the forge under the host name of raw, and it | |
| 185 | + | // is the target's host that must resolve: the runner connects there. | |
| 186 | + | func ResolveGuestForge(ctx context.Context, raw, target string, insecureHTTP bool, lookup func(ctx context.Context, host string) ([]string, error)) (*GuestForge, error) { | |
| 142 | 187 | g, err := ParseGuestForge(raw, insecureHTTP) | |
| 143 | 188 | if err != nil { | |
| 144 | 189 | return nil, err | |
| 145 | 190 | } | |
| 191 | + | if g.Target, err = ParseGuestForgeTarget(target); err != nil { | |
| 192 | + | return nil, err | |
| 193 | + | } | |
| 194 | + | host := g.Host | |
| 195 | + | if g.Target != "" { | |
| 196 | + | host, _, _ = net.SplitHostPort(g.Target) | |
| 197 | + | if _, err := netip.ParseAddr(host); err == nil { | |
| 198 | + | return g, nil | |
| 199 | + | } | |
| 200 | + | } | |
| 146 | 201 | ctx, cancel := context.WithTimeout(ctx, 10*time.Second) | |
| 147 | 202 | defer cancel() | |
| 148 | - | addrs, err := lookup(ctx, g.Host) | |
| 203 | + | addrs, err := lookup(ctx, host) | |
| 149 | 204 | if err != nil { | |
| 150 | - | return nil, fmt.Errorf("%s: %s does not resolve on this host: %w", EnvGuestForge, g.Host, err) | |
| 205 | + | return nil, fmt.Errorf("%s: %s does not resolve on this host: %w", EnvGuestForge, host, err) | |
| 151 | 206 | } | |
| 152 | 207 | if len(addrs) == 0 { | |
| 153 | - | return nil, fmt.Errorf("%s: %s resolves to no address on this host", EnvGuestForge, g.Host) | |
| 208 | + | return nil, fmt.Errorf("%s: %s resolves to no address on this host", EnvGuestForge, host) | |
| 154 | 209 | } | |
| 155 | 210 | return g, nil | |
| 156 | 211 | } | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "context" | 6 | "context" | ||
| 7 | "errors" | 7 | "errors" | ||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | + | "net" | |||
| 9 | "net/netip" | 10 | "net/netip" | ||
| 10 | "net/url" | 11 | "net/url" | ||
| 11 | "strconv" | 12 | "strconv" | ||
| @@ -19,6 +20,13 @@ import ( | |||||
| 19 | // internet side of the NAT (see the README). Off by default. | 20 | // internet side of the NAT (see the README). Off by default. | ||
| 20 | const EnvGuestForge = "ERMITE_RUNNER_GUEST_FORGE" | 21 | const EnvGuestForge = "ERMITE_RUNNER_GUEST_FORGE" | ||
| 21 | 22 | ||||
| 23 | + | // EnvGuestForgeTarget is ERMITE_RUNNER_GUEST_FORGE_TARGET=host:port: with the | |||
| 24 | + | // guest forge access on, the runner relays the forge port of each sandbox's | |||
| 25 | + | // gateway to that address instead of letting the host's own listener answer. | |||
| 26 | + | // For a runner in a container, where the forge is another container reached | |||
| 27 | + | // by name (ermite:7341) and nothing listens on the forge port locally. | |||
| 28 | + | const EnvGuestForgeTarget = "ERMITE_RUNNER_GUEST_FORGE_TARGET" | |||
| 29 | + | ||||
| 22 | // GuestForge is the one forge endpoint that sandboxes may reach on the host. | 30 | // GuestForge is the one forge endpoint that sandboxes may reach on the host. | ||
| 23 | // It is derived from the server URL by ParseGuestForge. | 31 | // It is derived from the server URL by ParseGuestForge. | ||
| 24 | type GuestForge struct { | 32 | type GuestForge struct { | ||
| @@ -30,6 +38,39 @@ type GuestForge struct { | |||||
| 30 | // Origin is scheme://host[:port] of the URL: the jobs' server URLs are | 38 | // Origin is scheme://host[:port] of the URL: the jobs' server URLs are | ||
| 31 | // rewritten to it. | 39 | // rewritten to it. | ||
| 32 | Origin string | 40 | Origin string | ||
| 41 | + | // Target, when not empty, is the host:port the runner relays the sandboxes' | |||
| 42 | + | // forge connections to (EnvGuestForgeTarget). | |||
| 43 | + | Target string | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // RelayTarget is Target, "" when the access is off or not relayed. Safe on nil. | |||
| 47 | + | func (g *GuestForge) RelayTarget() string { | |||
| 48 | + | if g == nil { | |||
| 49 | + | return "" | |||
| 50 | + | } | |||
| 51 | + | return g.Target | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // ParseGuestForgeTarget checks a relay target: host:port with a host name or | |||
| 55 | + | // an IP address and a port in 1-65535. "" is accepted (no relay). | |||
| 56 | + | func ParseGuestForgeTarget(raw string) (string, error) { | |||
| 57 | + | raw = strings.TrimSpace(raw) | |||
| 58 | + | if raw == "" { | |||
| 59 | + | return "", nil | |||
| 60 | + | } | |||
| 61 | + | h, p, err := net.SplitHostPort(raw) | |||
| 62 | + | if err != nil || h == "" { | |||
| 63 | + | return "", fmt.Errorf("%s: %q is not host:port", EnvGuestForgeTarget, raw) | |||
| 64 | + | } | |||
| 65 | + | if n, err := strconv.Atoi(p); err != nil || n < 1 || n > 65535 { | |||
| 66 | + | return "", fmt.Errorf("%s: port %q is not in 1-65535", EnvGuestForgeTarget, p) | |||
| 67 | + | } | |||
| 68 | + | if _, err := netip.ParseAddr(h); err != nil { | |||
| 69 | + | if err := CheckForgeHostname(strings.ToLower(h)); err != nil { | |||
| 70 | + | return "", fmt.Errorf("%s: %w", EnvGuestForgeTarget, err) | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | return raw, nil | |||
| 33 | } | 74 | } | ||
| 34 | 75 | ||||
| 35 | // Warning is the line logged at start and for each job. | 76 | // Warning is the line logged at start and for each job. | ||
| @@ -138,19 +179,33 @@ func CheckForgeHostname(h string) error { | |||||
| 138 | // resolves on the host (lookup is net.DefaultResolver.LookupHost in | 179 | // resolves on the host (lookup is net.DefaultResolver.LookupHost in | ||
| 139 | // production): the runner reaches the forge by that name, so a name that | 180 | // production): the runner reaches the forge by that name, so a name that | ||
| 140 | // resolves to nothing is a configuration error to report at start. | 181 | // resolves to nothing is a configuration error to report at start. | ||
| 141 | - | func ResolveGuestForge(ctx context.Context, raw string, insecureHTTP bool, lookup func(ctx context.Context, host string) ([]string, error)) (*GuestForge, error) { | 182 | + | // |
| 183 | + | // target is the relay target (EnvGuestForgeTarget, "" for none). With a | |||
| 184 | + | // target, the sandboxes still see the forge under the host name of raw, and it | |||
| 185 | + | // is the target's host that must resolve: the runner connects there. | |||
| 186 | + | func ResolveGuestForge(ctx context.Context, raw, target string, insecureHTTP bool, lookup func(ctx context.Context, host string) ([]string, error)) (*GuestForge, error) { | |||
| 142 | g, err := ParseGuestForge(raw, insecureHTTP) | 187 | g, err := ParseGuestForge(raw, insecureHTTP) | ||
| 143 | if err != nil { | 188 | if err != nil { | ||
| 144 | return nil, err | 189 | return nil, err | ||
| 145 | } | 190 | } | ||
| 191 | + | if g.Target, err = ParseGuestForgeTarget(target); err != nil { | |||
| 192 | + | return nil, err | |||
| 193 | + | } | |||
| 194 | + | host := g.Host | |||
| 195 | + | if g.Target != "" { | |||
| 196 | + | host, _, _ = net.SplitHostPort(g.Target) | |||
| 197 | + | if _, err := netip.ParseAddr(host); err == nil { | |||
| 198 | + | return g, nil | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 146 | ctx, cancel := context.WithTimeout(ctx, 10*time.Second) | 201 | ctx, cancel := context.WithTimeout(ctx, 10*time.Second) | ||
| 147 | defer cancel() | 202 | defer cancel() | ||
| 148 | - | addrs, err := lookup(ctx, g.Host) | 203 | + | addrs, err := lookup(ctx, host) |
| 149 | if err != nil { | 204 | if err != nil { | ||
| 150 | - | return nil, fmt.Errorf("%s: %s does not resolve on this host: %w", EnvGuestForge, g.Host, err) | 205 | + | return nil, fmt.Errorf("%s: %s does not resolve on this host: %w", EnvGuestForge, host, err) |
| 151 | } | 206 | } | ||
| 152 | if len(addrs) == 0 { | 207 | if len(addrs) == 0 { | ||
| 153 | - | return nil, fmt.Errorf("%s: %s resolves to no address on this host", EnvGuestForge, g.Host) | 208 | + | return nil, fmt.Errorf("%s: %s resolves to no address on this host", EnvGuestForge, host) |
| 154 | } | 209 | } | ||
| 155 | return g, nil | 210 | return g, nil | ||
| 156 | } | 211 | } | ||
M internal/sandbox/guestforge_test.go +26 -4
| @@ -74,22 +74,22 @@ func TestResolveGuestForge(t *testing.T) { | |||
| 74 | 74 | ctx := context.Background() | |
| 75 | 75 | var asked string | |
| 76 | 76 | lookup := func(_ context.Context, h string) ([]string, error) { asked = h; return []string{"127.0.0.1"}, nil } | |
| 77 | - | g, err := ResolveGuestForge(ctx, "http://ermite.bnema.dev:8088", true, lookup) | |
| 77 | + | g, err := ResolveGuestForge(ctx, "http://ermite.bnema.dev:8088", "", true, lookup) | |
| 78 | 78 | if err != nil || g.Port != 8088 || asked != "ermite.bnema.dev" { | |
| 79 | 79 | t.Fatalf("%+v %v %q", g, err, asked) | |
| 80 | 80 | } | |
| 81 | - | _, err = ResolveGuestForge(ctx, "https://nx.example.org", false, func(context.Context, string) ([]string, error) { | |
| 81 | + | _, err = ResolveGuestForge(ctx, "https://nx.example.org", "", false, func(context.Context, string) ([]string, error) { | |
| 82 | 82 | return nil, errors.New("no such host") | |
| 83 | 83 | }) | |
| 84 | 84 | if err == nil || !strings.Contains(err.Error(), "does not resolve") { | |
| 85 | 85 | t.Fatalf("%v", err) | |
| 86 | 86 | } | |
| 87 | - | _, err = ResolveGuestForge(ctx, "https://empty.example.org", false, func(context.Context, string) ([]string, error) { return nil, nil }) | |
| 87 | + | _, err = ResolveGuestForge(ctx, "https://empty.example.org", "", false, func(context.Context, string) ([]string, error) { return nil, nil }) | |
| 88 | 88 | if err == nil || !strings.Contains(err.Error(), "no address") { | |
| 89 | 89 | t.Fatalf("%v", err) | |
| 90 | 90 | } | |
| 91 | 91 | called := false | |
| 92 | - | _, err = ResolveGuestForge(ctx, "https://10.0.0.1", false, func(context.Context, string) ([]string, error) { called = true; return nil, nil }) | |
| 92 | + | _, err = ResolveGuestForge(ctx, "https://10.0.0.1", "", false, func(context.Context, string) ([]string, error) { called = true; return nil, nil }) | |
| 93 | 93 | if err == nil || called { | |
| 94 | 94 | t.Fatalf("an IP literal was looked up or accepted: %v", err) | |
| 95 | 95 | } | |
| @@ -106,6 +106,28 @@ func TestGuestForgeNilIsOff(t *testing.T) { | |||
| 106 | 106 | } | |
| 107 | 107 | } | |
| 108 | 108 | ||
| 109 | + | func TestResolveGuestForgeTarget(t *testing.T) { | |
| 110 | + | ctx := context.Background() | |
| 111 | + | var asked string | |
| 112 | + | lookup := func(_ context.Context, h string) ([]string, error) { asked = h; return []string{"10.89.0.5"}, nil } | |
| 113 | + | g, err := ResolveGuestForge(ctx, "http://ermite:7341", "ermite:7341", true, lookup) | |
| 114 | + | if err != nil || g.Target != "ermite:7341" || g.RelayTarget() != "ermite:7341" || asked != "ermite" || g.Port != 7341 { | |
| 115 | + | t.Fatalf("%+v %v %q", g, err, asked) | |
| 116 | + | } | |
| 117 | + | asked = "" | |
| 118 | + | if g, err = ResolveGuestForge(ctx, "https://git.example.org", "10.89.0.5:7341", false, lookup); err != nil || asked != "" || g.Target != "10.89.0.5:7341" { | |
| 119 | + | t.Fatalf("an IP target must not be looked up: %+v %v %q", g, err, asked) | |
| 120 | + | } | |
| 121 | + | for _, bad := range []string{"ermite", "ermite:0", "ermite:70000", ":7341", "bad_host:1", "*.x:1"} { | |
| 122 | + | if _, err := ResolveGuestForge(ctx, "http://ermite:7341", bad, true, lookup); err == nil || !strings.Contains(err.Error(), EnvGuestForgeTarget) { | |
| 123 | + | t.Errorf("%q: %v", bad, err) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | if (*GuestForge)(nil).RelayTarget() != "" { | |
| 127 | + | t.Fatal("nil RelayTarget") | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 109 | 131 | func TestLoadConfigGuestForge(t *testing.T) { | |
| 110 | 132 | get := func(v string) func(string) string { | |
| 111 | 133 | return func(k string) string { | |
| @@ -74,22 +74,22 @@ func TestResolveGuestForge(t *testing.T) { | |||||
| 74 | ctx := context.Background() | 74 | ctx := context.Background() | ||
| 75 | var asked string | 75 | var asked string | ||
| 76 | lookup := func(_ context.Context, h string) ([]string, error) { asked = h; return []string{"127.0.0.1"}, nil } | 76 | lookup := func(_ context.Context, h string) ([]string, error) { asked = h; return []string{"127.0.0.1"}, nil } | ||
| 77 | - | g, err := ResolveGuestForge(ctx, "http://ermite.bnema.dev:8088", true, lookup) | 77 | + | g, err := ResolveGuestForge(ctx, "http://ermite.bnema.dev:8088", "", true, lookup) |
| 78 | if err != nil || g.Port != 8088 || asked != "ermite.bnema.dev" { | 78 | if err != nil || g.Port != 8088 || asked != "ermite.bnema.dev" { | ||
| 79 | t.Fatalf("%+v %v %q", g, err, asked) | 79 | t.Fatalf("%+v %v %q", g, err, asked) | ||
| 80 | } | 80 | } | ||
| 81 | - | _, err = ResolveGuestForge(ctx, "https://nx.example.org", false, func(context.Context, string) ([]string, error) { | 81 | + | _, err = ResolveGuestForge(ctx, "https://nx.example.org", "", false, func(context.Context, string) ([]string, error) { |
| 82 | return nil, errors.New("no such host") | 82 | return nil, errors.New("no such host") | ||
| 83 | }) | 83 | }) | ||
| 84 | if err == nil || !strings.Contains(err.Error(), "does not resolve") { | 84 | if err == nil || !strings.Contains(err.Error(), "does not resolve") { | ||
| 85 | t.Fatalf("%v", err) | 85 | t.Fatalf("%v", err) | ||
| 86 | } | 86 | } | ||
| 87 | - | _, err = ResolveGuestForge(ctx, "https://empty.example.org", false, func(context.Context, string) ([]string, error) { return nil, nil }) | 87 | + | _, err = ResolveGuestForge(ctx, "https://empty.example.org", "", false, func(context.Context, string) ([]string, error) { return nil, nil }) |
| 88 | if err == nil || !strings.Contains(err.Error(), "no address") { | 88 | if err == nil || !strings.Contains(err.Error(), "no address") { | ||
| 89 | t.Fatalf("%v", err) | 89 | t.Fatalf("%v", err) | ||
| 90 | } | 90 | } | ||
| 91 | called := false | 91 | called := false | ||
| 92 | - | _, err = ResolveGuestForge(ctx, "https://10.0.0.1", false, func(context.Context, string) ([]string, error) { called = true; return nil, nil }) | 92 | + | _, err = ResolveGuestForge(ctx, "https://10.0.0.1", "", false, func(context.Context, string) ([]string, error) { called = true; return nil, nil }) |
| 93 | if err == nil || called { | 93 | if err == nil || called { | ||
| 94 | t.Fatalf("an IP literal was looked up or accepted: %v", err) | 94 | t.Fatalf("an IP literal was looked up or accepted: %v", err) | ||
| 95 | } | 95 | } | ||
| @@ -106,6 +106,28 @@ func TestGuestForgeNilIsOff(t *testing.T) { | |||||
| 106 | } | 106 | } | ||
| 107 | } | 107 | } | ||
| 108 | 108 | ||||
| 109 | + | func TestResolveGuestForgeTarget(t *testing.T) { | |||
| 110 | + | ctx := context.Background() | |||
| 111 | + | var asked string | |||
| 112 | + | lookup := func(_ context.Context, h string) ([]string, error) { asked = h; return []string{"10.89.0.5"}, nil } | |||
| 113 | + | g, err := ResolveGuestForge(ctx, "http://ermite:7341", "ermite:7341", true, lookup) | |||
| 114 | + | if err != nil || g.Target != "ermite:7341" || g.RelayTarget() != "ermite:7341" || asked != "ermite" || g.Port != 7341 { | |||
| 115 | + | t.Fatalf("%+v %v %q", g, err, asked) | |||
| 116 | + | } | |||
| 117 | + | asked = "" | |||
| 118 | + | if g, err = ResolveGuestForge(ctx, "https://git.example.org", "10.89.0.5:7341", false, lookup); err != nil || asked != "" || g.Target != "10.89.0.5:7341" { | |||
| 119 | + | t.Fatalf("an IP target must not be looked up: %+v %v %q", g, err, asked) | |||
| 120 | + | } | |||
| 121 | + | for _, bad := range []string{"ermite", "ermite:0", "ermite:70000", ":7341", "bad_host:1", "*.x:1"} { | |||
| 122 | + | if _, err := ResolveGuestForge(ctx, "http://ermite:7341", bad, true, lookup); err == nil || !strings.Contains(err.Error(), EnvGuestForgeTarget) { | |||
| 123 | + | t.Errorf("%q: %v", bad, err) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | if (*GuestForge)(nil).RelayTarget() != "" { | |||
| 127 | + | t.Fatal("nil RelayTarget") | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 109 | func TestLoadConfigGuestForge(t *testing.T) { | 131 | func TestLoadConfigGuestForge(t *testing.T) { | ||
| 110 | get := func(v string) func(string) string { | 132 | get := func(v string) func(string) string { | ||
| 111 | return func(k string) string { | 133 | return func(k string) string { | ||
M internal/sandbox/netsetup/netsetup.go +20 -1
| @@ -17,6 +17,7 @@ import ( | |||
| 17 | 17 | "bnema.dev/ermite-runner/internal/cmdrun" | |
| 18 | 18 | "bnema.dev/ermite-runner/internal/dnsfwd" | |
| 19 | 19 | "bnema.dev/ermite-runner/internal/netpolicy" | |
| 20 | + | "bnema.dev/ermite-runner/internal/tcprelay" | |
| 20 | 21 | ) | |
| 21 | 22 | ||
| 22 | 23 | // Plumbing is the host network surface a driver needs. Net implements it; tests | |
| @@ -38,6 +39,10 @@ type Net struct { | |||
| 38 | 39 | DNS *dnsfwd.Forwarder | |
| 39 | 40 | // ForwardFile is the ip_forward sysctl; overridden in tests. | |
| 40 | 41 | ForwardFile string | |
| 42 | + | // Relay, when not nil, forwards the guest forge port of every gateway to | |
| 43 | + | // the forge (ERMITE_RUNNER_GUEST_FORGE_TARGET): the forge then does not | |
| 44 | + | // need to listen in the runner's network namespace. | |
| 45 | + | Relay *tcprelay.Relay | |
| 41 | 46 | } | |
| 42 | 47 | ||
| 43 | 48 | // New builds a Net: nft runs through run, DNS listens on dnsPort and forwards | |
| @@ -52,7 +57,9 @@ func New(run cmdrun.Runner, dnsPort int, resolvConf string) *Net { | |||
| 52 | 57 | } | |
| 53 | 58 | ||
| 54 | 59 | // EnableForwarding turns on IPv4 forwarding (NAT needs it). It is a no-op when | |
| 55 | - | // already on, and an error that names the fix when it cannot be turned on. | |
| 60 | + | // already on, and an error that names the fix when it cannot be turned on. In | |
| 61 | + | // a container /proc/sys is read-only: the runtime must start the container's | |
| 62 | + | // network namespace with net.ipv4.ip_forward=1 (rootless Podman does). | |
| 56 | 63 | func (n *Net) EnableForwarding() error { | |
| 57 | 64 | b, err := os.ReadFile(n.ForwardFile) | |
| 58 | 65 | if err == nil && strings.TrimSpace(string(b)) == "1" { | |
| @@ -74,11 +81,20 @@ func (n *Net) Attach(ctx context.Context, g netpolicy.Guest) error { | |||
| 74 | 81 | if err := n.DNS.Listen(ctx, g.Gateway); err != nil { | |
| 75 | 82 | return errors.Join(err, n.Policy.Remove(context.WithoutCancel(ctx), g.Iface)) | |
| 76 | 83 | } | |
| 84 | + | if n.Relay != nil && g.ForgePort != 0 { | |
| 85 | + | if err := n.Relay.Listen(ctx, g.Gateway, g.ForgePort); err != nil { | |
| 86 | + | n.DNS.Close(g.Gateway) | |
| 87 | + | return errors.Join(err, n.Policy.Remove(context.WithoutCancel(ctx), g.Iface)) | |
| 88 | + | } | |
| 89 | + | } | |
| 77 | 90 | return nil | |
| 78 | 91 | } | |
| 79 | 92 | ||
| 80 | 93 | // Detach undoes Attach. It is idempotent and keeps going after an error. | |
| 81 | 94 | func (n *Net) Detach(ctx context.Context, iface string, gateway netip.Addr) error { | |
| 95 | + | if n.Relay != nil { | |
| 96 | + | n.Relay.Close(gateway) | |
| 97 | + | } | |
| 82 | 98 | n.DNS.Close(gateway) | |
| 83 | 99 | return n.Policy.Remove(ctx, iface) | |
| 84 | 100 | } | |
| @@ -89,5 +105,8 @@ func (n *Net) Reset(ctx context.Context) error { return n.Policy.Reset(ctx) } | |||
| 89 | 105 | ||
| 90 | 106 | // Close stops every DNS listener. | |
| 91 | 107 | func (n *Net) Close() { | |
| 108 | + | if n.Relay != nil { | |
| 109 | + | n.Relay.CloseAll() | |
| 110 | + | } | |
| 92 | 111 | n.DNS.CloseAll() | |
| 93 | 112 | } | |
| @@ -17,6 +17,7 @@ import ( | |||||
| 17 | "bnema.dev/ermite-runner/internal/cmdrun" | 17 | "bnema.dev/ermite-runner/internal/cmdrun" | ||
| 18 | "bnema.dev/ermite-runner/internal/dnsfwd" | 18 | "bnema.dev/ermite-runner/internal/dnsfwd" | ||
| 19 | "bnema.dev/ermite-runner/internal/netpolicy" | 19 | "bnema.dev/ermite-runner/internal/netpolicy" | ||
| 20 | + | "bnema.dev/ermite-runner/internal/tcprelay" | |||
| 20 | ) | 21 | ) | ||
| 21 | 22 | ||||
| 22 | // Plumbing is the host network surface a driver needs. Net implements it; tests | 23 | // Plumbing is the host network surface a driver needs. Net implements it; tests | ||
| @@ -38,6 +39,10 @@ type Net struct { | |||||
| 38 | DNS *dnsfwd.Forwarder | 39 | DNS *dnsfwd.Forwarder | ||
| 39 | // ForwardFile is the ip_forward sysctl; overridden in tests. | 40 | // ForwardFile is the ip_forward sysctl; overridden in tests. | ||
| 40 | ForwardFile string | 41 | ForwardFile string | ||
| 42 | + | // Relay, when not nil, forwards the guest forge port of every gateway to | |||
| 43 | + | // the forge (ERMITE_RUNNER_GUEST_FORGE_TARGET): the forge then does not | |||
| 44 | + | // need to listen in the runner's network namespace. | |||
| 45 | + | Relay *tcprelay.Relay | |||
| 41 | } | 46 | } | ||
| 42 | 47 | ||||
| 43 | // New builds a Net: nft runs through run, DNS listens on dnsPort and forwards | 48 | // New builds a Net: nft runs through run, DNS listens on dnsPort and forwards | ||
| @@ -52,7 +57,9 @@ func New(run cmdrun.Runner, dnsPort int, resolvConf string) *Net { | |||||
| 52 | } | 57 | } | ||
| 53 | 58 | ||||
| 54 | // EnableForwarding turns on IPv4 forwarding (NAT needs it). It is a no-op when | 59 | // EnableForwarding turns on IPv4 forwarding (NAT needs it). It is a no-op when | ||
| 55 | - | // already on, and an error that names the fix when it cannot be turned on. | 60 | + | // already on, and an error that names the fix when it cannot be turned on. In |
| 61 | + | // a container /proc/sys is read-only: the runtime must start the container's | |||
| 62 | + | // network namespace with net.ipv4.ip_forward=1 (rootless Podman does). | |||
| 56 | func (n *Net) EnableForwarding() error { | 63 | func (n *Net) EnableForwarding() error { | ||
| 57 | b, err := os.ReadFile(n.ForwardFile) | 64 | b, err := os.ReadFile(n.ForwardFile) | ||
| 58 | if err == nil && strings.TrimSpace(string(b)) == "1" { | 65 | if err == nil && strings.TrimSpace(string(b)) == "1" { | ||
| @@ -74,11 +81,20 @@ func (n *Net) Attach(ctx context.Context, g netpolicy.Guest) error { | |||||
| 74 | if err := n.DNS.Listen(ctx, g.Gateway); err != nil { | 81 | if err := n.DNS.Listen(ctx, g.Gateway); err != nil { | ||
| 75 | return errors.Join(err, n.Policy.Remove(context.WithoutCancel(ctx), g.Iface)) | 82 | return errors.Join(err, n.Policy.Remove(context.WithoutCancel(ctx), g.Iface)) | ||
| 76 | } | 83 | } | ||
| 84 | + | if n.Relay != nil && g.ForgePort != 0 { | |||
| 85 | + | if err := n.Relay.Listen(ctx, g.Gateway, g.ForgePort); err != nil { | |||
| 86 | + | n.DNS.Close(g.Gateway) | |||
| 87 | + | return errors.Join(err, n.Policy.Remove(context.WithoutCancel(ctx), g.Iface)) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 77 | return nil | 90 | return nil | ||
| 78 | } | 91 | } | ||
| 79 | 92 | ||||
| 80 | // Detach undoes Attach. It is idempotent and keeps going after an error. | 93 | // Detach undoes Attach. It is idempotent and keeps going after an error. | ||
| 81 | func (n *Net) Detach(ctx context.Context, iface string, gateway netip.Addr) error { | 94 | func (n *Net) Detach(ctx context.Context, iface string, gateway netip.Addr) error { | ||
| 95 | + | if n.Relay != nil { | |||
| 96 | + | n.Relay.Close(gateway) | |||
| 97 | + | } | |||
| 82 | n.DNS.Close(gateway) | 98 | n.DNS.Close(gateway) | ||
| 83 | return n.Policy.Remove(ctx, iface) | 99 | return n.Policy.Remove(ctx, iface) | ||
| 84 | } | 100 | } | ||
| @@ -89,5 +105,8 @@ func (n *Net) Reset(ctx context.Context) error { return n.Policy.Reset(ctx) } | |||||
| 89 | 105 | ||||
| 90 | // Close stops every DNS listener. | 106 | // Close stops every DNS listener. | ||
| 91 | func (n *Net) Close() { | 107 | func (n *Net) Close() { | ||
| 108 | + | if n.Relay != nil { | |||
| 109 | + | n.Relay.CloseAll() | |||
| 110 | + | } | |||
| 92 | n.DNS.CloseAll() | 111 | n.DNS.CloseAll() | ||
| 93 | } | 112 | } | ||
M internal/sandbox/netsetup/netsetup_test.go +56 -0
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | "bnema.dev/ermite-runner/internal/cmdrun" | |
| 15 | 15 | "bnema.dev/ermite-runner/internal/netpolicy" | |
| 16 | + | "bnema.dev/ermite-runner/internal/tcprelay" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| 18 | 19 | func guest() netpolicy.Guest { | |
| @@ -81,6 +82,61 @@ func TestAttachRollsBackPolicyWhenDNSFails(t *testing.T) { | |||
| 81 | 82 | } | |
| 82 | 83 | } | |
| 83 | 84 | ||
| 85 | + | // With a relay, Attach relays the guest forge port of the gateway, Detach stops | |
| 86 | + | // it, and a guest without a forge port gets no relay listener. | |
| 87 | + | func TestAttachStartsAndStopsTheForgeRelay(t *testing.T) { | |
| 88 | + | f := &cmdrun.Fake{} | |
| 89 | + | n := New(f, 0, "") | |
| 90 | + | n.Relay = tcprelay.New("127.0.0.1:1") | |
| 91 | + | t.Cleanup(n.Close) | |
| 92 | + | ctx := context.Background() | |
| 93 | + | g := guest() | |
| 94 | + | if err := n.Attach(ctx, g); err != nil { | |
| 95 | + | t.Skip("cannot bind loopback:", err) | |
| 96 | + | } | |
| 97 | + | if _, ok := n.Relay.Addr(g.Gateway); ok { | |
| 98 | + | t.Fatal("a relay was started for a guest without a forge port") | |
| 99 | + | } | |
| 100 | + | if err := n.Detach(ctx, g.Iface, g.Gateway); err != nil { | |
| 101 | + | t.Fatal(err) | |
| 102 | + | } | |
| 103 | + | // Port 0 lets the kernel pick a free port for the relay. | |
| 104 | + | g.ForgePort = 0 | |
| 105 | + | n.Relay = tcprelay.New("127.0.0.1:1") | |
| 106 | + | if err := n.Relay.Listen(ctx, g.Gateway, 0); err != nil { | |
| 107 | + | t.Skip(err) | |
| 108 | + | } | |
| 109 | + | if _, ok := n.Relay.Addr(g.Gateway); !ok { | |
| 110 | + | t.Fatal("relay not listening") | |
| 111 | + | } | |
| 112 | + | if err := n.Detach(ctx, g.Iface, g.Gateway); err != nil { | |
| 113 | + | t.Fatal(err) | |
| 114 | + | } | |
| 115 | + | if _, ok := n.Relay.Addr(g.Gateway); ok { | |
| 116 | + | t.Fatal("relay still listening after Detach") | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // A relay that cannot bind rolls back the DNS listener and the policy. | |
| 121 | + | func TestAttachRollsBackWhenTheRelayFails(t *testing.T) { | |
| 122 | + | f := &cmdrun.Fake{} | |
| 123 | + | n := New(f, 0, "") | |
| 124 | + | n.Relay = tcprelay.New("127.0.0.1:1") | |
| 125 | + | t.Cleanup(n.Close) | |
| 126 | + | ctx := context.Background() | |
| 127 | + | g := guest() | |
| 128 | + | g.ForgePort = 1 // privileged port: binding fails without CAP_NET_BIND_SERVICE | |
| 129 | + | if err := n.Attach(ctx, g); err == nil { | |
| 130 | + | t.Skip("port 1 could be bound here") | |
| 131 | + | } | |
| 132 | + | if _, ok := n.DNS.Addr(g.Gateway); ok { | |
| 133 | + | t.Fatal("DNS still listening after a failed Attach") | |
| 134 | + | } | |
| 135 | + | if len(f.Calls) != 2 { | |
| 136 | + | t.Fatalf("expected add then rollback remove, got %v", f.Lines()) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 84 | 140 | func TestAttachPolicyError(t *testing.T) { | |
| 85 | 141 | boom := errors.New("nft failed") | |
| 86 | 142 | n := New(&cmdrun.Fake{Handle: func(cmdrun.Call) ([]byte, error) { return nil, boom }}, 0, "") | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | "bnema.dev/ermite-runner/internal/cmdrun" | 14 | "bnema.dev/ermite-runner/internal/cmdrun" | ||
| 15 | "bnema.dev/ermite-runner/internal/netpolicy" | 15 | "bnema.dev/ermite-runner/internal/netpolicy" | ||
| 16 | + | "bnema.dev/ermite-runner/internal/tcprelay" | |||
| 16 | ) | 17 | ) | ||
| 17 | 18 | ||||
| 18 | func guest() netpolicy.Guest { | 19 | func guest() netpolicy.Guest { | ||
| @@ -81,6 +82,61 @@ func TestAttachRollsBackPolicyWhenDNSFails(t *testing.T) { | |||||
| 81 | } | 82 | } | ||
| 82 | } | 83 | } | ||
| 83 | 84 | ||||
| 85 | + | // With a relay, Attach relays the guest forge port of the gateway, Detach stops | |||
| 86 | + | // it, and a guest without a forge port gets no relay listener. | |||
| 87 | + | func TestAttachStartsAndStopsTheForgeRelay(t *testing.T) { | |||
| 88 | + | f := &cmdrun.Fake{} | |||
| 89 | + | n := New(f, 0, "") | |||
| 90 | + | n.Relay = tcprelay.New("127.0.0.1:1") | |||
| 91 | + | t.Cleanup(n.Close) | |||
| 92 | + | ctx := context.Background() | |||
| 93 | + | g := guest() | |||
| 94 | + | if err := n.Attach(ctx, g); err != nil { | |||
| 95 | + | t.Skip("cannot bind loopback:", err) | |||
| 96 | + | } | |||
| 97 | + | if _, ok := n.Relay.Addr(g.Gateway); ok { | |||
| 98 | + | t.Fatal("a relay was started for a guest without a forge port") | |||
| 99 | + | } | |||
| 100 | + | if err := n.Detach(ctx, g.Iface, g.Gateway); err != nil { | |||
| 101 | + | t.Fatal(err) | |||
| 102 | + | } | |||
| 103 | + | // Port 0 lets the kernel pick a free port for the relay. | |||
| 104 | + | g.ForgePort = 0 | |||
| 105 | + | n.Relay = tcprelay.New("127.0.0.1:1") | |||
| 106 | + | if err := n.Relay.Listen(ctx, g.Gateway, 0); err != nil { | |||
| 107 | + | t.Skip(err) | |||
| 108 | + | } | |||
| 109 | + | if _, ok := n.Relay.Addr(g.Gateway); !ok { | |||
| 110 | + | t.Fatal("relay not listening") | |||
| 111 | + | } | |||
| 112 | + | if err := n.Detach(ctx, g.Iface, g.Gateway); err != nil { | |||
| 113 | + | t.Fatal(err) | |||
| 114 | + | } | |||
| 115 | + | if _, ok := n.Relay.Addr(g.Gateway); ok { | |||
| 116 | + | t.Fatal("relay still listening after Detach") | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // A relay that cannot bind rolls back the DNS listener and the policy. | |||
| 121 | + | func TestAttachRollsBackWhenTheRelayFails(t *testing.T) { | |||
| 122 | + | f := &cmdrun.Fake{} | |||
| 123 | + | n := New(f, 0, "") | |||
| 124 | + | n.Relay = tcprelay.New("127.0.0.1:1") | |||
| 125 | + | t.Cleanup(n.Close) | |||
| 126 | + | ctx := context.Background() | |||
| 127 | + | g := guest() | |||
| 128 | + | g.ForgePort = 1 // privileged port: binding fails without CAP_NET_BIND_SERVICE | |||
| 129 | + | if err := n.Attach(ctx, g); err == nil { | |||
| 130 | + | t.Skip("port 1 could be bound here") | |||
| 131 | + | } | |||
| 132 | + | if _, ok := n.DNS.Addr(g.Gateway); ok { | |||
| 133 | + | t.Fatal("DNS still listening after a failed Attach") | |||
| 134 | + | } | |||
| 135 | + | if len(f.Calls) != 2 { | |||
| 136 | + | t.Fatalf("expected add then rollback remove, got %v", f.Lines()) | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 84 | func TestAttachPolicyError(t *testing.T) { | 140 | func TestAttachPolicyError(t *testing.T) { | ||
| 85 | boom := errors.New("nft failed") | 141 | boom := errors.New("nft failed") | ||
| 86 | n := New(&cmdrun.Fake{Handle: func(cmdrun.Call) ([]byte, error) { return nil, boom }}, 0, "") | 142 | n := New(&cmdrun.Fake{Handle: func(cmdrun.Call) ([]byte, error) { return nil, boom }}, 0, "") | ||
M internal/sandbox/vm/driver.go +4 -0
| @@ -33,6 +33,7 @@ import ( | |||
| 33 | 33 | "bnema.dev/ermite-runner/internal/sandbox" | |
| 34 | 34 | "bnema.dev/ermite-runner/internal/sandbox/disk" | |
| 35 | 35 | "bnema.dev/ermite-runner/internal/sandbox/netsetup" | |
| 36 | + | "bnema.dev/ermite-runner/internal/tcprelay" | |
| 36 | 37 | ) | |
| 37 | 38 | ||
| 38 | 39 | // Options are the testable knobs of the driver. | |
| @@ -152,6 +153,9 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) | |||
| 152 | 153 | if d.net == nil { | |
| 153 | 154 | n := netsetup.New(opt.Run, cfg.DNSPort, "") | |
| 154 | 155 | n.DNS.Override = cfg.GuestForge.DNSName() | |
| 156 | + | if t := cfg.GuestForge.RelayTarget(); t != "" { | |
| 157 | + | n.Relay = tcprelay.New(t) | |
| 158 | + | } | |
| 155 | 159 | d.net = n | |
| 156 | 160 | } | |
| 157 | 161 | if cfg.GuestForge != nil { | |
| @@ -33,6 +33,7 @@ import ( | |||||
| 33 | "bnema.dev/ermite-runner/internal/sandbox" | 33 | "bnema.dev/ermite-runner/internal/sandbox" | ||
| 34 | "bnema.dev/ermite-runner/internal/sandbox/disk" | 34 | "bnema.dev/ermite-runner/internal/sandbox/disk" | ||
| 35 | "bnema.dev/ermite-runner/internal/sandbox/netsetup" | 35 | "bnema.dev/ermite-runner/internal/sandbox/netsetup" | ||
| 36 | + | "bnema.dev/ermite-runner/internal/tcprelay" | |||
| 36 | ) | 37 | ) | ||
| 37 | 38 | ||||
| 38 | // Options are the testable knobs of the driver. | 39 | // Options are the testable knobs of the driver. | ||
| @@ -152,6 +153,9 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) | |||||
| 152 | if d.net == nil { | 153 | if d.net == nil { | ||
| 153 | n := netsetup.New(opt.Run, cfg.DNSPort, "") | 154 | n := netsetup.New(opt.Run, cfg.DNSPort, "") | ||
| 154 | n.DNS.Override = cfg.GuestForge.DNSName() | 155 | n.DNS.Override = cfg.GuestForge.DNSName() | ||
| 156 | + | if t := cfg.GuestForge.RelayTarget(); t != "" { | |||
| 157 | + | n.Relay = tcprelay.New(t) | |||
| 158 | + | } | |||
| 155 | d.net = n | 159 | d.net = n | ||
| 156 | } | 160 | } | ||
| 157 | if cfg.GuestForge != nil { | 161 | if cfg.GuestForge != nil { | ||
A internal/tcprelay/tcprelay.go +183 -0
| @@ -0,0 +1,183 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | // Package tcprelay forwards TCP connections accepted on a sandbox's gateway | |
| 4 | + | // address to one fixed target. It carries the guest forge access when the | |
| 5 | + | // forge does not listen on the runner's own network namespace: a runner in a | |
| 6 | + | // container reaches the forge by name on its container network, and the | |
| 7 | + | // sandboxes, which only reach their gateway, are relayed there. | |
| 8 | + | package tcprelay | |
| 9 | + | ||
| 10 | + | import ( | |
| 11 | + | "context" | |
| 12 | + | "errors" | |
| 13 | + | "fmt" | |
| 14 | + | "io" | |
| 15 | + | "net" | |
| 16 | + | "net/netip" | |
| 17 | + | "strconv" | |
| 18 | + | "sync" | |
| 19 | + | "time" | |
| 20 | + | ||
| 21 | + | "github.com/bnema/zerowrap" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // MaxConns bounds the connections relayed at once for one listener: a guest | |
| 25 | + | // cannot exhaust the runner's file descriptors. | |
| 26 | + | const MaxConns = 64 | |
| 27 | + | ||
| 28 | + | // dialTimeout bounds the connection to the target. | |
| 29 | + | const dialTimeout = 10 * time.Second | |
| 30 | + | ||
| 31 | + | // Relay forwards every connection accepted on its listeners to Target. | |
| 32 | + | type Relay struct { | |
| 33 | + | // Target is host:port, resolved again for every connection. | |
| 34 | + | Target string | |
| 35 | + | // Dial connects to Target; nil means a net.Dialer. | |
| 36 | + | Dial func(ctx context.Context, network, addr string) (net.Conn, error) | |
| 37 | + | ||
| 38 | + | mu sync.Mutex | |
| 39 | + | listeners map[netip.Addr]*listener | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type listener struct { | |
| 43 | + | ln net.Listener | |
| 44 | + | cancel context.CancelFunc | |
| 45 | + | done sync.WaitGroup | |
| 46 | + | slots chan struct{} | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // New returns a relay to target (host:port). | |
| 50 | + | func New(target string) *Relay { | |
| 51 | + | return &Relay{Target: target, listeners: map[netip.Addr]*listener{}} | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // Listen starts relaying connections to addr:port. It returns once the socket | |
| 55 | + | // is bound. Listening twice on the same address is an error. | |
| 56 | + | func (r *Relay) Listen(ctx context.Context, addr netip.Addr, port uint16) error { | |
| 57 | + | r.mu.Lock() | |
| 58 | + | defer r.mu.Unlock() | |
| 59 | + | if r.listeners == nil { | |
| 60 | + | r.listeners = map[netip.Addr]*listener{} | |
| 61 | + | } | |
| 62 | + | if _, ok := r.listeners[addr]; ok { | |
| 63 | + | return fmt.Errorf("tcprelay: already listening on %s", addr) | |
| 64 | + | } | |
| 65 | + | var lc net.ListenConfig | |
| 66 | + | ln, err := lc.Listen(ctx, "tcp", net.JoinHostPort(addr.String(), strconv.Itoa(int(port)))) | |
| 67 | + | if err != nil { | |
| 68 | + | return fmt.Errorf("tcprelay: listen %s:%d: %w", addr, port, err) | |
| 69 | + | } | |
| 70 | + | lctx, cancel := context.WithCancel(context.WithoutCancel(ctx)) | |
| 71 | + | l := &listener{ln: ln, cancel: cancel, slots: make(chan struct{}, MaxConns)} | |
| 72 | + | r.listeners[addr] = l | |
| 73 | + | l.done.Add(1) | |
| 74 | + | go func() { | |
| 75 | + | defer l.done.Done() | |
| 76 | + | r.serve(lctx, l) | |
| 77 | + | }() | |
| 78 | + | return nil | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Addr returns the bound address for addr, useful when port is 0. | |
| 82 | + | func (r *Relay) Addr(addr netip.Addr) (net.Addr, bool) { | |
| 83 | + | r.mu.Lock() | |
| 84 | + | defer r.mu.Unlock() | |
| 85 | + | l, ok := r.listeners[addr] | |
| 86 | + | if !ok { | |
| 87 | + | return nil, false | |
| 88 | + | } | |
| 89 | + | return l.ln.Addr(), true | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // Close stops the listener of addr and its relayed connections. It is a no-op | |
| 93 | + | // when there is none. | |
| 94 | + | func (r *Relay) Close(addr netip.Addr) { | |
| 95 | + | r.mu.Lock() | |
| 96 | + | l, ok := r.listeners[addr] | |
| 97 | + | delete(r.listeners, addr) | |
| 98 | + | r.mu.Unlock() | |
| 99 | + | if ok { | |
| 100 | + | l.stop() | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // CloseAll stops every listener. | |
| 105 | + | func (r *Relay) CloseAll() { | |
| 106 | + | r.mu.Lock() | |
| 107 | + | ls := r.listeners | |
| 108 | + | r.listeners = map[netip.Addr]*listener{} | |
| 109 | + | r.mu.Unlock() | |
| 110 | + | for _, l := range ls { | |
| 111 | + | l.stop() | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func (l *listener) stop() { | |
| 116 | + | l.cancel() | |
| 117 | + | _ = l.ln.Close() | |
| 118 | + | l.done.Wait() | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func (r *Relay) serve(ctx context.Context, l *listener) { | |
| 122 | + | log := zerowrap.FromCtx(ctx) | |
| 123 | + | for { | |
| 124 | + | c, err := l.ln.Accept() | |
| 125 | + | if err != nil { | |
| 126 | + | if ctx.Err() != nil || errors.Is(err, net.ErrClosed) { | |
| 127 | + | return | |
| 128 | + | } | |
| 129 | + | log.Warn().Err(err).Msg("tcprelay: accept") | |
| 130 | + | continue | |
| 131 | + | } | |
| 132 | + | select { | |
| 133 | + | case l.slots <- struct{}{}: | |
| 134 | + | default: | |
| 135 | + | log.Warn().Str("target", r.Target).Msg("tcprelay: too many relayed connections, refusing one") | |
| 136 | + | _ = c.Close() | |
| 137 | + | continue | |
| 138 | + | } | |
| 139 | + | l.done.Add(1) | |
| 140 | + | go func() { | |
| 141 | + | defer l.done.Done() | |
| 142 | + | defer func() { <-l.slots }() | |
| 143 | + | r.relay(ctx, c) | |
| 144 | + | }() | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func (r *Relay) relay(ctx context.Context, in net.Conn) { | |
| 149 | + | defer func() { _ = in.Close() }() | |
| 150 | + | dial := r.Dial | |
| 151 | + | if dial == nil { | |
| 152 | + | d := net.Dialer{} | |
| 153 | + | dial = d.DialContext | |
| 154 | + | } | |
| 155 | + | dctx, cancel := context.WithTimeout(ctx, dialTimeout) | |
| 156 | + | out, err := dial(dctx, "tcp", r.Target) | |
| 157 | + | cancel() | |
| 158 | + | if err != nil { | |
| 159 | + | log := zerowrap.FromCtx(ctx) | |
| 160 | + | log.Warn().Err(err).Str("target", r.Target).Msg("tcprelay: dial") | |
| 161 | + | return | |
| 162 | + | } | |
| 163 | + | defer func() { _ = out.Close() }() | |
| 164 | + | // Closing both ends when the listener stops ends the copies. | |
| 165 | + | stop := context.AfterFunc(ctx, func() { _ = in.Close(); _ = out.Close() }) | |
| 166 | + | defer stop() | |
| 167 | + | var wg sync.WaitGroup | |
| 168 | + | wg.Add(2) | |
| 169 | + | go func() { defer wg.Done(); pipe(out, in) }() | |
| 170 | + | go func() { defer wg.Done(); pipe(in, out) }() | |
| 171 | + | wg.Wait() | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // pipe copies src to dst, then half-closes dst so the other side sees the end | |
| 175 | + | // of the stream while the reverse direction may still be sending. | |
| 176 | + | func pipe(dst, src net.Conn) { | |
| 177 | + | _, _ = io.Copy(dst, src) | |
| 178 | + | if cw, ok := dst.(interface{ CloseWrite() error }); ok { | |
| 179 | + | _ = cw.CloseWrite() | |
| 180 | + | } else { | |
| 181 | + | _ = dst.Close() | |
| 182 | + | } | |
| 183 | + | } | |
| @@ -0,0 +1,183 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | // Package tcprelay forwards TCP connections accepted on a sandbox's gateway | |||
| 4 | + | // address to one fixed target. It carries the guest forge access when the | |||
| 5 | + | // forge does not listen on the runner's own network namespace: a runner in a | |||
| 6 | + | // container reaches the forge by name on its container network, and the | |||
| 7 | + | // sandboxes, which only reach their gateway, are relayed there. | |||
| 8 | + | package tcprelay | |||
| 9 | + | ||||
| 10 | + | import ( | |||
| 11 | + | "context" | |||
| 12 | + | "errors" | |||
| 13 | + | "fmt" | |||
| 14 | + | "io" | |||
| 15 | + | "net" | |||
| 16 | + | "net/netip" | |||
| 17 | + | "strconv" | |||
| 18 | + | "sync" | |||
| 19 | + | "time" | |||
| 20 | + | ||||
| 21 | + | "github.com/bnema/zerowrap" | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | // MaxConns bounds the connections relayed at once for one listener: a guest | |||
| 25 | + | // cannot exhaust the runner's file descriptors. | |||
| 26 | + | const MaxConns = 64 | |||
| 27 | + | ||||
| 28 | + | // dialTimeout bounds the connection to the target. | |||
| 29 | + | const dialTimeout = 10 * time.Second | |||
| 30 | + | ||||
| 31 | + | // Relay forwards every connection accepted on its listeners to Target. | |||
| 32 | + | type Relay struct { | |||
| 33 | + | // Target is host:port, resolved again for every connection. | |||
| 34 | + | Target string | |||
| 35 | + | // Dial connects to Target; nil means a net.Dialer. | |||
| 36 | + | Dial func(ctx context.Context, network, addr string) (net.Conn, error) | |||
| 37 | + | ||||
| 38 | + | mu sync.Mutex | |||
| 39 | + | listeners map[netip.Addr]*listener | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type listener struct { | |||
| 43 | + | ln net.Listener | |||
| 44 | + | cancel context.CancelFunc | |||
| 45 | + | done sync.WaitGroup | |||
| 46 | + | slots chan struct{} | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // New returns a relay to target (host:port). | |||
| 50 | + | func New(target string) *Relay { | |||
| 51 | + | return &Relay{Target: target, listeners: map[netip.Addr]*listener{}} | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // Listen starts relaying connections to addr:port. It returns once the socket | |||
| 55 | + | // is bound. Listening twice on the same address is an error. | |||
| 56 | + | func (r *Relay) Listen(ctx context.Context, addr netip.Addr, port uint16) error { | |||
| 57 | + | r.mu.Lock() | |||
| 58 | + | defer r.mu.Unlock() | |||
| 59 | + | if r.listeners == nil { | |||
| 60 | + | r.listeners = map[netip.Addr]*listener{} | |||
| 61 | + | } | |||
| 62 | + | if _, ok := r.listeners[addr]; ok { | |||
| 63 | + | return fmt.Errorf("tcprelay: already listening on %s", addr) | |||
| 64 | + | } | |||
| 65 | + | var lc net.ListenConfig | |||
| 66 | + | ln, err := lc.Listen(ctx, "tcp", net.JoinHostPort(addr.String(), strconv.Itoa(int(port)))) | |||
| 67 | + | if err != nil { | |||
| 68 | + | return fmt.Errorf("tcprelay: listen %s:%d: %w", addr, port, err) | |||
| 69 | + | } | |||
| 70 | + | lctx, cancel := context.WithCancel(context.WithoutCancel(ctx)) | |||
| 71 | + | l := &listener{ln: ln, cancel: cancel, slots: make(chan struct{}, MaxConns)} | |||
| 72 | + | r.listeners[addr] = l | |||
| 73 | + | l.done.Add(1) | |||
| 74 | + | go func() { | |||
| 75 | + | defer l.done.Done() | |||
| 76 | + | r.serve(lctx, l) | |||
| 77 | + | }() | |||
| 78 | + | return nil | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // Addr returns the bound address for addr, useful when port is 0. | |||
| 82 | + | func (r *Relay) Addr(addr netip.Addr) (net.Addr, bool) { | |||
| 83 | + | r.mu.Lock() | |||
| 84 | + | defer r.mu.Unlock() | |||
| 85 | + | l, ok := r.listeners[addr] | |||
| 86 | + | if !ok { | |||
| 87 | + | return nil, false | |||
| 88 | + | } | |||
| 89 | + | return l.ln.Addr(), true | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // Close stops the listener of addr and its relayed connections. It is a no-op | |||
| 93 | + | // when there is none. | |||
| 94 | + | func (r *Relay) Close(addr netip.Addr) { | |||
| 95 | + | r.mu.Lock() | |||
| 96 | + | l, ok := r.listeners[addr] | |||
| 97 | + | delete(r.listeners, addr) | |||
| 98 | + | r.mu.Unlock() | |||
| 99 | + | if ok { | |||
| 100 | + | l.stop() | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // CloseAll stops every listener. | |||
| 105 | + | func (r *Relay) CloseAll() { | |||
| 106 | + | r.mu.Lock() | |||
| 107 | + | ls := r.listeners | |||
| 108 | + | r.listeners = map[netip.Addr]*listener{} | |||
| 109 | + | r.mu.Unlock() | |||
| 110 | + | for _, l := range ls { | |||
| 111 | + | l.stop() | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func (l *listener) stop() { | |||
| 116 | + | l.cancel() | |||
| 117 | + | _ = l.ln.Close() | |||
| 118 | + | l.done.Wait() | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func (r *Relay) serve(ctx context.Context, l *listener) { | |||
| 122 | + | log := zerowrap.FromCtx(ctx) | |||
| 123 | + | for { | |||
| 124 | + | c, err := l.ln.Accept() | |||
| 125 | + | if err != nil { | |||
| 126 | + | if ctx.Err() != nil || errors.Is(err, net.ErrClosed) { | |||
| 127 | + | return | |||
| 128 | + | } | |||
| 129 | + | log.Warn().Err(err).Msg("tcprelay: accept") | |||
| 130 | + | continue | |||
| 131 | + | } | |||
| 132 | + | select { | |||
| 133 | + | case l.slots <- struct{}{}: | |||
| 134 | + | default: | |||
| 135 | + | log.Warn().Str("target", r.Target).Msg("tcprelay: too many relayed connections, refusing one") | |||
| 136 | + | _ = c.Close() | |||
| 137 | + | continue | |||
| 138 | + | } | |||
| 139 | + | l.done.Add(1) | |||
| 140 | + | go func() { | |||
| 141 | + | defer l.done.Done() | |||
| 142 | + | defer func() { <-l.slots }() | |||
| 143 | + | r.relay(ctx, c) | |||
| 144 | + | }() | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func (r *Relay) relay(ctx context.Context, in net.Conn) { | |||
| 149 | + | defer func() { _ = in.Close() }() | |||
| 150 | + | dial := r.Dial | |||
| 151 | + | if dial == nil { | |||
| 152 | + | d := net.Dialer{} | |||
| 153 | + | dial = d.DialContext | |||
| 154 | + | } | |||
| 155 | + | dctx, cancel := context.WithTimeout(ctx, dialTimeout) | |||
| 156 | + | out, err := dial(dctx, "tcp", r.Target) | |||
| 157 | + | cancel() | |||
| 158 | + | if err != nil { | |||
| 159 | + | log := zerowrap.FromCtx(ctx) | |||
| 160 | + | log.Warn().Err(err).Str("target", r.Target).Msg("tcprelay: dial") | |||
| 161 | + | return | |||
| 162 | + | } | |||
| 163 | + | defer func() { _ = out.Close() }() | |||
| 164 | + | // Closing both ends when the listener stops ends the copies. | |||
| 165 | + | stop := context.AfterFunc(ctx, func() { _ = in.Close(); _ = out.Close() }) | |||
| 166 | + | defer stop() | |||
| 167 | + | var wg sync.WaitGroup | |||
| 168 | + | wg.Add(2) | |||
| 169 | + | go func() { defer wg.Done(); pipe(out, in) }() | |||
| 170 | + | go func() { defer wg.Done(); pipe(in, out) }() | |||
| 171 | + | wg.Wait() | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | // pipe copies src to dst, then half-closes dst so the other side sees the end | |||
| 175 | + | // of the stream while the reverse direction may still be sending. | |||
| 176 | + | func pipe(dst, src net.Conn) { | |||
| 177 | + | _, _ = io.Copy(dst, src) | |||
| 178 | + | if cw, ok := dst.(interface{ CloseWrite() error }); ok { | |||
| 179 | + | _ = cw.CloseWrite() | |||
| 180 | + | } else { | |||
| 181 | + | _ = dst.Close() | |||
| 182 | + | } | |||
| 183 | + | } | |||
A internal/tcprelay/tcprelay_test.go +123 -0
| @@ -0,0 +1,123 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package tcprelay | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bufio" | |
| 7 | + | "context" | |
| 8 | + | "io" | |
| 9 | + | "net" | |
| 10 | + | "net/netip" | |
| 11 | + | "strconv" | |
| 12 | + | "testing" | |
| 13 | + | "time" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | var lo = netip.MustParseAddr("127.0.0.1") | |
| 17 | + | ||
| 18 | + | // echo starts a line echo server and returns its address. | |
| 19 | + | func echo(t *testing.T) string { | |
| 20 | + | t.Helper() | |
| 21 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |
| 22 | + | if err != nil { | |
| 23 | + | t.Skip("cannot bind loopback:", err) | |
| 24 | + | } | |
| 25 | + | t.Cleanup(func() { _ = ln.Close() }) | |
| 26 | + | go func() { | |
| 27 | + | for { | |
| 28 | + | c, err := ln.Accept() | |
| 29 | + | if err != nil { | |
| 30 | + | return | |
| 31 | + | } | |
| 32 | + | go func() { defer func() { _ = c.Close() }(); _, _ = io.Copy(c, c) }() | |
| 33 | + | } | |
| 34 | + | }() | |
| 35 | + | return ln.Addr().String() | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func dialRelay(t *testing.T, r *Relay) net.Conn { | |
| 39 | + | t.Helper() | |
| 40 | + | a, ok := r.Addr(lo) | |
| 41 | + | if !ok { | |
| 42 | + | t.Fatal("relay not listening") | |
| 43 | + | } | |
| 44 | + | c, err := net.DialTimeout("tcp", a.String(), 2*time.Second) | |
| 45 | + | if err != nil { | |
| 46 | + | t.Fatal(err) | |
| 47 | + | } | |
| 48 | + | t.Cleanup(func() { _ = c.Close() }) | |
| 49 | + | return c | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestRelayForwardsBothWays(t *testing.T) { | |
| 53 | + | r := New(echo(t)) | |
| 54 | + | if err := r.Listen(context.Background(), lo, 0); err != nil { | |
| 55 | + | t.Fatal(err) | |
| 56 | + | } | |
| 57 | + | t.Cleanup(r.CloseAll) | |
| 58 | + | c := dialRelay(t, r) | |
| 59 | + | _ = c.SetDeadline(time.Now().Add(5 * time.Second)) | |
| 60 | + | if _, err := c.Write([]byte("hello\n")); err != nil { | |
| 61 | + | t.Fatal(err) | |
| 62 | + | } | |
| 63 | + | line, err := bufio.NewReader(c).ReadString('\n') | |
| 64 | + | if err != nil || line != "hello\n" { | |
| 65 | + | t.Fatalf("got %q, %v", line, err) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestRelayListenTwiceFails(t *testing.T) { | |
| 70 | + | r := New(echo(t)) | |
| 71 | + | if err := r.Listen(context.Background(), lo, 0); err != nil { | |
| 72 | + | t.Fatal(err) | |
| 73 | + | } | |
| 74 | + | t.Cleanup(r.CloseAll) | |
| 75 | + | if err := r.Listen(context.Background(), lo, 0); err == nil { | |
| 76 | + | t.Fatal("a second listener on the same address was accepted") | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestRelayCloseStopsListenerAndConnections(t *testing.T) { | |
| 81 | + | r := New(echo(t)) | |
| 82 | + | if err := r.Listen(context.Background(), lo, 0); err != nil { | |
| 83 | + | t.Fatal(err) | |
| 84 | + | } | |
| 85 | + | a, _ := r.Addr(lo) | |
| 86 | + | c := dialRelay(t, r) | |
| 87 | + | if _, err := c.Write([]byte("x\n")); err != nil { | |
| 88 | + | t.Fatal(err) | |
| 89 | + | } | |
| 90 | + | _, _ = bufio.NewReader(c).ReadString('\n') | |
| 91 | + | r.Close(lo) | |
| 92 | + | if _, ok := r.Addr(lo); ok { | |
| 93 | + | t.Fatal("still listening after Close") | |
| 94 | + | } | |
| 95 | + | _ = c.SetReadDeadline(time.Now().Add(2 * time.Second)) | |
| 96 | + | if _, err := c.Read(make([]byte, 1)); err == nil { | |
| 97 | + | t.Fatal("relayed connection still open after Close") | |
| 98 | + | } | |
| 99 | + | if c2, err := net.DialTimeout("tcp", a.String(), time.Second); err == nil { | |
| 100 | + | _ = c2.Close() | |
| 101 | + | t.Fatal("new connections still accepted after Close") | |
| 102 | + | } | |
| 103 | + | r.Close(lo) // idempotent | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestRelayUnreachableTargetClosesTheGuestSide(t *testing.T) { | |
| 107 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |
| 108 | + | if err != nil { | |
| 109 | + | t.Skip(err) | |
| 110 | + | } | |
| 111 | + | port := ln.Addr().(*net.TCPAddr).Port | |
| 112 | + | _ = ln.Close() // nothing listens there now | |
| 113 | + | r := New(net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) | |
| 114 | + | if err := r.Listen(context.Background(), lo, 0); err != nil { | |
| 115 | + | t.Fatal(err) | |
| 116 | + | } | |
| 117 | + | t.Cleanup(r.CloseAll) | |
| 118 | + | c := dialRelay(t, r) | |
| 119 | + | _ = c.SetReadDeadline(time.Now().Add(5 * time.Second)) | |
| 120 | + | if _, err := c.Read(make([]byte, 1)); err == nil { | |
| 121 | + | t.Fatal("expected the connection to be closed") | |
| 122 | + | } | |
| 123 | + | } | |
| @@ -0,0 +1,123 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package tcprelay | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bufio" | |||
| 7 | + | "context" | |||
| 8 | + | "io" | |||
| 9 | + | "net" | |||
| 10 | + | "net/netip" | |||
| 11 | + | "strconv" | |||
| 12 | + | "testing" | |||
| 13 | + | "time" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | var lo = netip.MustParseAddr("127.0.0.1") | |||
| 17 | + | ||||
| 18 | + | // echo starts a line echo server and returns its address. | |||
| 19 | + | func echo(t *testing.T) string { | |||
| 20 | + | t.Helper() | |||
| 21 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |||
| 22 | + | if err != nil { | |||
| 23 | + | t.Skip("cannot bind loopback:", err) | |||
| 24 | + | } | |||
| 25 | + | t.Cleanup(func() { _ = ln.Close() }) | |||
| 26 | + | go func() { | |||
| 27 | + | for { | |||
| 28 | + | c, err := ln.Accept() | |||
| 29 | + | if err != nil { | |||
| 30 | + | return | |||
| 31 | + | } | |||
| 32 | + | go func() { defer func() { _ = c.Close() }(); _, _ = io.Copy(c, c) }() | |||
| 33 | + | } | |||
| 34 | + | }() | |||
| 35 | + | return ln.Addr().String() | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func dialRelay(t *testing.T, r *Relay) net.Conn { | |||
| 39 | + | t.Helper() | |||
| 40 | + | a, ok := r.Addr(lo) | |||
| 41 | + | if !ok { | |||
| 42 | + | t.Fatal("relay not listening") | |||
| 43 | + | } | |||
| 44 | + | c, err := net.DialTimeout("tcp", a.String(), 2*time.Second) | |||
| 45 | + | if err != nil { | |||
| 46 | + | t.Fatal(err) | |||
| 47 | + | } | |||
| 48 | + | t.Cleanup(func() { _ = c.Close() }) | |||
| 49 | + | return c | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestRelayForwardsBothWays(t *testing.T) { | |||
| 53 | + | r := New(echo(t)) | |||
| 54 | + | if err := r.Listen(context.Background(), lo, 0); err != nil { | |||
| 55 | + | t.Fatal(err) | |||
| 56 | + | } | |||
| 57 | + | t.Cleanup(r.CloseAll) | |||
| 58 | + | c := dialRelay(t, r) | |||
| 59 | + | _ = c.SetDeadline(time.Now().Add(5 * time.Second)) | |||
| 60 | + | if _, err := c.Write([]byte("hello\n")); err != nil { | |||
| 61 | + | t.Fatal(err) | |||
| 62 | + | } | |||
| 63 | + | line, err := bufio.NewReader(c).ReadString('\n') | |||
| 64 | + | if err != nil || line != "hello\n" { | |||
| 65 | + | t.Fatalf("got %q, %v", line, err) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func TestRelayListenTwiceFails(t *testing.T) { | |||
| 70 | + | r := New(echo(t)) | |||
| 71 | + | if err := r.Listen(context.Background(), lo, 0); err != nil { | |||
| 72 | + | t.Fatal(err) | |||
| 73 | + | } | |||
| 74 | + | t.Cleanup(r.CloseAll) | |||
| 75 | + | if err := r.Listen(context.Background(), lo, 0); err == nil { | |||
| 76 | + | t.Fatal("a second listener on the same address was accepted") | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestRelayCloseStopsListenerAndConnections(t *testing.T) { | |||
| 81 | + | r := New(echo(t)) | |||
| 82 | + | if err := r.Listen(context.Background(), lo, 0); err != nil { | |||
| 83 | + | t.Fatal(err) | |||
| 84 | + | } | |||
| 85 | + | a, _ := r.Addr(lo) | |||
| 86 | + | c := dialRelay(t, r) | |||
| 87 | + | if _, err := c.Write([]byte("x\n")); err != nil { | |||
| 88 | + | t.Fatal(err) | |||
| 89 | + | } | |||
| 90 | + | _, _ = bufio.NewReader(c).ReadString('\n') | |||
| 91 | + | r.Close(lo) | |||
| 92 | + | if _, ok := r.Addr(lo); ok { | |||
| 93 | + | t.Fatal("still listening after Close") | |||
| 94 | + | } | |||
| 95 | + | _ = c.SetReadDeadline(time.Now().Add(2 * time.Second)) | |||
| 96 | + | if _, err := c.Read(make([]byte, 1)); err == nil { | |||
| 97 | + | t.Fatal("relayed connection still open after Close") | |||
| 98 | + | } | |||
| 99 | + | if c2, err := net.DialTimeout("tcp", a.String(), time.Second); err == nil { | |||
| 100 | + | _ = c2.Close() | |||
| 101 | + | t.Fatal("new connections still accepted after Close") | |||
| 102 | + | } | |||
| 103 | + | r.Close(lo) // idempotent | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestRelayUnreachableTargetClosesTheGuestSide(t *testing.T) { | |||
| 107 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |||
| 108 | + | if err != nil { | |||
| 109 | + | t.Skip(err) | |||
| 110 | + | } | |||
| 111 | + | port := ln.Addr().(*net.TCPAddr).Port | |||
| 112 | + | _ = ln.Close() // nothing listens there now | |||
| 113 | + | r := New(net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) | |||
| 114 | + | if err := r.Listen(context.Background(), lo, 0); err != nil { | |||
| 115 | + | t.Fatal(err) | |||
| 116 | + | } | |||
| 117 | + | t.Cleanup(r.CloseAll) | |||
| 118 | + | c := dialRelay(t, r) | |||
| 119 | + | _ = c.SetReadDeadline(time.Now().Add(5 * time.Second)) | |||
| 120 | + | if _, err := c.Read(make([]byte, 1)); err == nil { | |||
| 121 | + | t.Fatal("expected the connection to be closed") | |||
| 122 | + | } | |||
| 123 | + | } | |||