From 02e71621f27fa7e493a45baf194928b680fdcd06 Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:09:14 +0800 Subject: [PATCH] feat(deploy): ship Server containers and repair NAT-PMP mappings (#400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem and outcome / 问题与结果 Self-hosting currently requires assembling a Server installation, while App port mappings can use the wrong external port or remain active after cancellation. This phase adds a verified Linux x64 Server image and Compose recipe, and repairs the existing NAT-PMP dependencies so mapping results, cancellation and cleanup follow the gateway response and request lifetime. The image wraps the exact verified Server archive and is tested before publication. Version tags retain their original image; `latest` follows the latest published GitHub release. The two scoped dependency replacements retain upstream source, tests and licenses, with their removal criteria documented in `internal/thirdparty/README.md`. Planned release: **v1.3.0** (compatible Docker distribution feature plus NAT fixes). ## Release notes ### 中文 - 新增 Linux x64 Docker 镜像与 Compose 部署方式,内置网页、信令和 STUN,可按需开启 SFU 兜底。提供部署、更新与备份教程。 - 修复 App 的 NAT-PMP 端口映射:正确使用路由器分配的外部端口,并在关闭时尝试删除映射。 - NAT-PMP 请求会随取消或超时结束,避免连接尝试结束后继续在后台重试。 已有配置和房间数据格式保持兼容,更新无需迁移。 ### English - Added a Linux x64 Docker image and Compose setup with the Web UI, signaling, STUN and optional SFU fallback, plus deployment, update and backup guides. - Fixed App NAT-PMP mappings to use the external port assigned by the router and attempt mapping removal on shutdown. - NAT-PMP requests now stop on cancellation or timeout, preventing retries from continuing after a connection attempt has ended. Existing configuration and room data formats remain compatible; no migration is required. ## Verification / 验证 [Candidate CI](https://github.com/TNTcraftHIM/Piik/actions/runs/34761969362) passed for `1d8de6c4690434cd2cf44fbdee4f45b1776432fa`. The final two files changed after that run are documentation only (`docs/todo.md` and `docs/verification-status.md`), with repository hygiene checked separately. - Web type checks, unit tests and production build; Go core tests, vet and target builds; NAT and media-edge race detection. - Actual packaged-image checks with Docker Compose: production startup, embedded Web, UDP STUN, optional SFU startup, retained room authority across recreation, diagnostic ZIP export, non-root/read-only execution and clean shutdown. - Windows x64, Linux x64 and macOS arm64 App packaging. - Scoped NAT dependency regressions cover assigned ports, renewal, deletion, cancellation, lost replies and PCPv6 composition using real local UDP endpoints. The publishing pipeline reruns the image checks for the merged release revision. First publication also requires making the GHCR package public and checking anonymous pulls of its version and `latest` tags. Representative physical routers, public-media networks and operator-specific HTTPS/firewall settings remain outside the container smoke test. ## Impact / 影响 No signaling, native-control or storage-format change. Existing App/Server installations remain compatible. Docker supports `linux/amd64` in this phase; its default is P2P, and SFU remains an explicit deployment option. Existing deployments keep their configuration. README, bilingual self-hosting guides, container maintenance, versioning, dependency provenance and NAT evidence are updated. Completed feedback is removed from TODO; ongoing network evidence remains tracked there. --- .github/workflows/repository-hygiene.yml | 40 +- CONTRIBUTING.md | 8 + README.md | 1 + README.zh-CN.md | 1 + deploy/container/.env.example | 16 + deploy/container/Dockerfile | 10 + deploy/container/compose.yaml | 27 + docs/operations/github.md | 15 + docs/operations/self-hosting.md | 24 + docs/operations/self-hosting.zh-CN.md | 22 + docs/operations/service-management.md | 73 +- docs/product/presentation-lifecycle.md | 4 + docs/reference/engineering.md | 1 + docs/reference/versioning.md | 13 + docs/research/nat-traversal.md | 34 +- docs/todo.md | 37 +- docs/verification-status.md | 2 +- go.mod | 5 + go.sum | 4 - internal/app/mediaedge/engine.go | 4 +- internal/app/portmapping/mapping.go | 38 +- internal/app/portmapping/mapping_test.go | 4 +- internal/thirdparty/README.md | 26 + internal/thirdparty/go-nat-pmp/LICENSE | 13 + internal/thirdparty/go-nat-pmp/go.mod | 3 + internal/thirdparty/go-nat-pmp/natpmp.go | 169 +++++ internal/thirdparty/go-nat-pmp/natpmp_test.go | 250 +++++++ internal/thirdparty/go-nat-pmp/network.go | 115 ++++ .../thirdparty/go-nat-pmp/network_test.go | 75 +++ internal/thirdparty/go-nat-pmp/recorder.go | 23 + internal/thirdparty/go-nat/LICENSE | 191 ++++++ internal/thirdparty/go-nat/gateway.go | 46 ++ internal/thirdparty/go-nat/go.mod | 17 + internal/thirdparty/go-nat/go.sum | 29 + internal/thirdparty/go-nat/nat.go | 231 +++++++ internal/thirdparty/go-nat/nat_test.go | 472 +++++++++++++ internal/thirdparty/go-nat/natpmp.go | 131 ++++ internal/thirdparty/go-nat/natpmp_test.go | 166 +++++ internal/thirdparty/go-nat/pcp.go | 63 ++ internal/thirdparty/go-nat/pcp/client.go | 484 +++++++++++++ internal/thirdparty/go-nat/pcp/client_test.go | 321 +++++++++ .../go-nat/pcp/gateway_discoverer.go | 109 +++ .../go-nat/pcp/gateway_discoverer_test.go | 154 +++++ internal/thirdparty/go-nat/pcp/nat.go | 367 ++++++++++ internal/thirdparty/go-nat/pcp/nat_test.go | 216 ++++++ internal/thirdparty/go-nat/pcp/protocol.go | 229 +++++++ .../thirdparty/go-nat/pcp/protocol_test.go | 353 ++++++++++ internal/thirdparty/go-nat/pcp_ipv6.go | 180 +++++ internal/thirdparty/go-nat/upnp.go | 636 ++++++++++++++++++ internal/thirdparty/go-nat/upnp_test.go | 242 +++++++ licenses/README.md | 4 +- package.json | 2 + scripts/check-client.mjs | 34 +- scripts/check-container.mjs | 129 ++++ scripts/package-app-release.mjs | 20 +- scripts/publish-container.mjs | 59 ++ tests/publish-container.test.ts | 70 ++ 57 files changed, 5885 insertions(+), 127 deletions(-) create mode 100644 deploy/container/.env.example create mode 100644 deploy/container/compose.yaml create mode 100644 internal/thirdparty/README.md create mode 100644 internal/thirdparty/go-nat-pmp/LICENSE create mode 100644 internal/thirdparty/go-nat-pmp/go.mod create mode 100644 internal/thirdparty/go-nat-pmp/natpmp.go create mode 100644 internal/thirdparty/go-nat-pmp/natpmp_test.go create mode 100644 internal/thirdparty/go-nat-pmp/network.go create mode 100644 internal/thirdparty/go-nat-pmp/network_test.go create mode 100644 internal/thirdparty/go-nat-pmp/recorder.go create mode 100644 internal/thirdparty/go-nat/LICENSE create mode 100644 internal/thirdparty/go-nat/gateway.go create mode 100644 internal/thirdparty/go-nat/go.mod create mode 100644 internal/thirdparty/go-nat/go.sum create mode 100644 internal/thirdparty/go-nat/nat.go create mode 100644 internal/thirdparty/go-nat/nat_test.go create mode 100644 internal/thirdparty/go-nat/natpmp.go create mode 100644 internal/thirdparty/go-nat/natpmp_test.go create mode 100644 internal/thirdparty/go-nat/pcp.go create mode 100644 internal/thirdparty/go-nat/pcp/client.go create mode 100644 internal/thirdparty/go-nat/pcp/client_test.go create mode 100644 internal/thirdparty/go-nat/pcp/gateway_discoverer.go create mode 100644 internal/thirdparty/go-nat/pcp/gateway_discoverer_test.go create mode 100644 internal/thirdparty/go-nat/pcp/nat.go create mode 100644 internal/thirdparty/go-nat/pcp/nat_test.go create mode 100644 internal/thirdparty/go-nat/pcp/protocol.go create mode 100644 internal/thirdparty/go-nat/pcp/protocol_test.go create mode 100644 internal/thirdparty/go-nat/pcp_ipv6.go create mode 100644 internal/thirdparty/go-nat/upnp.go create mode 100644 internal/thirdparty/go-nat/upnp_test.go create mode 100644 scripts/check-container.mjs create mode 100644 scripts/publish-container.mjs create mode 100644 tests/publish-container.test.ts diff --git a/.github/workflows/repository-hygiene.yml b/.github/workflows/repository-hygiene.yml index dc6f9f8..2a8ff25 100644 --- a/.github/workflows/repository-hygiene.yml +++ b/.github/workflows/repository-hygiene.yml @@ -73,6 +73,8 @@ jobs: cache-dependency-path: go.sum - name: Check Go core run: npm run check:client-core + - name: Check NAT and media ownership with the race detector + run: npm run check:client-race - name: Type-check and test run: npm run typecheck && npm test env: @@ -105,7 +107,20 @@ jobs: - name: Install dependencies run: npm ci - name: Package application release - run: node scripts/package-app-release.mjs "$RUNNER_TEMP/app-release" + run: >- + node scripts/package-app-release.mjs "$RUNNER_TEMP/app-release" + --container-image "piik-candidate:$GITHUB_SHA" + - name: Check the container and deployment recipe + run: npm run check:container -- "piik-candidate:$GITHUB_SHA" "$GITHUB_SHA" + - name: Save the verified container + run: docker save "piik-candidate:$GITHUB_SHA" --output "$RUNNER_TEMP/piik-container.tar" + - name: Upload the verified container + uses: actions/upload-artifact@v7 + with: + name: piik-container-${{ github.sha }} + path: ${{ runner.temp }}/piik-container.tar + if-no-files-found: error + retention-days: 14 - name: Upload application release uses: actions/upload-artifact@v7 with: @@ -195,6 +210,7 @@ jobs: timeout-minutes: 30 permissions: contents: write + packages: write steps: - name: Check out repository uses: actions/checkout@v6 @@ -218,16 +234,38 @@ jobs: pattern: piik-app-*-${{ github.sha }} path: ${{ runner.temp }}/publish-release merge-multiple: true + - name: Download the verified container + uses: actions/download-artifact@v8 + with: + name: piik-container-${{ github.sha }} + path: ${{ runner.temp }}/container + - name: Load the verified container + run: docker load --input "$RUNNER_TEMP/container/piik-container.tar" + - name: Log in to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} # Local descriptors/manifests/checksums are validated before publishing. # Release assets contain packages only; checksums appear in the release body. - name: Publish packages with release notes and checksums + id: github_release run: >- node scripts/publish-release.mjs "$RUNNER_TEMP/publish-release" "$PIIK_RELEASE_VERSION" "$GITHUB_SHA" env: GH_TOKEN: ${{ github.token }} PIIK_RELEASE_VERSION: ${{ needs.validate.outputs.version }} + - name: Publish the matching Server container + run: >- + node scripts/publish-container.mjs "piik-candidate:$GITHUB_SHA" + "$RUNNER_TEMP/publish-release" "$PIIK_RELEASE_VERSION" "$GITHUB_SHA" + env: + GH_TOKEN: ${{ github.token }} + PIIK_RELEASE_VERSION: ${{ needs.validate.outputs.version }} - name: Mirror verified release to Gitee + if: ${{ !cancelled() && steps.github_release.outcome == 'success' }} run: >- node scripts/mirror-release.mjs "$RUNNER_TEMP/publish-release" "$PIIK_RELEASE_VERSION" "$GITHUB_SHA" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b7a62a1..45afc96 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,6 +101,14 @@ does not establish coverage. core is Go, so its acceptance runs here. It builds the Vite client first when that output is missing, because both binaries embed it. CI invokes these same package commands rather than rebuilding their steps in YAML. +- `npm run check:client-race` checks port mapping, media-edge ownership and their + scoped NAT dependencies with Go's race detector. It requires a supported cgo + toolchain; CI runs it on Linux. +- `npm run check:container -- ` checks the Compose recipe + against a packaged linux/amd64 image: embedded Web, P2P/STUN, optional SFU + startup, room persistence across recreation, diagnostics and clean shutdown. + It requires Docker Compose v2 and available recipe ports on an isolated runner; + its temporary project and volumes are removed after the check. - `gate:*` commands are explicit physical or network acceptance. They must use isolated profiles, bounded deadlines, shared cleanup helpers, and a structured result. A manual diagnostic may locate a failure, but is not retained as pass diff --git a/README.md b/README.md index 115918a..394eaa8 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ Download the Linux x64 Server package, extract it, and run: Open `http://localhost:8787` to try it locally. For a public site, configure your domain, HTTPS reverse proxy and STUN address. Room data is stored in SQLite. +For containers, use the [Docker Compose setup](./docs/operations/self-hosting.md#docker-compose). [**Deploy your own site →**](./docs/operations/self-hosting.md) · [Configuration](./docs/reference/configuration.md) · [Run from source](./docs/README.md#run-from-source) diff --git a/README.zh-CN.md b/README.zh-CN.md index 8078009..5a50797 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -91,6 +91,7 @@ Piik Server 是一个内置网页界面的独立程序。 打开 `http://localhost:8787` 即可在本机试用。对外提供服务时, 再配置域名、HTTPS 反向代理和 STUN 地址。房间数据保存在 SQLite 中。 +也可以使用 [Docker Compose 部署](./docs/operations/self-hosting.zh-CN.md#使用-docker-compose)。 [**部署自己的站点 →**](./docs/operations/self-hosting.zh-CN.md) · [配置参考](./docs/reference/configuration.md) · [从源码运行](./docs/README.md#run-from-source) diff --git a/deploy/container/.env.example b/deploy/container/.env.example new file mode 100644 index 0000000..c83e7a5 --- /dev/null +++ b/deploy/container/.env.example @@ -0,0 +1,16 @@ +# Point this domain directly at the server. See the self-hosting guide for HTTPS. +PUBLIC_BASE_URL=https://share.example.com +STUN_URLS=stun:share.example.com:3478 +MAX_VIEWERS_PER_ROOM=20 +# Empty means the site opens without a site password. Room access is separate. +SITE_ACCESS_PASSWORD= + +# Optional automatic media fallback: set BOTH values and allow UDP 7882. +# SFU_UDP_PORT=7882 +# SFU_PUBLIC_IP=203.0.113.10 + +# Optional NAT survey: also allow UDP 3479 and 3480. +# NAT_PREDICTION_ENABLED=true + +# To pin a release, replace latest with its vMAJOR.MINOR.PATCH tag. +# PIIK_IMAGE=ghcr.io/tntcrafthim/piik:latest diff --git a/deploy/container/Dockerfile b/deploy/container/Dockerfile index e2c87b0..2f8cfb5 100644 --- a/deploy/container/Dockerfile +++ b/deploy/container/Dockerfile @@ -1,4 +1,14 @@ FROM gcr.io/distroless/static-debian13:nonroot-amd64@sha256:e754765ad9e167b0677b41c617fd44afb7b9818a477f48f17bda08e12cfb98cb +ARG PIIK_VERSION=development +ARG PIIK_REVISION +ARG PIIK_SERVER_SHA256 +LABEL org.opencontainers.image.title="Piik Server" \ + org.opencontainers.image.description="Private screen sharing with Web, signaling, STUN and optional media fallback." \ + org.opencontainers.image.source="https://github.com/TNTcraftHIM/Piik" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.version=$PIIK_VERSION \ + org.opencontainers.image.revision=$PIIK_REVISION \ + tv.piik.server.sha256=$PIIK_SERVER_SHA256 WORKDIR /app COPY --chmod=0555 piik-server /app/ COPY --chmod=0444 LICENSE THIRD-PARTY-NOTICES.txt REVISION /app/ diff --git a/deploy/container/compose.yaml b/deploy/container/compose.yaml new file mode 100644 index 0000000..757b900 --- /dev/null +++ b/deploy/container/compose.yaml @@ -0,0 +1,27 @@ +name: piik + +services: + piik: + image: ${PIIK_IMAGE:-ghcr.io/tntcrafthim/piik:latest} + platform: linux/amd64 + restart: unless-stopped + stop_grace_period: 20s + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + env_file: .env + environment: + PIIK_ENV: production + LISTEN_HOST: 0.0.0.0 + ports: + - "127.0.0.1:8787:8787/tcp" + - "3478:3478/udp" + # Auxiliary STUN and SFU listen only when enabled in .env. + - "3479:3479/udp" + - "3480:3480/udp" + - "7882:7882/udp" + volumes: + - piik-data:/home/nonroot + +volumes: + piik-data: diff --git a/docs/operations/github.md b/docs/operations/github.md index 337861e..d470ef9 100644 --- a/docs/operations/github.md +++ b/docs/operations/github.md @@ -60,6 +60,21 @@ this does not alter existing releases or a currently running workflow. Inspect the active run separately. No pipeline step changes visibility or installs an update into a running Piik deployment. +## Container Registry + +The Server image uses `ghcr.io/tntcrafthim/piik`. The publishing job authenticates +with `GITHUB_TOKEN` and `packages: write`; no additional registry secret is needed. +The image's source label links it to this repository. GitHub's +[Container registry guide](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry) +owns the registry permission and visibility behavior. + +At first publication, set the Piik container package's visibility to **public** +in its GitHub package settings; repository visibility alone does not do this. +Verify both its version tag and `latest` with an anonymous pull before announcing +the Docker download. Keep repository Actions access enabled for later releases. +Image checks, version identity and retries follow +[container distribution](../reference/versioning.md#container-distribution). + ## Gitee Download Mirror [TNTcraftHIM/Piik](https://gitee.com/TNTcraftHIM/Piik) is a public README/Release diff --git a/docs/operations/self-hosting.md b/docs/operations/self-hosting.md index fd2728a..e23a517 100644 --- a/docs/operations/self-hosting.md +++ b/docs/operations/self-hosting.md @@ -21,6 +21,30 @@ Open `http://localhost:8787`. This is a local trial; use the configuration below to let friends connect over the internet. To start a temporary room on your computer, use the [Piik App guide](../guide/getting-started.md). +## Docker Compose + +On a Linux x64 server with Docker Compose v2, download the two deployment files +into an empty directory: + +```sh +curl -fsSLo compose.yaml https://raw.githubusercontent.com/TNTcraftHIM/Piik/main/deploy/container/compose.yaml +curl -fsSLo .env https://raw.githubusercontent.com/TNTcraftHIM/Piik/main/deploy/container/.env.example +``` + +Replace `share.example.com` in `.env` with your domain, then start Piik: + +```sh +docker compose run --rm piik --check-config +docker compose up -d +``` + +The `ghcr.io/tntcrafthim/piik:latest` image includes the Web UI, signaling, STUN +and optional SFU. Complete [HTTPS](#2-enable-https) and +[firewall configuration](#3-open-the-ports-and-verify) below. The default is P2P; +the sample `.env` also shows how to enable SFU fallback. Keep the `piik-data` +volume, which stores room data and optional diagnostics. See +[container maintenance](./service-management.md#container) for updates and backups. + ## Put it online You need a Linux x64 server and a domain pointing to its public IP. diff --git a/docs/operations/self-hosting.zh-CN.md b/docs/operations/self-hosting.zh-CN.md index 79312c1..201b68c 100644 --- a/docs/operations/self-hosting.zh-CN.md +++ b/docs/operations/self-hosting.zh-CN.md @@ -20,6 +20,28 @@ Piik Server 将网页、房间管理和可选的媒体转发打包在**一个服 打开 `http://localhost:8787` 即可试用。需要让朋友通过互联网访问时, 继续完成下方配置。如果只想在自己的电脑上临时开房间,可直接使用 [Piik App](../guide/getting-started.zh-CN.md#用-piik-app-分享)。 +## 使用 Docker Compose + +在已安装 Docker Compose v2 的 Linux x64 服务器上,将两个部署文件下载到空目录中: + +```sh +curl -fsSLo compose.yaml https://raw.githubusercontent.com/TNTcraftHIM/Piik/main/deploy/container/compose.yaml +curl -fsSLo .env https://raw.githubusercontent.com/TNTcraftHIM/Piik/main/deploy/container/.env.example +``` + +将 `.env` 中的 `share.example.com` 换成自己的域名,然后启动: + +```sh +docker compose run --rm piik --check-config +docker compose up -d +``` + +`ghcr.io/tntcrafthim/piik:latest` 镜像包含网页、信令、STUN 和可选 SFU。 +接着完成下方的 [HTTPS 配置](#2-配置-https)和[端口放行](#3-放行端口并检查)。 +默认使用 P2P;如需 SFU 兜底,按 `.env` 中的说明启用即可。 +请保留 `piik-data` 数据卷,房间数据和可选诊断文件都保存在其中。 +更新与备份见[容器维护说明](./service-management.md#container)。 + ## 对外提供服务 准备一台 Linux x64 服务器,以及一个指向服务器公网 IP 的域名。 diff --git a/docs/operations/service-management.md b/docs/operations/service-management.md index 12fafda..2e28434 100644 --- a/docs/operations/service-management.md +++ b/docs/operations/service-management.md @@ -1,7 +1,7 @@ # Service Management For first installation, use the [short self-hosting guide](./self-hosting.md). -This reference covers a persistent Linux service and the optional container recipe. +This reference covers a persistent Linux service and container maintenance. ## systemd @@ -28,24 +28,18 @@ after adapting the domain and certificate paths. ## Container -The [runtime-only Dockerfile](../../deploy/container/Dockerfile) consumes the -existing linux/amd64 Server release; it does not rebuild Go or Browser assets. -Its pinned [Distroless static non-root base](https://github.com/GoogleContainerTools/distroless) -includes CA certificates for HTTPS release checks and has no shell or package -manager. This is an optional recipe, not a published image or automatic installer. +Use the [Docker Compose setup](./self-hosting.md#docker-compose) for installation. +The image is `ghcr.io/tntcrafthim/piik`, with `latest` and matching product version +tags (`vMAJOR.MINOR.PATCH`). It supports **linux/amd64**. Set `PIIK_IMAGE` in `.env` +to pin a version or image digest. Updates are explicit; pulling an image does not +replace a running container. -Verify the release archive against its descriptor and manifest using the -[release procedure](../deployment.md), then extract its four files into a new -build-context directory: `piik-server`, `LICENSE`, `THIRD-PARTY-NOTICES.txt` -and `REVISION`. Use that directory, not the repository or a secrets directory: - -```sh -docker build --platform linux/amd64 \ - -f /path/to/Piik/deploy/container/Dockerfile \ - -t piik: /path/to/extracted-runtime -docker run --rm --env-file /path/to/piik.env \ - piik: --check-config -``` +The [runtime Dockerfile](../../deploy/container/Dockerfile) wraps the verified +Server archive, including its Web UI, license notices and `REVISION`. +Its pinned [Distroless non-root base](https://github.com/GoogleContainerTools/distroless) +includes CA certificates and has no shell or package manager. The +[release workflow](../reference/versioning.md#container-distribution) owns +building, checking and publishing the image. Use the existing [production configuration](../reference/configuration.md). Keep `LISTEN_HOST=0.0.0.0` inside the container and retain the host's existing @@ -54,20 +48,12 @@ reachable server IPv4 address when SFU is enabled; container-private candidates are not Internet-reachable. Advertised STUN names must resolve to the server's public address, and UDP must reach the container directly. -The following example publishes the standard listeners and keeps HTTP private -to a proxy running on the same host. Omit UDP publish options for disabled SFU -or auxiliary STUN listeners; use the matching port if configuration changes it. - -```sh -docker run -d --name piik --restart unless-stopped --stop-timeout 20 \ - --read-only --cap-drop ALL --security-opt no-new-privileges \ - --env-file /path/to/piik.env \ - --mount type=volume,source=piik-data,target=/home/nonroot \ - -e ROOM_DATABASE_PATH=/home/nonroot/rooms.sqlite \ - -p 127.0.0.1:8787:8787/tcp \ - -p 3478:3478/udp -p 3479:3479/udp -p 3480:3480/udp -p 7882:7882/udp \ - piik: -``` +The [Compose file](../../deploy/container/compose.yaml) keeps HTTP on host +loopback, publishes the standard UDP ports and enables automatic restart. +Publishing a port does not enable its service; `.env` controls optional listeners. +Edit both the application setting and port mapping if changing a listener port. +Run a host proxy, or adapt the proxy's container networking deliberately: its +own `127.0.0.1` does not reach Piik in another container. The image's `/home/nonroot` is owned by UID/GID `65532:65532` with mode `0700`. A new named volume inherits that directory; an existing volume or bind mount @@ -75,16 +61,29 @@ must already be writable by that identity. Keep this volume when replacing the container. Set `ROOM_DATABASE_PATH=:memory:` for memory-only room authority. Diagnostics remain opt-in and use `/home/nonroot/logs`; [export and retention](../reference/configuration.md#diagnostics) remain the operator's responsibility. Do not mount application files writable. -With diagnostics enabled, `docker kill --signal=USR1 piik` requests a local +With diagnostics enabled, `docker compose kill --signal=SIGUSR1 piik` requests a local export without stopping the container; the ZIP remains in the mounted log directory. Check `/healthz` from the host and public proxy, then verify configured STUN/SFU UDP and room/media behavior using [operational verification](#operational-verification). There is no shell-based healthcheck in the image. Retain the previous image, -environment and volume backup before replacement. Stop the old container before -reusing its listeners or SQLite volume, and recreate with the previous image and -environment if verification fails. The bare-metal `release-app.sh` wrapper does -not manage containers. This recipe changes no proxy or firewall itself. +environment and volume backup before replacement. From the deployment directory: + +```sh +docker compose pull +docker compose stop +docker compose cp piik:/home/nonroot ./piik-data-backup +docker compose up -d +docker compose logs --tail=50 piik +``` + +Use a new backup directory each time. Stop Piik before copying SQLite so its +database and journal are consistent. Preserve `.env` separately and protect both +backups as room credentials. Keep the same Compose project/directory so the named +volume is reused; `docker compose down --volumes` deletes that data. To roll back, +restore the previous image setting and compatible configuration, then recreate +the service. Read release notes before crossing a storage-format boundary. +The bare-metal `release-app.sh` wrapper does not manage containers. ## Operational Verification diff --git a/docs/product/presentation-lifecycle.md b/docs/product/presentation-lifecycle.md index 2633853..41a469e 100644 --- a/docs/product/presentation-lifecycle.md +++ b/docs/product/presentation-lifecycle.md @@ -87,6 +87,10 @@ fullscreen includes the playback bar; Safari's video-only fullscreen uses system controls and native video events to reflect playback and exit. Identity, connection details and topology stay in the deck. +Replacement media bindings do not consult Viewer-local pause: when the Host is +playing, they attempt playback subject to browser autoplay permission. There is +no separate remembered playback intent for source changes or reconnects. + The bar spans the screen's lower edge. Narrow screens separate audio controls from window actions into two rows and retain 44px action targets. Playing video hides the bar after two idle seconds, following [Media Chrome's default](https://github.com/muxinc/media-chrome/blob/main/docs/src/pages/docs/en/components/media-controller.md#autohide). diff --git a/docs/reference/engineering.md b/docs/reference/engineering.md index 5395476..7d4471a 100644 --- a/docs/reference/engineering.md +++ b/docs/reference/engineering.md @@ -21,6 +21,7 @@ checks the Browser, `scripts/tsconfig.json` checks tooling/tests, and root | `src/client`, `src/shared` | Browser UI/media orchestration and its shared wire types; the same UI serves Hosted and App modes | | `public/` | Static assets shipped with that Browser application | | `cmd/`, `internal/` | Go executable composition and private application, server, media and diagnostics packages | +| `internal/thirdparty/` | Scoped Go dependency repairs with pinned upstream provenance, licenses and tests; removal criteria live beside the source | | `native/capture`, `native/fixtures`, `native/probes` | Platform capture implementations, synthetic workloads and standalone capability probes | | `site/` | Independently published static introduction; its assets do not implement product behavior | | `scripts/` | Development checks, browser/media gates, dependency assembly and release packaging/publishing tools | diff --git a/docs/reference/versioning.md b/docs/reference/versioning.md index 527fc69..d8061de 100644 --- a/docs/reference/versioning.md +++ b/docs/reference/versioning.md @@ -165,6 +165,19 @@ restoring an old authority database after newer grants/revocations is not a safe routine downgrade. No automatic updater or generic migration framework is introduced by this policy. +## Container Distribution + +`ghcr.io/tntcrafthim/piik` wraps the same verified linux/amd64 Server archive as +the GitHub release. The packager's optional `--container-image ` builds from +that extraction; CI checks its Compose recipe and transfers the verified image +between jobs as a CI artifact, outside public Release attachments. +`publish-container.mjs` requires matching published GitHub source, version and +Server digest in the image labels. Existing version tags are retained; `latest` +moves only when that version is GitHub's latest. Manual candidates never publish. +The [release approval boundary](#automatic-publication) and +[first-publication visibility check](../operations/github.md#container-registry) +apply. Preserve the original image and packages for retries. + ## Release Notes GitHub Releases owns the published changelog. Each product-changing PR supplies reviewed diff --git a/docs/research/nat-traversal.md b/docs/research/nat-traversal.md index f76dfc7..e4c246c 100644 --- a/docs/research/nat-traversal.md +++ b/docs/research/nat-traversal.md @@ -125,23 +125,25 @@ That proves the same-socket ICE mechanism, not rescue through a physical NAT. ### Gateway And Survey Limits -The pinned `go-nat` NAT-PMP adapter ignores context cancellation. Its underlying -client can retry each proposed port for about 128 seconds, with several port -proposals per call. Piik bounds the mapping caller's wait to three seconds; -an abandoned worker may continue to the dependency's own retry limit. Failed -attempts are not relaunched by later edges. Cleanup must not call Delete while -that worker still owns the adapter's unsynchronized port bookkeeping. +The root module uses [scoped local repairs](../../internal/thirdparty/README.md) +for `go-nat` and its NAT-PMP client. The unmodified upstream revisions discard +gateway-assigned ports, send no deletion and ignore context cancellation. +The repaired adapter retains `MappedExternalPort` on creation and renewal, +sends lifetime-zero deletion under [RFC 6886](https://www.rfc-editor.org/rfc/rfc6886.html#section-3.4), +and propagates discovery, mapping and deletion cancellation to socket I/O. +Packet-level loopback regressions cover reassignment, renewal, deletion, +cancellation and the existing PCPv6 combination. They do not establish physical +router coverage or a higher field connection-success rate. -NAT-PMP Delete in this dependency clears only its local bookkeeping; it sends -no router deletion. A successful mapping can therefore remain until its requested -two-hour lease expires, including a late success after Piik stops waiting. -Caller timeout and router lease expiry are distinct lifecycle boundaries. -The same adapter discards the gateway's `MappedExternalPort` response and returns -its requested port. A gateway may assign a different port under -[RFC 6886](https://www.rfc-editor.org/rfc/rfc6886.html#section-3.3), making that -optional mapped candidate incorrect. Ordinary ICE candidates remain available. -Fix this inside the dependency so its combined NAT-PMP/PCPv6 adapter retains -IPv6 pinholes; a second mapping writer would add another failure and lease owner. +Piik still bounds a mapping caller's wait to three seconds. PCPv6 rollback can +finish after that wait, so cleanup must wait for the in-flight gateway call +before accessing its bookkeeping. Failed attempts are not relaunched by later +edges. Close attempts deletion even after a failed request, because losing a +reply does not prove the router rejected the mapping. Cleanup is best effort: +an unreachable gateway or unsettled rollback can leave a lease to expire. +Caller cancellation and router lease expiry remain distinct lifecycle boundaries. +The gateway selector and its NAT-PMP/PCPv6 combination retain one mapping owner; +ordinary ICE candidates remain independent. Binding all three STUN listeners proves local startup, not public reachability. A local self-probe also cannot prove traversal through an operator's firewall. diff --git a/docs/todo.md b/docs/todo.md index dab7ec4..cd4e91f 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -8,8 +8,8 @@ history. A parked idea is not implementation authority. ## Now - [ ] **Post-launch monitoring.** Collect App/Server feedback and verify the - public download path and deployed services after product releases. Follow the - [deployment runbook](./deployment.md) for the private service and keep public + public downloads, container pulls and deployed services after product releases. + Follow the [deployment runbook](./deployment.md) for the private service and keep public demo deployment separate. Preserve the owner's [device/network deferrals](./verification-status.md#candidate-evidence-boundary). @@ -18,14 +18,6 @@ compatibility baseline; private service deployment stays independent. ## Next: P2P Connection And Feedback Evidence -**Next release: NAT-PMP dependency correctness.** Integrate an upstream fix or -maintained dependency revision that uses the gateway-assigned external port, -sends an actual deletion request and honors cancellation/deadlines. Preserve -the existing PCPv6 combination and bounded mapping lifecycle. The current -optional path can advertise a requested port that the router reassigned; -ordinary ICE remains available. Keep this out of the current release. See the -[dependency boundary](./research/nat-traversal.md#gateway-and-survey-limits). - After the current phase, measure connection success, time to first picture and failure causes on representative networks, especially App and P2P-only sites. Use existing Debug provenance and selected-path events before adding runtime @@ -43,21 +35,6 @@ not establish better connection success or speed; this note adds no retry policy ## Parked Product Work -The reported persistent low resolution after Viewer backgrounding and square -black video remain unconfirmed incident leads, separate from the closed, -reproduced reconnect ownership bug. They do not block this release. Reopen from -matching upstream/receiver evidence; local H264 relay background checks did not -reproduce them. Missing NAT attempt text alone does not prove skipped attempts. -If Native adaptation is implicated, compare actual VSE limitations with sender -quality evidence before changing policy. Do not add retry budgets, visibility -resets or resolution heuristics without proof. Sanitized aggregates and the -isolated reconnect reproductions are diagnostic evidence, not a recovery policy. -Game/background settings behavior also remains an unconfirmed incident lead; -reopen from actual control actions and matching requested/applied media evidence. -Clarify whether Viewer-local pause should survive replacement media before -adding playback-intent state; the current binding starts new media unless the -Host is paused. No preservation policy has been accepted for that transition. - 1. **Representative device/network acceptance.** Resume the remaining matrix in [verification status](./verification-status.md#remaining-device-and-network-acceptance) when directed, preserving the owner's platform deferrals. Correlate freezes @@ -107,9 +84,7 @@ Host is paused. No preservation policy has been accepted for that transition. support: the pinned capture SDK currently has no Windows x86 package. Complete native packaging and real launch/capture acceptance before advertising a 32-bit target. -10. **Ready-to-run Docker distribution.** Publish a maintained image and a short - deployment recipe. The existing runtime image already contains Web, room - signaling, STUN and optional embedded SFU in one process; the gap is a - published image and simple HTTPS, port and persistent-volume setup. Reuse - that server artifact and the existing release identity rather than adding - another service stack. +10. **Windows code signing.** Revisit after enrollment in a trusted signing + service. Sign Piik's executables before archive checksums are computed; + signing improves publisher identity but does not guarantee that antivirus + cloud scanning stops. Service selection and enrollment remain pending. diff --git a/docs/verification-status.md b/docs/verification-status.md index be97aaf..962b430 100644 --- a/docs/verification-status.md +++ b/docs/verification-status.md @@ -55,7 +55,7 @@ This is bounded single-machine Windows evidence. The local Server executables were built for Windows from the matching source; this run did not execute the Linux release archives. Process replacement used a forced stop on Windows and does not promise uninterrupted media. It does not cover game/audio endurance, -storage faults, the wider device/network matrix or Go race detection. +storage faults or the wider device/network matrix. The first public release passed real Server/three-platform App packaging and GitHub/Gitee publication, including anonymous mirror checksum verification. diff --git a/go.mod b/go.mod index 436b133..fcf6c22 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,11 @@ module github.com/TNTcraftHIM/Piik go 1.26 +// Scoped NAT-PMP fixes; provenance and removal criteria: internal/thirdparty/README.md. +replace github.com/netbirdio/go-nat => ./internal/thirdparty/go-nat + +replace github.com/jackpal/go-nat-pmp => ./internal/thirdparty/go-nat-pmp + require ( charm.land/bubbletea/v2 v2.0.9 charm.land/lipgloss/v2 v2.0.6 diff --git a/go.sum b/go.sum index 7076ce1..072b88e 100644 --- a/go.sum +++ b/go.sum @@ -129,8 +129,6 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/huin/goupnp v1.2.0 h1:uOKW26NG1hsSSbXIZ1IR7XP9Gjd1U8pnLaCMgntmkmY= github.com/huin/goupnp v1.2.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= -github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= -github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ= github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk= github.com/josharian/native v0.0.0-20200817173448-b6b71def0850/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= @@ -227,8 +225,6 @@ github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 h1:pBxXEsxcsO3qVUND//5j1kelYlO57x5IrRviNF0+0iA= -github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8/go.mod h1:mFViabv4PpnoDw9w7W21a7xux6APA4q7KQZRsv4BCl8= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= diff --git a/internal/app/mediaedge/engine.go b/internal/app/mediaedge/engine.go index 5e4f2a0..eacfd11 100644 --- a/internal/app/mediaedge/engine.go +++ b/internal/app/mediaedge/engine.go @@ -180,6 +180,8 @@ func (engine *Engine) surveySTUN( ) { surveyContext, cancel := context.WithTimeout(ctx, stunSurveyTimeout) defer cancel() + // One survey retains its resolver even while canceled workers are settling. + resolver := net.DefaultResolver results := make(chan mappedAddress) var pending sync.WaitGroup for _, server := range servers { @@ -194,7 +196,7 @@ func (engine *Engine) surveySTUN( defer pending.Done() // Resolve each destination independently inside the same bound as // its Binding request; one broken resolver must not stall healthy STUN. - addresses, err := net.DefaultResolver.LookupNetIP(surveyContext, "ip4", uri.Host) + addresses, err := resolver.LookupNetIP(surveyContext, "ip4", uri.Host) if err != nil || len(addresses) == 0 { slog.DebugContext(surveyContext, "nat-survey", "event", "resolve-failed", "host", uri.Host, diagnostics.Error(err)) return diff --git a/internal/app/portmapping/mapping.go b/internal/app/portmapping/mapping.go index def6c50..08f86c1 100644 --- a/internal/app/portmapping/mapping.go +++ b/internal/app/portmapping/mapping.go @@ -36,15 +36,14 @@ type Mapping struct { cancel context.CancelFunc ready chan struct{} - mu sync.Mutex - gateway gateway - attempted bool - mapped bool - externalPort int - deleteRequired bool - attemptDone chan struct{} - renewAfter time.Time - closed bool + mu sync.Mutex + gateway gateway + attempted bool + mapped bool + externalPort int + attemptDone chan struct{} + renewAfter time.Time + closed bool } func Start(localPort int) *Mapping { @@ -88,18 +87,16 @@ func (mapping *Mapping) Close() { } mapping.closed = true gateway := mapping.gateway - deleteRequired := mapping.deleteRequired + attempted := mapping.attempted attemptDone := mapping.attemptDone mapping.mapped = false mapping.externalPort = 0 - mapping.deleteRequired = false mapping.mu.Unlock() - if gateway == nil || !deleteRequired { + if gateway == nil || !attempted { return } - // A still-abandoned attempt may be retrying inside the dependency - // (NAT-PMP keeps unsynchronized state); give it a brief window to return - // rather than racing its bookkeeping with the Delete. + // A gateway call may still be finishing detached PCPv6 rollback. Allow + // a brief settlement window before touching its mapping bookkeeping. if attemptDone != nil { select { case <-attemptDone: @@ -133,12 +130,12 @@ func (mapping *Mapping) discover(parent context.Context) { } func (mapping *Mapping) mapPortLocked(ctx context.Context) { + // A request can succeed on the gateway even if its reply is lost. Close + // must attempt cleanup once the request settles, including failed renewal. mapping.attempted = true - // NAT-PMP ignores cancellation and retries each proposed port for ~128 s. - // Bound our wait; the dependency may continue until its own retry limit. - // The buffered result lets that worker finish after abandonment. Failed - // attempts are not retried by Prepare, and Close waits for attemptDone - // before touching the dependency's unsynchronized port bookkeeping. + // Keep the caller bounded even while a gateway finishes detached rollback. + // The buffered result lets it settle after our wait ends. Failed attempts + // are not retried by Prepare; Close waits before accessing its bookkeeping. type mappingResult struct { externalPort int err error @@ -175,6 +172,5 @@ func (mapping *Mapping) mapPortLocked(ctx context.Context) { } mapping.mapped = true mapping.externalPort = externalPort - mapping.deleteRequired = true mapping.renewAfter = time.Now().Add(leaseDuration / 2) } diff --git a/internal/app/portmapping/mapping_test.go b/internal/app/portmapping/mapping_test.go index d3396bb..6a463a4 100644 --- a/internal/app/portmapping/mapping_test.go +++ b/internal/app/portmapping/mapping_test.go @@ -29,7 +29,7 @@ func (gateway *fakeGateway) AddPortMapping( addErr := gateway.addErr gateway.mu.Unlock() if block != nil { - // Like the pinned NAT-PMP client: never observes the context. + // Exercise the caller's bound even if a gateway fails to observe context. <-block } if addErr != nil { @@ -103,7 +103,7 @@ func TestMappingFailureIsNotRetriedForEveryEdge(t *testing.T) { fake.mu.Lock() defer fake.mu.Unlock() - if len(fake.added) != 1 || len(fake.deleted) != 0 { + if len(fake.added) != 1 || len(fake.deleted) != 1 { t.Fatalf("mapping calls = add %v delete %v", fake.added, fake.deleted) } } diff --git a/internal/thirdparty/README.md b/internal/thirdparty/README.md new file mode 100644 index 0000000..81bdf4d --- /dev/null +++ b/internal/thirdparty/README.md @@ -0,0 +1,26 @@ +# Scoped Go Dependency Repairs + +The root `go.mod` replaces only these two modules with local source. They retain +their original module paths and Apache-2.0 licenses. The checked-in source, +upstream tests and focused regressions are built and tested by +`npm run check:client`. + +| Module | Source baseline | Local changes | +| --- | --- | --- | +| `go-nat` | [netbirdio/go-nat at 6b2c8c5c74e8](https://github.com/netbirdio/go-nat/tree/6b2c8c5c74e8331ed41811cfd2fdc4c3dd8c3ff0) | `natpmp.go` uses request contexts, remembers the assigned external port and sends lifetime-zero deletion; regression tests cover the real client and PCPv6 combination | +| `go-nat-pmp` | [jackpal/go-nat-pmp v1.0.2](https://github.com/jackpal/go-nat-pmp/tree/v1.0.2) | Context-aware request methods, deadline/cancellation through UDP I/O, adapted recorder/test caller, and a module manifest | + +Upstream source and tests are retained, excluding examples and CI configuration. +Modified upstream files carry a notice; formatting follows the project Go +toolchain. The package notice collector reads the replacement modules' license +files directly, so App archives retain their attribution. + +These repairs preserve the existing gateway selector and PCPv6 composition. +The [NAT research owner](../../docs/research/nat-traversal.md#gateway-and-survey-limits) +describes the runtime limits. Do not add a second gateway client in Piik. + +When maintained upstream revisions cover these fixes and the same regressions +pass, replace both local module directives with version pins, remove their source +directories and the extra dependency test patterns in `scripts/check-client.mjs`. +Until then, review updates against the named baselines; do not edit Go's shared +module cache or apply build-time patches. diff --git a/internal/thirdparty/go-nat-pmp/LICENSE b/internal/thirdparty/go-nat-pmp/LICENSE new file mode 100644 index 0000000..249514b --- /dev/null +++ b/internal/thirdparty/go-nat-pmp/LICENSE @@ -0,0 +1,13 @@ + Copyright 2013 John Howard Palevich + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/internal/thirdparty/go-nat-pmp/go.mod b/internal/thirdparty/go-nat-pmp/go.mod new file mode 100644 index 0000000..098ccda --- /dev/null +++ b/internal/thirdparty/go-nat-pmp/go.mod @@ -0,0 +1,3 @@ +module github.com/jackpal/go-nat-pmp + +go 1.24 diff --git a/internal/thirdparty/go-nat-pmp/natpmp.go b/internal/thirdparty/go-nat-pmp/natpmp.go new file mode 100644 index 0000000..4c48a6a --- /dev/null +++ b/internal/thirdparty/go-nat-pmp/natpmp.go @@ -0,0 +1,169 @@ +// Modified by Piik: context-aware requests; the original API delegates to them. +package natpmp + +import ( + "context" + "fmt" + "net" + "time" +) + +// Implement the NAT-PMP protocol, typically supported by Apple routers and open source +// routers such as DD-WRT and Tomato. +// +// See https://tools.ietf.org/rfc/rfc6886.txt +// +// Usage: +// +// client := natpmp.NewClient(gatewayIP) +// response, err := client.GetExternalAddress() + +// The recommended mapping lifetime for AddPortMapping. +const RECOMMENDED_MAPPING_LIFETIME_SECONDS = 3600 + +// Interface used to make remote procedure calls. +type caller interface { + call(ctx context.Context, msg []byte, timeout time.Duration) (result []byte, err error) +} + +// Client is a NAT-PMP protocol client. +type Client struct { + caller caller + timeout time.Duration +} + +// Create a NAT-PMP client for the NAT-PMP server at the gateway. +// Uses default timeout which is around 128 seconds. +func NewClient(gateway net.IP) (nat *Client) { + return &Client{&network{gateway}, 0} +} + +// Create a NAT-PMP client for the NAT-PMP server at the gateway, with a timeout. +// Timeout defines the total amount of time we will keep retrying before giving up. +func NewClientWithTimeout(gateway net.IP, timeout time.Duration) (nat *Client) { + return &Client{&network{gateway}, timeout} +} + +// Results of the NAT-PMP GetExternalAddress operation. +type GetExternalAddressResult struct { + SecondsSinceStartOfEpoc uint32 + ExternalIPAddress [4]byte +} + +// Get the external address of the router. +// +// Note that this call can take up to 128 seconds to return. +func (n *Client) GetExternalAddress() (result *GetExternalAddressResult, err error) { + return n.GetExternalAddressContext(context.Background()) +} + +// GetExternalAddressContext retrieves the external address until ctx ends. +func (n *Client) GetExternalAddressContext(ctx context.Context) (result *GetExternalAddressResult, err error) { + msg := make([]byte, 2) + msg[0] = 0 // Version 0 + msg[1] = 0 // OP Code 0 + response, err := n.rpc(ctx, msg, 12) + if err != nil { + return + } + result = &GetExternalAddressResult{} + result.SecondsSinceStartOfEpoc = readNetworkOrderUint32(response[4:8]) + copy(result.ExternalIPAddress[:], response[8:12]) + return +} + +// Results of the NAT-PMP AddPortMapping operation +type AddPortMappingResult struct { + SecondsSinceStartOfEpoc uint32 + InternalPort uint16 + MappedExternalPort uint16 + PortMappingLifetimeInSeconds uint32 +} + +// Add (or delete) a port mapping. To delete a mapping, set the requestedExternalPort and lifetime to 0. +// Note that this call can take up to 128 seconds to return. +func (n *Client) AddPortMapping(protocol string, internalPort, requestedExternalPort int, lifetime int) (result *AddPortMappingResult, err error) { + return n.AddPortMappingContext(context.Background(), protocol, internalPort, requestedExternalPort, lifetime) +} + +// AddPortMappingContext creates, renews or deletes a mapping until ctx ends. +func (n *Client) AddPortMappingContext(ctx context.Context, protocol string, internalPort, requestedExternalPort int, lifetime int) (result *AddPortMappingResult, err error) { + var opcode byte + if protocol == "udp" { + opcode = 1 + } else if protocol == "tcp" { + opcode = 2 + } else { + err = fmt.Errorf("unknown protocol %v", protocol) + return + } + msg := make([]byte, 12) + msg[0] = 0 // Version 0 + msg[1] = opcode + // [2:3] is reserved. + writeNetworkOrderUint16(msg[4:6], uint16(internalPort)) + writeNetworkOrderUint16(msg[6:8], uint16(requestedExternalPort)) + writeNetworkOrderUint32(msg[8:12], uint32(lifetime)) + response, err := n.rpc(ctx, msg, 16) + if err != nil { + return + } + result = &AddPortMappingResult{} + result.SecondsSinceStartOfEpoc = readNetworkOrderUint32(response[4:8]) + result.InternalPort = readNetworkOrderUint16(response[8:10]) + result.MappedExternalPort = readNetworkOrderUint16(response[10:12]) + result.PortMappingLifetimeInSeconds = readNetworkOrderUint32(response[12:16]) + return +} + +func (n *Client) rpc(ctx context.Context, msg []byte, resultSize int) (result []byte, err error) { + result, err = n.caller.call(ctx, msg, n.timeout) + if err != nil { + return + } + err = protocolChecks(msg, resultSize, result) + return +} + +func protocolChecks(msg []byte, resultSize int, result []byte) (err error) { + if len(result) != resultSize { + err = fmt.Errorf("unexpected result size %d, expected %d", len(result), resultSize) + return + } + if result[0] != 0 { + err = fmt.Errorf("unknown protocol version %d", result[0]) + return + } + expectedOp := msg[1] | 0x80 + if result[1] != expectedOp { + err = fmt.Errorf("Unexpected opcode %d. Expected %d", result[1], expectedOp) + return + } + resultCode := readNetworkOrderUint16(result[2:4]) + if resultCode != 0 { + err = fmt.Errorf("Non-zero result code %d", resultCode) + return + } + // If we got here the RPC is good. + return +} + +func writeNetworkOrderUint16(buf []byte, d uint16) { + buf[0] = byte(d >> 8) + buf[1] = byte(d) +} + +func writeNetworkOrderUint32(buf []byte, d uint32) { + buf[0] = byte(d >> 24) + buf[1] = byte(d >> 16) + buf[2] = byte(d >> 8) + buf[3] = byte(d) +} + +func readNetworkOrderUint16(buf []byte) uint16 { + return (uint16(buf[0]) << 8) | uint16(buf[1]) +} + +func readNetworkOrderUint32(buf []byte) uint32 { + return (uint32(buf[0]) << 24) | (uint32(buf[1]) << 16) | (uint32(buf[2]) << 8) | uint32(buf[3]) +} diff --git a/internal/thirdparty/go-nat-pmp/natpmp_test.go b/internal/thirdparty/go-nat-pmp/natpmp_test.go new file mode 100644 index 0000000..c877f00 --- /dev/null +++ b/internal/thirdparty/go-nat-pmp/natpmp_test.go @@ -0,0 +1,250 @@ +// Modified by Piik: adapt the existing protocol tests to context-aware calls. +package natpmp + +import ( + "bytes" + "context" + "fmt" + "testing" + "time" +) + +type callRecord struct { + // The expected msg argument to call. + msg []byte + result []byte + err error +} + +type mockNetwork struct { + // test object, used to report errors. + t *testing.T + cr callRecord +} + +func (n *mockNetwork) call(_ context.Context, msg []byte, timeout time.Duration) (result []byte, err error) { + if bytes.Compare(msg, n.cr.msg) != 0 { + n.t.Errorf("msg=%v, expected %v", msg, n.cr.msg) + } + return n.cr.result, n.cr.err +} + +type getExternalAddressRecord struct { + result *GetExternalAddressResult + err error + cr callRecord +} + +/* + +Poor-man's generater code + +import "net" + +type callRecorder struct { + callRecord callRecord +} + +func (cr *callRecorder) observeCall(msg []byte, result []byte, err error) { + cr.callRecord = callRecord{msg, result, err} +} + +func TestRecordGetExternalAddress(t *testing.T) { + cr := &callRecorder{} + c := Client{&recorder{&network{net.IPv4(192,168,1,1)},cr}} + result, err := c.GetExternalAddress() + t.Logf("%#v, %#v, %#v", result, err, cr.callRecord) +} + +func TestRecordAddPortMapping(t *testing.T) { + cr := &callRecorder{} + c := Client{&recorder{&network{net.IPv4(192,168,1,1)},cr}} + result, err := c.AddPortMapping("tcp", 123, 0, 0) + t.Logf("%#v, %#v, %#v", result, err, cr.callRecord) +} + +*/ + +func TestGetExternalAddress(t *testing.T) { + dummyError := fmt.Errorf("dummy error") + testCases := []getExternalAddressRecord{ + { + nil, + dummyError, + callRecord{[]uint8{0x0, 0x0}, nil, dummyError}, + }, + { + &GetExternalAddressResult{0x13f24f, [4]uint8{0x49, 0x8c, 0x36, 0x9a}}, + nil, + callRecord{[]uint8{0x0, 0x0}, []uint8{0x0, 0x80, 0x0, 0x0, 0x0, 0x13, 0xf2, 0x4f, 0x49, 0x8c, 0x36, 0x9a}, nil}, + }, + } + for i, testCase := range testCases { + t.Logf("case %d", i) + c := Client{&mockNetwork{t, testCase.cr}, 0} + result, err := c.GetExternalAddress() + if err != nil { + if err != testCase.err { + t.Error(err) + } + } else { + if result.SecondsSinceStartOfEpoc != testCase.result.SecondsSinceStartOfEpoc { + t.Errorf("result.SecondsSinceStartOfEpoc=%d != %d", result.SecondsSinceStartOfEpoc, testCase.result.SecondsSinceStartOfEpoc) + } + if bytes.Compare(result.ExternalIPAddress[:], testCase.result.ExternalIPAddress[:]) != 0 { + t.Errorf("result.ExternalIPAddress=%v != %v", result.ExternalIPAddress, testCase.result.ExternalIPAddress) + } + } + } +} + +type addPortMappingRecord struct { + protocol string + internalPort int + requestedExternalPort int + lifetime int + result *AddPortMappingResult + err error + cr callRecord +} + +func TestAddPortMapping(t *testing.T) { + dummyError := fmt.Errorf("dummy error") + testCases := []addPortMappingRecord{ + // Propagate error + { + "udp", 123, 456, 1200, + nil, + dummyError, + callRecord{[]uint8{0x0, 0x1, 0x0, 0x0, 0x0, 0x7b, 0x1, 0xc8, 0x0, 0x0, 0x4, 0xb0}, nil, dummyError}, + }, + // Add UDP + { + "udp", 123, 456, 1200, + &AddPortMappingResult{0x13feff, 0x7b, 0x1c8, 0x4b0}, + nil, + callRecord{ + []uint8{0x0, 0x1, 0x0, 0x0, 0x0, 0x7b, 0x1, 0xc8, 0x0, 0x0, 0x4, 0xb0}, + []uint8{0x0, 0x81, 0x0, 0x0, 0x0, 0x13, 0xfe, 0xff, 0x0, 0x7b, 0x1, 0xc8, 0x0, 0x0, 0x4, 0xb0}, + nil, + }, + }, + // Add TCP + { + "tcp", 123, 456, 1200, + &AddPortMappingResult{0x140321, 0x7b, 0x1c8, 0x4b0}, + nil, + callRecord{ + []uint8{0x0, 0x2, 0x0, 0x0, 0x0, 0x7b, 0x1, 0xc8, 0x0, 0x0, 0x4, 0xb0}, + []uint8{0x0, 0x82, 0x0, 0x0, 0x0, 0x14, 0x3, 0x21, 0x0, 0x7b, 0x1, 0xc8, 0x0, 0x0, 0x4, 0xb0}, + nil, + }, + }, + // Remove UDP + { + "udp", 123, 0, 0, + &AddPortMappingResult{0x1403d5, 0x7b, 0x0, 0x0}, + nil, + callRecord{ + []uint8{0x0, 0x1, 0x0, 0x0, 0x0, 0x7b, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, + []uint8{0x0, 0x81, 0x0, 0x0, 0x0, 0x14, 0x3, 0xd5, 0x0, 0x7b, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, + nil, + }, + }, + // Remove TCP + { + "tcp", 123, 0, 0, + &AddPortMappingResult{0x140496, 0x7b, 0x0, 0x0}, + nil, + callRecord{ + []uint8{0x0, 0x2, 0x0, 0x0, 0x0, 0x7b, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, + []uint8{0x0, 0x82, 0x0, 0x0, 0x0, 0x14, 0x4, 0x96, 0x0, 0x7b, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, + nil, + }, + }, + } + + for i, testCase := range testCases { + t.Logf("case %d", i) + c := Client{&mockNetwork{t, testCase.cr}, 0} + result, err := c.AddPortMapping(testCase.protocol, testCase.internalPort, testCase.requestedExternalPort, testCase.lifetime) + if err != nil || testCase.err != nil { + if err != testCase.err && fmt.Sprintf("%v", err) != fmt.Sprintf("%v", testCase.err) { + t.Errorf("err=%v != %v", err, testCase.err) + } + } else { + if result.SecondsSinceStartOfEpoc != testCase.result.SecondsSinceStartOfEpoc { + t.Errorf("result.SecondsSinceStartOfEpoc=%d != %d", result.SecondsSinceStartOfEpoc, testCase.result.SecondsSinceStartOfEpoc) + } + if result.InternalPort != testCase.result.InternalPort { + t.Errorf("result.InternalPort=%d != %d", result.InternalPort, testCase.result.InternalPort) + } + if result.MappedExternalPort != testCase.result.MappedExternalPort { + t.Errorf("result.InternalPort=%d != %d", result.MappedExternalPort, testCase.result.MappedExternalPort) + } + if result.PortMappingLifetimeInSeconds != testCase.result.PortMappingLifetimeInSeconds { + t.Errorf("result.InternalPort=%d != %d", result.PortMappingLifetimeInSeconds, testCase.result.PortMappingLifetimeInSeconds) + } + } + } +} + +func TestProtocolChecks(t *testing.T) { + testCases := []addPortMappingRecord{ + // Unexpected result size. + { + "tcp", 123, 456, 1200, + nil, + fmt.Errorf("unexpected result size %d, expected %d", 1, 16), + callRecord{ + []uint8{0x0, 0x2, 0x0, 0x0, 0x0, 0x7b, 0x1, 0xc8, 0x0, 0x0, 0x4, 0xb0}, + []uint8{0x0}, + nil, + }, + }, + // Unknown protocol version. + { + "tcp", 123, 456, 1200, + nil, + fmt.Errorf("unknown protocol version %d", 1), + callRecord{ + []uint8{0x0, 0x2, 0x0, 0x0, 0x0, 0x7b, 0x1, 0xc8, 0x0, 0x0, 0x4, 0xb0}, + []uint8{0x1, 0x82, 0x0, 0x0, 0x0, 0x14, 0x4, 0x96, 0x0, 0x7b, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, + nil, + }, + }, + // Unexpected opcode. + { + "tcp", 123, 456, 1200, + nil, + fmt.Errorf("Unexpected opcode %d. Expected %d", 0x88, 0x82), + callRecord{ + []uint8{0x0, 0x2, 0x0, 0x0, 0x0, 0x7b, 0x1, 0xc8, 0x0, 0x0, 0x4, 0xb0}, + []uint8{0x0, 0x88, 0x0, 0x0, 0x0, 0x14, 0x4, 0x96, 0x0, 0x7b, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, + nil, + }, + }, + // Non-success result code. + { + "tcp", 123, 456, 1200, + nil, + fmt.Errorf("Non-zero result code %d", 17), + callRecord{ + []uint8{0x0, 0x2, 0x0, 0x0, 0x0, 0x7b, 0x1, 0xc8, 0x0, 0x0, 0x4, 0xb0}, + []uint8{0x0, 0x82, 0x0, 0x11, 0x0, 0x14, 0x4, 0x96, 0x0, 0x7b, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, + nil, + }, + }, + } + for i, testCase := range testCases { + t.Logf("case %d", i) + c := Client{&mockNetwork{t, testCase.cr}, 0} + result, err := c.AddPortMapping(testCase.protocol, testCase.internalPort, testCase.requestedExternalPort, testCase.lifetime) + if err != testCase.err && fmt.Sprintf("%v", err) != fmt.Sprintf("%v", testCase.err) { + t.Errorf("err=%v != %v", err, testCase.err) + } + if result != nil { + t.Errorf("result=%v != nil", result) + } + } +} diff --git a/internal/thirdparty/go-nat-pmp/network.go b/internal/thirdparty/go-nat-pmp/network.go new file mode 100644 index 0000000..7a1369b --- /dev/null +++ b/internal/thirdparty/go-nat-pmp/network.go @@ -0,0 +1,115 @@ +// Modified by Piik: stop socket I/O and retransmissions on context cancellation. +package natpmp + +import ( + "context" + "errors" + "fmt" + "net" + "time" +) + +const nAT_PMP_PORT = 5351 +const nAT_TRIES = 9 +const nAT_INITIAL_MS = 250 + +// A caller that implements the NAT-PMP RPC protocol. +type network struct { + gateway net.IP +} + +func (n *network) call(ctx context.Context, msg []byte, timeout time.Duration) (result []byte, err error) { + if err = ctx.Err(); err != nil { + return + } + var server net.UDPAddr + server.IP = n.gateway + server.Port = nAT_PMP_PORT + conn, err := net.DialUDP("udp", nil, &server) + if err != nil { + return + } + defer conn.Close() + stopCancel := context.AfterFunc(ctx, func() { _ = conn.Close() }) + defer stopCancel() + defer func() { + if err != nil && ctx.Err() != nil { + err = ctx.Err() + } + }() + + // 16 bytes is the maximum result size. + result = make([]byte, 16) + + var finalTimeout time.Time + if timeout != 0 { + finalTimeout = time.Now().Add(timeout) + } + if deadline, ok := ctx.Deadline(); ok { + finalTimeout = minTime(finalTimeout, deadline) + } + + needNewDeadline := true + + var tries uint + for tries = 0; (tries < nAT_TRIES && finalTimeout.IsZero()) || time.Now().Before(finalTimeout); { + if err = ctx.Err(); err != nil { + return + } + if needNewDeadline { + nextDeadline := time.Now().Add((nAT_INITIAL_MS << tries) * time.Millisecond) + err = conn.SetDeadline(minTime(nextDeadline, finalTimeout)) + if err != nil { + return + } + needNewDeadline = false + } + _, err = conn.Write(msg) + if err != nil { + return + } + var bytesRead int + var remoteAddr *net.UDPAddr + bytesRead, remoteAddr, err = conn.ReadFromUDP(result) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + tries++ + needNewDeadline = true + continue + } + return + } + if !remoteAddr.IP.Equal(n.gateway) { + // Ignore this packet. + // Continue without increasing retransmission timeout or deadline. + continue + } + // Trim result to actual number of bytes received + if bytesRead < len(result) { + result = result[:bytesRead] + } + return + } + if deadline, ok := ctx.Deadline(); ok && !time.Now().Before(deadline) { + return nil, context.DeadlineExceeded + } + err = fmt.Errorf("Timed out trying to contact gateway") + return +} + +func minTime(a, b time.Time) time.Time { + if a.IsZero() { + return b + } + if b.IsZero() { + return a + } + if a.Before(b) { + return a + } + return b +} diff --git a/internal/thirdparty/go-nat-pmp/network_test.go b/internal/thirdparty/go-nat-pmp/network_test.go new file mode 100644 index 0000000..c1e8809 --- /dev/null +++ b/internal/thirdparty/go-nat-pmp/network_test.go @@ -0,0 +1,75 @@ +// Copyright 2026 Piik contributors. Licensed under Apache-2.0. +package natpmp + +import ( + "context" + "errors" + "net" + "testing" + "time" +) + +func TestRequestCancellationAndDeadline(t *testing.T) { + // The check runner serializes the gateway fixture packages on this endpoint. + ip := net.IPv4(127, 0, 0, 1) + for _, operation := range []string{"discovery", "mapping", "deletion"} { + for _, cause := range []string{"cancel", "deadline", "client-timeout"} { + t.Run(operation+"/"+cause, func(t *testing.T) { + server, err := net.ListenUDP("udp4", &net.UDPAddr{IP: ip, Port: nAT_PMP_PORT}) + if err != nil { + t.Fatal(err) + } + defer server.Close() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + client := NewClient(ip) + expected := error(context.Canceled) + if cause == "deadline" { + var deadlineCancel context.CancelFunc + ctx, deadlineCancel = context.WithTimeout(ctx, 50*time.Millisecond) + defer deadlineCancel() + expected = context.DeadlineExceeded + } else if cause == "client-timeout" { + client = NewClientWithTimeout(ip, 50*time.Millisecond) + } + done := make(chan error, 1) + go func() { + var err error + switch operation { + case "discovery": + _, err = client.GetExternalAddressContext(ctx) + case "mapping": + _, err = client.AddPortMappingContext(ctx, "udp", 43210, 45000, 7200) + case "deletion": + _, err = client.AddPortMappingContext(ctx, "udp", 43210, 0, 0) + } + done <- err + }() + _ = server.SetReadDeadline(time.Now().Add(time.Second)) + if _, _, err := server.ReadFromUDP(make([]byte, 64)); err != nil { + t.Fatal(err) + } + if cause == "cancel" { + cancel() + } + select { + case err := <-done: + if cause == "client-timeout" { + if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("client timeout error = %v", err) + } + } else if !errors.Is(err, expected) { + t.Fatalf("error = %v, want %v", err, expected) + } + case <-time.After(time.Second): + t.Fatal("socket request did not stop") + } + // Cancellation must end retransmissions, not only release the caller. + _ = server.SetReadDeadline(time.Now().Add(300 * time.Millisecond)) + if _, _, err := server.ReadFromUDP(make([]byte, 64)); err == nil { + t.Fatal("received a retransmission after the call ended") + } + }) + } + } +} diff --git a/internal/thirdparty/go-nat-pmp/recorder.go b/internal/thirdparty/go-nat-pmp/recorder.go new file mode 100644 index 0000000..dac5835 --- /dev/null +++ b/internal/thirdparty/go-nat-pmp/recorder.go @@ -0,0 +1,23 @@ +// Modified by Piik: propagate the request context through the recorder. +package natpmp + +import ( + "context" + "time" +) + +type callObserver interface { + observeCall(msg []byte, result []byte, err error) +} + +// A caller that records the RPC call. +type recorder struct { + child caller + observer callObserver +} + +func (n *recorder) call(ctx context.Context, msg []byte, timeout time.Duration) (result []byte, err error) { + result, err = n.child.call(ctx, msg, timeout) + n.observer.observeCall(msg, result, err) + return +} diff --git a/internal/thirdparty/go-nat/LICENSE b/internal/thirdparty/go-nat/LICENSE new file mode 100644 index 0000000..37ec93a --- /dev/null +++ b/internal/thirdparty/go-nat/LICENSE @@ -0,0 +1,191 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, "control" means (i) the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising +permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +"Object" form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +"submitted" means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +2. Grant of Copyright License. + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +3. Grant of Patent License. + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of +this License; and +You must cause any modified files to carry prominent notices stating that You +changed the files; and +You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +If the Work includes a "NOTICE" text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +5. Submission of Contributions. + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +6. Trademarks. + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +8. Limitation of Liability. + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets "[]" replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same "printed page" as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/internal/thirdparty/go-nat/gateway.go b/internal/thirdparty/go-nat/gateway.go new file mode 100644 index 0000000..ec6f8d7 --- /dev/null +++ b/internal/thirdparty/go-nat/gateway.go @@ -0,0 +1,46 @@ +package nat + +import ( + "net" + "runtime" + + "github.com/libp2p/go-netroute" +) + +func getDefaultGateway() (net.IP, error) { + router, err := netroute.New() + if err != nil { + return nil, err + } + + dst := defaultRouteProbe4() + + _, ip, _, err := router.Route(dst) + if err != nil { + return nil, err + } + if ip == nil { + return nil, ErrNoNATFound + } + return ip, nil +} + +// defaultRouteProbe4 returns the destination to look up when asking for the +// default IPv4 route. +// +// On Linux and Android it is a concrete address rather than 0.0.0.0, because +// go-netroute's netlink implementation rejects an unspecified destination +// before it reaches the kernel. That implementation is newer than the version +// this module requires, so the two must both work: 0.0.0.0/8 is reserved and +// carries no route of its own, so 0.0.0.1 resolves to the default route on the +// older table-parsing implementation as well. +// +// TODO: on Android and iOS, use the platform APIs +// (ConnectivityManager.getLinkProperties / NWPathMonitor) when a netlink-based +// lookup is restricted or unavailable. +func defaultRouteProbe4() net.IP { + if runtime.GOOS == "linux" || runtime.GOOS == "android" { + return net.IPv4(0, 0, 0, 1) + } + return net.IPv4zero +} diff --git a/internal/thirdparty/go-nat/go.mod b/internal/thirdparty/go-nat/go.mod new file mode 100644 index 0000000..78ff3f7 --- /dev/null +++ b/internal/thirdparty/go-nat/go.mod @@ -0,0 +1,17 @@ +module github.com/netbirdio/go-nat + +require ( + github.com/google/gopacket v1.1.19 + github.com/huin/goupnp v1.2.0 + github.com/jackpal/go-nat-pmp v1.0.2 + github.com/koron/go-ssdp v0.0.4 + github.com/libp2p/go-netroute v0.2.1 +) + +require ( + golang.org/x/net v0.10.0 // indirect + golang.org/x/sync v0.2.0 // indirect + golang.org/x/sys v0.8.0 // indirect +) + +go 1.24 diff --git a/internal/thirdparty/go-nat/go.sum b/internal/thirdparty/go-nat/go.sum new file mode 100644 index 0000000..b20c1c6 --- /dev/null +++ b/internal/thirdparty/go-nat/go.sum @@ -0,0 +1,29 @@ +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= +github.com/huin/goupnp v1.2.0 h1:uOKW26NG1hsSSbXIZ1IR7XP9Gjd1U8pnLaCMgntmkmY= +github.com/huin/goupnp v1.2.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= +github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= +github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU= +github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= +golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/thirdparty/go-nat/nat.go b/internal/thirdparty/go-nat/nat.go new file mode 100644 index 0000000..345b5e3 --- /dev/null +++ b/internal/thirdparty/go-nat/nat.go @@ -0,0 +1,231 @@ +// Package nat implements NAT handling facilities +package nat + +import ( + "context" + "errors" + "fmt" + "math" + "math/rand" + "net" + "sync" + "time" +) + +var ErrNoExternalAddress = errors.New("no external address") +var ErrNoInternalAddress = errors.New("no internal address") +var ErrNoNATFound = errors.New("no NAT found") + +// protocol is either "udp" or "tcp" +type NAT interface { + // Type returns the kind of NAT port mapping service that is used + Type() string + + // GetDeviceAddress returns the internal address of the gateway device. + GetDeviceAddress() (addr net.IP, err error) + + // GetExternalAddress returns the external address of the gateway device. + GetExternalAddress() (addr net.IP, err error) + + // GetInternalAddress returns the address of the local host. + GetInternalAddress() (addr net.IP, err error) + + // AddPortMapping maps a port on the local host to an external port. + AddPortMapping(ctx context.Context, protocol string, internalPort int, description string, timeout time.Duration) (mappedExternalPort int, err error) + + // DeletePortMapping removes a port mapping. + DeletePortMapping(ctx context.Context, protocol string, internalPort int) (err error) +} + +// HealthChecker is implemented by gateways that can report whether they still +// hold the mappings they were asked for, PCP being the only one today. +// +// Assert on this interface rather than on a concrete NAT type: a dual-stack +// gateway is a wrapper around the IPv4 NAT, so a type assertion misses it. +type HealthChecker interface { + // CheckServerHealth reports the gateway's current epoch and whether it + // appears to have lost state, in which case mappings must be recreated. + CheckServerHealth(ctx context.Context) (epoch uint32, serverRestarted bool, err error) +} + +// IPv6PinholeReporter is implemented by dual-stack gateways that open IPv6 +// firewall pinholes alongside the IPv4 port mapping. Pinholes are best effort +// and never fail AddPortMapping or DeletePortMapping on their own, so this is +// the only way to find out whether one was actually opened. +type IPv6PinholeReporter interface { + // IPv6PinholeError returns the error from the most recent IPv6 pinhole + // operation, or nil when it succeeded. + IPv6PinholeError() error +} + +// DiscoverNATs returns all NATs discovered in the network. +// Callers choose between the returned protocols; use DiscoverGateway for +// PCP preference and automatic dual-stack mapping. +func DiscoverNATs(ctx context.Context) <-chan NAT { + return mergeNATs(ctx, discoverFallbackNATs(ctx), discoverPCP(ctx)) +} + +func discoverFallbackNATs(ctx context.Context) <-chan NAT { + return mergeNATs(ctx, + discoverUPNP_IG1(ctx), + discoverUPNP_IG2(ctx), + discoverUPNP_Unicast(ctx), + discoverUPNP_GenIGDev(ctx), + discoverNATPMP(ctx), + ) +} + +func mergeNATs(ctx context.Context, sources ...<-chan NAT) <-chan NAT { + nats := make(chan NAT) + var wg sync.WaitGroup + + for _, source := range sources { + if source == nil { + continue + } + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case nat, ok := <-source: + if !ok { + return + } + select { + case nats <- nat: + case <-ctx.Done(): + return + } + case <-ctx.Done(): + return + } + } + }() + } + + go func() { + wg.Wait() + close(nats) + }() + return nats +} + +// DiscoverGateway attempts to find a gateway device. It prefers PCP for IPv4, +// independently enables PCP for IPv6, then falls back to the first UPnP or +// NAT-PMP gateway discovered. +func DiscoverGateway(ctx context.Context) (NAT, error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + return selectGateway(ctx, discoverPCPv4(ctx), discoverPCPv6(ctx), discoverFallbackNATs(ctx)) +} + +func selectGateway(ctx context.Context, pcp4s <-chan NAT, pcp6s <-chan pcpIPv6Client, fallbacks <-chan NAT) (NAT, error) { + var found gatewaySelection + + // PCPv4 outranks the fallback protocols and PCPv6 is independent of both, + // so neither PCP probe may be abandoned early. Fallback discovery only has + // to yield its first result, or to finish empty-handed. + settled := func() bool { + return pcp4s == nil && pcp6s == nil && + (found.pcp4 != nil || found.fallback != nil || fallbacks == nil) + } + + for !settled() { + select { + case gateway, ok := <-pcp4s: + pcp4s = nil + found.keepPCPv4(gateway, ok) + case client, ok := <-pcp6s: + pcp6s = nil + found.keepPCPv6(client, ok) + case gateway, ok := <-fallbacks: + if !ok { + fallbacks = nil + } + found.keepFallback(gateway, ok) + case <-ctx.Done(): + // Sources publish into buffered channels, so a result discovered + // just before the deadline is readable now. Take whatever is + // there, then stop waiting on anything still in flight. + for pcp4s != nil || pcp6s != nil || fallbacks != nil { + select { + case gateway, ok := <-pcp4s: + pcp4s = nil + found.keepPCPv4(gateway, ok) + case client, ok := <-pcp6s: + pcp6s = nil + found.keepPCPv6(client, ok) + case gateway, ok := <-fallbacks: + if !ok { + fallbacks = nil + } + found.keepFallback(gateway, ok) + default: + pcp4s, pcp6s, fallbacks = nil, nil, nil + } + } + } + } + + gateway, err := found.gateway() + if err != nil && ctx.Err() != nil { + // Nothing was found, but the deadline also passed, so the caller can + // tell "this network has no NAT" from "the probes ran out of time". + return nil, fmt.Errorf("%w: %w", err, ctx.Err()) + } + return gateway, err +} + +// gatewaySelection accumulates discovery results while selectGateway waits. +type gatewaySelection struct { + pcp4 NAT + pcp6 pcpIPv6Client + fallback NAT +} + +func (s *gatewaySelection) keepPCPv4(gateway NAT, ok bool) { + if ok { + s.pcp4 = gateway + } +} + +func (s *gatewaySelection) keepPCPv6(client pcpIPv6Client, ok bool) { + if ok { + s.pcp6 = client + } +} + +// keepFallback records the first fallback gateway offered; the rest are +// discarded, since any of them is as good as the others. +func (s *gatewaySelection) keepFallback(gateway NAT, ok bool) { + if ok && s.fallback == nil { + s.fallback = gateway + } +} + +func (s *gatewaySelection) gateway() (NAT, error) { + gateway := s.pcp4 + if gateway == nil { + gateway = s.fallback + } + if gateway == nil { + return nil, ErrNoNATFound + } + if s.pcp6 != nil { + gateway = newNATWithPCPIPv6(gateway, s.pcp6) + } + return gateway, nil +} + +var ( + random = rand.New(rand.NewSource(time.Now().UnixNano())) + randomMu sync.Mutex +) + +func randomPort() int { + randomMu.Lock() + defer randomMu.Unlock() + return random.Intn(math.MaxUint16-10000) + 10000 +} diff --git a/internal/thirdparty/go-nat/nat_test.go b/internal/thirdparty/go-nat/nat_test.go new file mode 100644 index 0000000..8a79169 --- /dev/null +++ b/internal/thirdparty/go-nat/nat_test.go @@ -0,0 +1,472 @@ +package nat + +import ( + "context" + "errors" + "net" + "testing" + "time" + + "github.com/netbirdio/go-nat/pcp" +) + +type fakeNAT struct { + typ string + port int + addErr error + addStarted chan struct{} + addRelease <-chan struct{} + adds int + deletes int +} + +func (n *fakeNAT) Type() string { return n.typ } +func (n *fakeNAT) GetDeviceAddress() (net.IP, error) { return nil, nil } +func (n *fakeNAT) GetExternalAddress() (net.IP, error) { return nil, nil } +func (n *fakeNAT) GetInternalAddress() (net.IP, error) { return nil, nil } +func (n *fakeNAT) AddPortMapping(context.Context, string, int, string, time.Duration) (int, error) { + n.adds++ + if n.addStarted != nil { + close(n.addStarted) + } + if n.addRelease != nil { + <-n.addRelease + } + return n.port, n.addErr +} +func (n *fakeNAT) DeletePortMapping(context.Context, string, int) error { + n.deletes++ + return n.addErr +} + +type fakePCPMapper struct { + addErr error + // unresponsive makes AddPortMapping and Announce block until their context + // ends, standing in for a PCP server that never answers. + unresponsive bool + deleteErr error + addStarted chan struct{} + announceErr error + epochLost bool + adds int + deletes int + announces int +} + +func (m *fakePCPMapper) AddPortMapping(ctx context.Context, _ string, _ int, _ time.Duration) (*pcp.MapResponse, error) { + m.adds++ + if m.addStarted != nil { + close(m.addStarted) + } + if m.unresponsive { + <-ctx.Done() + return nil, ctx.Err() + } + return nil, m.addErr +} + +func (m *fakePCPMapper) DeletePortMapping(context.Context, string, int) error { + m.deletes++ + return m.deleteErr +} + +// fakePCPEpoch is what fakePCPMapper reports, distinct from the IPv4 epoch so +// that tests can tell which stack an epoch came from. +const fakePCPEpoch = 42 + +func (m *fakePCPMapper) Announce(ctx context.Context) (uint32, error) { + m.announces++ + if m.unresponsive { + <-ctx.Done() + return 0, ctx.Err() + } + return fakePCPEpoch, m.announceErr +} + +func (m *fakePCPMapper) EpochStateLost() bool { return m.epochLost } + +// fakeHealthCheckedNAT is a fallback NAT that also reports gateway health, the +// shape a PCPv4 gateway has once it is wrapped for IPv6 pinholes. +type fakeHealthCheckedNAT struct { + fakeNAT + epoch uint32 + restarted bool + healthErr error +} + +func (n *fakeHealthCheckedNAT) CheckServerHealth(context.Context) (uint32, bool, error) { + return n.epoch, n.restarted, n.healthErr +} + +func results[T any](values ...T) <-chan T { + result := make(chan T, len(values)) + for _, value := range values { + result <- value + } + close(result) + return result +} + +func TestMergeNATs(t *testing.T) { + first := &fakeNAT{typ: "UPnP"} + second := &fakeNAT{typ: "NAT-PMP"} + got := make(map[NAT]bool) + for nat := range mergeNATs(context.Background(), nil, results[NAT](first), results[NAT](second)) { + got[nat] = true + } + if !got[first] || !got[second] || len(got) != 2 { + t.Fatalf("merged NATs = %v, want both sources", got) + } +} + +func TestSelectGatewayPrefersPCPv4(t *testing.T) { + pcp4 := &fakeNAT{typ: "PCP"} + fallback := &fakeNAT{typ: "UPnP"} + + // Results already published when the context is cancelled must still be + // collected, with PCP preferred over the fallback. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + got, err := selectGateway(ctx, results[NAT](pcp4), results[pcpIPv6Client](), results[NAT](fallback)) + if err != nil { + t.Fatal(err) + } + if got != pcp4 { + t.Fatalf("selected %s, want PCP", got.Type()) + } +} + +func TestSelectGatewayAddsPCPv6ToFallback(t *testing.T) { + v4Err := errors.New("IPv4 mapping failed") + fallback := &fakeNAT{typ: "NAT-PMP", port: 4242, addErr: v4Err} + pcp6 := &fakePCPMapper{addErr: errors.New("IPv6 mapping failed")} + + got, err := selectGateway(context.Background(), results[NAT](), results[pcpIPv6Client](pcp6), results[NAT](fallback)) + if err != nil { + t.Fatal(err) + } + + port, err := got.AddPortMapping(context.Background(), "tcp", 1234, "test", time.Minute) + if port != fallback.port || !errors.Is(err, v4Err) { + t.Fatalf("AddPortMapping() = (%d, %v), want (%d, %v)", port, err, fallback.port, v4Err) + } + if fallback.adds != 1 || pcp6.adds != 1 { + t.Fatalf("mapping calls = (IPv4: %d, IPv6: %d), want (1, 1)", fallback.adds, pcp6.adds) + } + + _ = got.DeletePortMapping(context.Background(), "tcp", 1234) + if fallback.deletes != 1 || pcp6.deletes != 1 { + t.Fatalf("delete calls = (IPv4: %d, IPv6: %d), want (1, 1)", fallback.deletes, pcp6.deletes) + } +} + +func TestDualStackMappingStartsBothStacks(t *testing.T) { + releaseIPv4 := make(chan struct{}) + startedIPv6 := make(chan struct{}) + ipv4 := &fakeNAT{addRelease: releaseIPv4} + ipv6 := &fakePCPMapper{addStarted: startedIPv6} + gateway := newNATWithPCPIPv6(ipv4, ipv6) + + done := make(chan struct{}) + go func() { + _, _ = gateway.AddPortMapping(context.Background(), "tcp", 1234, "test", time.Minute) + close(done) + }() + + select { + case <-startedIPv6: + case <-time.After(time.Second): + close(releaseIPv4) + <-done + t.Fatal("IPv6 mapping did not start while IPv4 mapping was blocked") + } + close(releaseIPv4) + <-done +} + +func TestDualStackMappingRollsBackIPv6OnIPv4Failure(t *testing.T) { + ipv4 := &fakeNAT{addErr: errors.New("IPv4 mapping failed")} + ipv6 := &fakePCPMapper{} + gateway := newNATWithPCPIPv6(ipv4, ipv6) + + if _, err := gateway.AddPortMapping(context.Background(), "tcp", 1234, "test", time.Minute); err == nil { + t.Fatal("AddPortMapping() error = nil, want IPv4 failure") + } + if ipv6.deletes != 1 { + t.Fatalf("IPv6 deletes = %d, want 1: the pinhole leaks when the IPv4 mapping fails", ipv6.deletes) + } +} + +func TestDualStackReportsPinholeOutcome(t *testing.T) { + v6Err := errors.New("IPv6 pinhole failed") + gateway := newNATWithPCPIPv6(&fakeNAT{port: 4242}, &fakePCPMapper{addErr: v6Err, deleteErr: v6Err}) + + reporter, ok := gateway.(IPv6PinholeReporter) + if !ok { + t.Fatal("dual-stack gateway does not implement IPv6PinholeReporter") + } + + // A pinhole failure must not fail the call: the peer on the other end may + // have no IPv6 at all, so the IPv4 mapping is what matters. + port, err := gateway.AddPortMapping(context.Background(), "tcp", 1234, "test", time.Minute) + if port != 4242 || err != nil { + t.Fatalf("AddPortMapping() = (%d, %v), want (4242, nil)", port, err) + } + if got := reporter.IPv6PinholeError(); !errors.Is(got, v6Err) { + t.Fatalf("IPv6PinholeError() after add = %v, want %v", got, v6Err) + } + + if err := gateway.DeletePortMapping(context.Background(), "tcp", 1234); err != nil { + t.Fatalf("DeletePortMapping() error = %v, want nil", err) + } + if got := reporter.IPv6PinholeError(); !errors.Is(got, v6Err) { + t.Fatalf("IPv6PinholeError() after delete = %v, want %v", got, v6Err) + } +} + +func TestDualStackClearsPinholeErrorOnSuccess(t *testing.T) { + mapper := &fakePCPMapper{addErr: errors.New("IPv6 pinhole failed")} + gateway := newNATWithPCPIPv6(&fakeNAT{}, mapper) + reporter := gateway.(IPv6PinholeReporter) + + // Fail a pinhole first, so the nil below shows the error was cleared + // rather than never recorded. + if _, err := gateway.AddPortMapping(context.Background(), "tcp", 1234, "test", time.Minute); err != nil { + t.Fatal(err) + } + if reporter.IPv6PinholeError() == nil { + t.Fatal("IPv6PinholeError() = nil, want the pinhole failure") + } + + mapper.addErr = nil + + if _, err := gateway.AddPortMapping(context.Background(), "tcp", 1234, "test", time.Minute); err != nil { + t.Fatal(err) + } + if got := reporter.IPv6PinholeError(); got != nil { + t.Fatalf("IPv6PinholeError() = %v, want nil", got) + } +} + +func TestDualStackReportsPinholeLeftOpenWhenRollbackFails(t *testing.T) { + // The IPv4 mapping fails, so the pinhole is rolled back, and the rollback + // fails too. The pinhole is still open, and saying it was closed would be + // a lie the caller cannot check. + deleteErr := errors.New("IPv6 delete failed") + ipv6 := &fakePCPMapper{deleteErr: deleteErr} + gateway := newNATWithPCPIPv6(&fakeNAT{addErr: errors.New("IPv4 mapping failed")}, ipv6) + + if _, err := gateway.AddPortMapping(context.Background(), "tcp", 1234, "test", time.Minute); err == nil { + t.Fatal("AddPortMapping() error = nil, want the IPv4 failure") + } + + got := gateway.(IPv6PinholeReporter).IPv6PinholeError() + if !errors.Is(got, errPinholeLeftOpen) { + t.Fatalf("IPv6PinholeError() = %v, want it to report the pinhole left open", got) + } + if !errors.Is(got, deleteErr) { + t.Fatalf("IPv6PinholeError() = %v, want it to carry %v", got, deleteErr) + } +} + +func TestDualStackMappingDoesNotWaitOutAnUnresponsiveIPv6Server(t *testing.T) { + // Without a bound, the pinhole runs the full RFC 6887 retry schedule and a + // perfectly good IPv4 mapping waits ~30s behind it. + restore := pinholeTimeout + pinholeTimeout = 50 * time.Millisecond + t.Cleanup(func() { pinholeTimeout = restore }) + + ipv6 := &fakePCPMapper{unresponsive: true} + gateway := newNATWithPCPIPv6(&fakeNAT{port: 4242}, ipv6) + + done := make(chan struct{}) + go func() { + defer close(done) + port, err := gateway.AddPortMapping(context.Background(), "tcp", 1234, "test", time.Minute) + if port != 4242 || err != nil { + t.Errorf("AddPortMapping() = (%d, %v), want (4242, nil)", port, err) + } + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("AddPortMapping is still waiting on the IPv6 pinhole") + } + + if got := gateway.(IPv6PinholeReporter).IPv6PinholeError(); !errors.Is(got, context.DeadlineExceeded) { + t.Fatalf("IPv6PinholeError() = %v, want the pinhole deadline", got) + } +} + +func TestDualStackReportsPinholeRolledBack(t *testing.T) { + ipv6 := &fakePCPMapper{} + gateway := newNATWithPCPIPv6(&fakeNAT{addErr: errors.New("IPv4 mapping failed")}, ipv6) + + if _, err := gateway.AddPortMapping(context.Background(), "tcp", 1234, "test", time.Minute); err == nil { + t.Fatal("AddPortMapping() error = nil, want the IPv4 failure") + } + + got := gateway.(IPv6PinholeReporter).IPv6PinholeError() + if !errors.Is(got, errPinholeRolledBack) { + t.Fatalf("IPv6PinholeError() = %v, want it to report the rollback", got) + } +} + +func TestDualStackTypeNamesBothStacks(t *testing.T) { + gateway := newNATWithPCPIPv6(&fakeNAT{typ: "UPnP (IG1-IP1)"}, &fakePCPMapper{}) + if got, want := gateway.Type(), "UPnP (IG1-IP1)+PCPv6"; got != want { + t.Fatalf("Type() = %q, want %q", got, want) + } +} + +func TestDualStackHealthCheck(t *testing.T) { + tests := []struct { + name string + ipv4 NAT + ipv6 *fakePCPMapper + wantEpoch uint32 + wantRestarted bool + wantErr bool + }{ + { + name: "IPv6 restart alone forces a remap", + ipv4: &fakeHealthCheckedNAT{epoch: 7}, + ipv6: &fakePCPMapper{epochLost: true}, + // The pinhole is gone even though the IPv4 gateway is healthy. + wantEpoch: 7, + wantRestarted: true, + }, + { + name: "IPv4 restart alone forces a remap", + ipv4: &fakeHealthCheckedNAT{epoch: 7, restarted: true}, + ipv6: &fakePCPMapper{}, + wantEpoch: 7, + wantRestarted: true, + }, + { + name: "neither stack restarted", + ipv4: &fakeHealthCheckedNAT{epoch: 7}, + ipv6: &fakePCPMapper{}, + wantEpoch: 7, + }, + { + name: "fallback IPv4 cannot report health, IPv6 still can", + ipv4: &fakeNAT{typ: "UPnP"}, + ipv6: &fakePCPMapper{epochLost: true}, + // A UPnP gateway has no epoch, so the IPv6 one is reported instead. + wantEpoch: fakePCPEpoch, + wantRestarted: true, + }, + { + name: "one unreachable stack is not a restart", + ipv4: &fakeHealthCheckedNAT{healthErr: errors.New("unreachable")}, + ipv6: &fakePCPMapper{epochLost: true}, + // The IPv6 verdict stands; the unreachable IPv4 server says nothing. + wantEpoch: fakePCPEpoch, + wantRestarted: true, + }, + { + name: "both stacks unreachable", + ipv4: &fakeHealthCheckedNAT{healthErr: errors.New("unreachable")}, + ipv6: &fakePCPMapper{announceErr: errors.New("unreachable")}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gateway, ok := newNATWithPCPIPv6(tt.ipv4, tt.ipv6).(HealthChecker) + if !ok { + t.Fatal("dual-stack gateway does not implement HealthChecker") + } + + epoch, restarted, err := gateway.CheckServerHealth(context.Background()) + if (err != nil) != tt.wantErr { + t.Fatalf("CheckServerHealth() error = %v, wantErr %v", err, tt.wantErr) + } + if epoch != tt.wantEpoch { + t.Fatalf("epoch = %d, want %d", epoch, tt.wantEpoch) + } + if restarted != tt.wantRestarted { + t.Fatalf("serverRestarted = %v, want %v", restarted, tt.wantRestarted) + } + }) + } +} + +func TestSelectGatewayAbortsOnCancelledContext(t *testing.T) { + // Sources that never publish and never close, like discovery probes stuck + // on an unresponsive network. + stuck4 := make(chan NAT) + stuck6 := make(chan pcpIPv6Client) + stuckFallbacks := make(chan NAT) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + done := make(chan error, 1) + go func() { + _, err := selectGateway(ctx, stuck4, stuck6, stuckFallbacks) + done <- err + }() + + select { + case err := <-done: + if err == nil { + t.Fatal("selectGateway() error = nil, want non-nil on cancelled context") + } + case <-time.After(2 * time.Second): + t.Fatal("selectGateway did not return after context cancellation") + } +} + +func TestSelectGatewayUsesFirstFallback(t *testing.T) { + first := &fakeNAT{typ: "UPnP"} + second := &fakeNAT{typ: "NAT-PMP"} + + got, err := selectGateway(context.Background(), results[NAT](), results[pcpIPv6Client](), results[NAT](first, second)) + if err != nil { + t.Fatal(err) + } + if got != first { + t.Fatalf("selected %s, want first fallback", got.Type()) + } + + _, err = selectGateway(context.Background(), results[NAT](), results[pcpIPv6Client](), results[NAT]()) + if !errors.Is(err, ErrNoNATFound) { + t.Fatalf("empty discovery error = %v, want %v", err, ErrNoNATFound) + } +} + +func TestDualStackHealthCheckSurvivesAnUnresponsiveIPv6Server(t *testing.T) { + // Announced in series on the caller's context, the IPv6 server would run + // the full retry schedule and leave nothing for the IPv4 gateway, so a + // dead pinhole server would disable health checking entirely. + restore := pinholeTimeout + pinholeTimeout = 50 * time.Millisecond + t.Cleanup(func() { pinholeTimeout = restore }) + + ipv4 := &fakeHealthCheckedNAT{epoch: 7, restarted: true} + gateway := newNATWithPCPIPv6(ipv4, &fakePCPMapper{unresponsive: true}).(HealthChecker) + + callerBudget := 2 * time.Second + ctx, cancel := context.WithTimeout(context.Background(), callerBudget) + defer cancel() + + start := time.Now() + epoch, restarted, err := gateway.CheckServerHealth(ctx) + elapsed := time.Since(start) + + if err != nil { + t.Fatalf("CheckServerHealth() error = %v, want the IPv4 verdict to stand", err) + } + if epoch != 7 || !restarted { + t.Fatalf("CheckServerHealth() = (%d, %v), want (7, true)", epoch, restarted) + } + // Unbounded, the dead pinhole server holds the call for the caller's whole + // budget, which is the health check's only chance to run before the next tick. + if elapsed >= callerBudget/2 { + t.Fatalf("CheckServerHealth() took %v, want it bounded near %v", elapsed, pinholeTimeout) + } +} diff --git a/internal/thirdparty/go-nat/natpmp.go b/internal/thirdparty/go-nat/natpmp.go new file mode 100644 index 0000000..c5e0549 --- /dev/null +++ b/internal/thirdparty/go-nat/natpmp.go @@ -0,0 +1,131 @@ +// Modified by Piik: honor request contexts, gateway-assigned ports and deletion. +package nat + +import ( + "context" + "net" + "time" + + natpmp "github.com/jackpal/go-nat-pmp" +) + +var ( + _ NAT = (*natpmpNAT)(nil) +) + +func discoverNATPMP(ctx context.Context) <-chan NAT { + ip, err := getDefaultGateway() + if err != nil { + return nil + } + return discoverNATPMPWithAddr(ctx, ip) +} + +func discoverNATPMPWithAddr(ctx context.Context, ip net.IP) <-chan NAT { + res := make(chan NAT, 1) + go func() { + defer close(res) + client := natpmp.NewClient(ip) + _, err := client.GetExternalAddressContext(ctx) + if err != nil { + return + } + res <- &natpmpNAT{client, ip, make(map[int]int)} + }() + return res +} + +type natpmpNAT struct { + c *natpmp.Client + gateway net.IP + ports map[int]int +} + +func (n *natpmpNAT) GetDeviceAddress() (addr net.IP, err error) { + return n.gateway, nil +} + +func (n *natpmpNAT) GetInternalAddress() (addr net.IP, err error) { + ifaces, err := net.Interfaces() + if err != nil { + return nil, err + } + + for _, iface := range ifaces { + addrs, err := iface.Addrs() + if err != nil { + return nil, err + } + + for _, addr := range addrs { + switch x := addr.(type) { + case *net.IPNet: + if x.Contains(n.gateway) { + return x.IP, nil + } + } + } + } + + return nil, ErrNoInternalAddress +} + +func (n *natpmpNAT) GetExternalAddress() (addr net.IP, err error) { + ctx, cancel := context.WithTimeout(context.Background(), deviceTimeout) + defer cancel() + res, err := n.c.GetExternalAddressContext(ctx) + if err != nil { + return nil, err + } + + d := res.ExternalIPAddress + return net.IPv4(d[0], d[1], d[2], d[3]), nil +} + +func (n *natpmpNAT) AddPortMapping(ctx context.Context, protocol string, internalPort int, _ string, timeout time.Duration) (int, error) { + var ( + err error + result *natpmp.AddPortMappingResult + ) + + timeoutInSeconds := int(timeout / time.Second) + + if externalPort := n.ports[internalPort]; externalPort > 0 { + result, err = n.c.AddPortMappingContext(ctx, protocol, internalPort, externalPort, timeoutInSeconds) + if err == nil { + externalPort = int(result.MappedExternalPort) + n.ports[internalPort] = externalPort + return externalPort, nil + } + } + + for i := 0; i < 3; i++ { + if err := ctx.Err(); err != nil { + return 0, err + } + externalPort := randomPort() + result, err = n.c.AddPortMappingContext(ctx, protocol, internalPort, externalPort, timeoutInSeconds) + if err == nil { + externalPort = int(result.MappedExternalPort) + n.ports[internalPort] = externalPort + return externalPort, nil + } + } + + return 0, err +} + +func (n *natpmpNAT) DeletePortMapping(ctx context.Context, protocol string, internalPort int) (err error) { + // RFC 6886 section 3.4: lifetime and suggested external port are both zero. + // Send even without a cached response: an earlier request may have succeeded + // on the gateway while its reply was lost. + if _, err := n.c.AddPortMappingContext(ctx, protocol, internalPort, 0, 0); err != nil { + return err + } + delete(n.ports, internalPort) + return nil +} + +func (n *natpmpNAT) Type() string { + return "NAT-PMP" +} diff --git a/internal/thirdparty/go-nat/natpmp_test.go b/internal/thirdparty/go-nat/natpmp_test.go new file mode 100644 index 0000000..88e9d4a --- /dev/null +++ b/internal/thirdparty/go-nat/natpmp_test.go @@ -0,0 +1,166 @@ +// Copyright 2026 Piik contributors. Licensed under Apache-2.0. +package nat + +import ( + "context" + "encoding/binary" + "errors" + "net" + "testing" + "time" + + natpmp "github.com/jackpal/go-nat-pmp" +) + +// Use the protocol's real UDP endpoint so the adapter and its client are tested +// together. The check runner serializes gateway fixture packages on port 5351. +func natpmpServer(t *testing.T, reply func([]byte) []byte) (net.IP, <-chan []byte) { + t.Helper() + ip := net.IPv4(127, 0, 0, 1) + server, err := net.ListenUDP("udp4", &net.UDPAddr{IP: ip, Port: 5351}) + if err != nil { + t.Fatal(err) + } + requests := make(chan []byte, 32) + done := make(chan struct{}) + go func() { + defer close(done) + defer close(requests) + for { + buffer := make([]byte, 64) + n, addr, err := server.ReadFromUDP(buffer) + if err != nil { + return + } + request := buffer[:n] + requests <- request + if response := reply(request); response != nil { + _, _ = server.WriteToUDP(response, addr) + } + } + }() + t.Cleanup(func() { + _ = server.Close() + <-done + }) + return ip, requests +} + +func mappingResponse(request []byte, external uint16) []byte { + response := make([]byte, 16) + response[1] = request[1] | 0x80 + copy(response[8:10], request[4:6]) + binary.BigEndian.PutUint16(response[10:12], external) + copy(response[12:16], request[8:12]) + return response +} + +func TestNATPMPUsesAssignedPortsAndDeletesWithPCPv6(t *testing.T) { + assigned := uint16(45000) + ip, requests := natpmpServer(t, func(request []byte) []byte { + assigned++ + return mappingResponse(request, assigned) + }) + ipv4 := &natpmpNAT{natpmp.NewClientWithTimeout(ip, time.Second), ip, make(map[int]int)} + ipv6 := &fakePCPMapper{} + gateway := newNATWithPCPIPv6(ipv4, ipv6) + for _, expected := range []int{45001, 45002} { + port, err := gateway.AddPortMapping(context.Background(), "udp", 43210, "test", time.Hour) + if err != nil || port != expected { + t.Fatalf("port = %d, err = %v, want %d", port, err, expected) + } + request := <-requests + if expected == 45002 && binary.BigEndian.Uint16(request[6:8]) != 45001 { + t.Fatalf("renewal did not request the assigned port: %v", request) + } + } + if err := gateway.DeletePortMapping(context.Background(), "udp", 43210); err != nil { + t.Fatal(err) + } + select { + case request := <-requests: + if request[1] != 1 || binary.BigEndian.Uint16(request[4:6]) != 43210 || + binary.BigEndian.Uint16(request[6:8]) != 0 || binary.BigEndian.Uint32(request[8:12]) != 0 { + t.Fatalf("invalid deletion request: %v", request) + } + case <-time.After(time.Second): + t.Fatal("DeletePortMapping sent no deletion request") + } + if len(ipv4.ports) != 0 || ipv6.adds != 2 || ipv6.deletes != 1 { + t.Fatalf("cleanup: ports=%v, IPv6 adds=%d deletes=%d", ipv4.ports, ipv6.adds, ipv6.deletes) + } +} + +func TestNATPMPFailedDeleteRetainsMapping(t *testing.T) { + ip, requests := natpmpServer(t, func(request []byte) []byte { + response := mappingResponse(request, 45001) + if binary.BigEndian.Uint32(request[8:12]) == 0 { + response[3] = 2 // Not authorized: do not forget the still-owned mapping. + } + return response + }) + gateway := &natpmpNAT{natpmp.NewClientWithTimeout(ip, time.Second), ip, make(map[int]int)} + if _, err := gateway.AddPortMapping(context.Background(), "udp", 43210, "test", time.Hour); err != nil { + t.Fatal(err) + } + <-requests + if err := gateway.DeletePortMapping(context.Background(), "udp", 43210); err == nil { + t.Fatal("rejected deletion returned no error") + } + if gateway.ports[43210] != 45001 { + t.Fatalf("failed deletion forgot mapping: %v", gateway.ports) + } +} + +func TestNATPMPMappingTimeoutStillAllowsDeletion(t *testing.T) { + ip, requests := natpmpServer(t, func(request []byte) []byte { + if binary.BigEndian.Uint32(request[8:12]) == 0 { + return mappingResponse(request, 0) + } + return nil // The router created the mapping, but its response was lost. + }) + gateway := &natpmpNAT{natpmp.NewClientWithTimeout(ip, 500*time.Millisecond), ip, make(map[int]int)} + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + start := time.Now() + _, err := gateway.AddPortMapping(ctx, "udp", 43210, "test", time.Hour) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("mapping error = %v, want DeadlineExceeded", err) + } + if elapsed := time.Since(start); elapsed > 300*time.Millisecond { + t.Fatalf("mapping continued after its deadline for %v", elapsed) + } + <-requests + if err := gateway.DeletePortMapping(context.Background(), "udp", 43210); err != nil { + t.Fatal(err) + } + select { + case request := <-requests: + if binary.BigEndian.Uint16(request[6:8]) != 0 || binary.BigEndian.Uint32(request[8:12]) != 0 { + t.Fatalf("expected deletion after a lost mapping reply: %v", request) + } + case <-time.After(time.Second): + t.Fatal("a lost reply prevented cleanup") + } +} + +func TestNATPMPDiscoveryStopsOnCancel(t *testing.T) { + ip, requests := natpmpServer(t, func([]byte) []byte { return nil }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + result := discoverNATPMPWithAddr(ctx, ip) + select { + case <-requests: + case <-time.After(time.Second): + t.Fatal("discovery did not start") + } + cancel() + select { + case gateway, ok := <-result: + if ok || gateway != nil { + t.Fatal("canceled discovery published a gateway") + } + case <-time.After(time.Second): + t.Fatal("discovery did not stop") + } +} diff --git a/internal/thirdparty/go-nat/pcp.go b/internal/thirdparty/go-nat/pcp.go new file mode 100644 index 0000000..233316a --- /dev/null +++ b/internal/thirdparty/go-nat/pcp.go @@ -0,0 +1,63 @@ +package nat + +import ( + "context" + "time" + + "github.com/netbirdio/go-nat/pcp" +) + +// pcpProbeTimeout bounds PCP discovery. A PCP server sits on the local link +// and answers in milliseconds, so the RFC 6887 §8.1.1 retransmission schedule +// that Client uses for mapping requests, four attempts spread over roughly 30 +// seconds, would only delay falling back to UPnP or NAT-PMP on the far more +// common networks that speak no PCP at all. +const pcpProbeTimeout = 2 * time.Second + +func discoverPCP(ctx context.Context) <-chan NAT { + results := make(chan NAT, 1) + go func() { + defer close(results) + + ctx, cancel := context.WithTimeout(ctx, pcpProbeTimeout) + defer cancel() + + gateway, err := pcp.DiscoverPCP(ctx) + if err == nil { + results <- gateway + } + }() + return results +} + +func discoverPCPv4(ctx context.Context) <-chan NAT { + results := make(chan NAT, 1) + go func() { + defer close(results) + + ctx, cancel := context.WithTimeout(ctx, pcpProbeTimeout) + defer cancel() + + gateway, err := pcp.DiscoverPCPIPv4(ctx) + if err == nil { + results <- gateway + } + }() + return results +} + +func discoverPCPv6(ctx context.Context) <-chan pcpIPv6Client { + results := make(chan pcpIPv6Client, 1) + go func() { + defer close(results) + + ctx, cancel := context.WithTimeout(ctx, pcpProbeTimeout) + defer cancel() + + client, err := pcp.DiscoverPCPIPv6(ctx) + if err == nil { + results <- client + } + }() + return results +} diff --git a/internal/thirdparty/go-nat/pcp/client.go b/internal/thirdparty/go-nat/pcp/client.go new file mode 100644 index 0000000..a7ce08e --- /dev/null +++ b/internal/thirdparty/go-nat/pcp/client.go @@ -0,0 +1,484 @@ +package pcp + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "net" + "net/netip" + "sync" + "time" +) + +const ( + defaultTimeout = 3 * time.Second + responseBufferSize = 128 + + // RFC 6887 Section 8.1.1 retry timing. + initialRetryDelay = 3 * time.Second + maxRetryDelay = 1024 * time.Second + maxRetries = 4 +) + +// Client is a PCP protocol client. +// All methods are safe for concurrent use. +type Client struct { + gateway netip.Addr + timeout time.Duration + + mu sync.Mutex + // localIP caches the resolved local IP address. + localIP netip.Addr + // lastEpoch is the last observed server epoch value. + lastEpoch uint32 + // epochTime tracks when lastEpoch was received for state loss detection. + epochTime time.Time + // externalIP caches the external IP from the last successful MAP response. + externalIP netip.Addr + // epochStateLost is set when epoch indicates server restart. + epochStateLost bool + // nonces holds the nonce for each active mapping; RFC 6887 requires + // renewals and deletes to reuse the nonce the mapping was created with. + nonces map[mappingKey][12]byte + // mappingLocks serializes requests per mapping. The nonce is the server's + // identity for a mapping, so a delete completing while a create for the + // same key is still in flight would drop the nonce the created mapping + // needs, leaving one on the server that no later delete can remove. + mappingLocks map[mappingKey]*sync.Mutex +} + +// lockMapping serializes requests for one mapping. It never holds c.mu across +// the request itself, which would block every other operation on the client for +// the length of a UDP exchange. +func (c *Client) lockMapping(key mappingKey) func() { + c.mu.Lock() + lock, ok := c.mappingLocks[key] + if !ok { + lock = &sync.Mutex{} + if c.mappingLocks == nil { + c.mappingLocks = make(map[mappingKey]*sync.Mutex) + } + c.mappingLocks[key] = lock + } + c.mu.Unlock() + + lock.Lock() + return lock.Unlock +} + +type mappingKey struct { + proto uint8 + port uint16 +} + +// NewClient creates a new PCP client for the gateway at the given IP. +func NewClient(gateway net.IP) *Client { + return NewClientWithTimeout(gateway, defaultTimeout) +} + +// NewClientWithTimeout creates a new PCP client with a custom timeout. +func NewClientWithTimeout(gateway net.IP, timeout time.Duration) *Client { + addr, ok := netip.AddrFromSlice(gateway) + if !ok { + return &Client{timeout: timeout} + } + return &Client{ + gateway: addr.Unmap(), + timeout: timeout, + } +} + +// SetLocalIP sets the local IP address to use in PCP requests. +func (c *Client) SetLocalIP(ip net.IP) { + addr, ok := netip.AddrFromSlice(ip) + + c.mu.Lock() + defer c.mu.Unlock() + if !ok { + c.localIP = netip.Addr{} + return + } + c.localIP = addr.Unmap() +} + +// Gateway returns the gateway IP address. +func (c *Client) Gateway() net.IP { + if !c.gateway.IsValid() { + return nil + } + return append(net.IP(nil), c.gateway.AsSlice()...) +} + +// Announce sends a PCP ANNOUNCE request to discover PCP support. +// It returns the server's epoch time on success. +func (c *Client) Announce(ctx context.Context) (epoch uint32, err error) { + localIP, err := c.getLocalIP() + if err != nil { + return 0, fmt.Errorf("get local IP: %w", err) + } + + req := buildAnnounceRequest(localIP) + resp, err := c.sendRequest(ctx, req) + if err != nil { + return 0, fmt.Errorf("send announce: %w", err) + } + + parsed, err := parseResponse(resp) + if err != nil { + return 0, fmt.Errorf("parse announce response: %w", err) + } + + if parsed.ResultCode != ResultSuccess { + return 0, fmt.Errorf("PCP ANNOUNCE failed: %s", ResultCodeString(parsed.ResultCode)) + } + + c.mu.Lock() + c.updateEpochLocked(parsed.Epoch) + c.mu.Unlock() + return parsed.Epoch, nil +} + +// AddPortMapping requests a port mapping from the PCP server. +func (c *Client) AddPortMapping(ctx context.Context, protocol string, internalPort int, lifetime time.Duration) (*MapResponse, error) { + return c.addPortMappingWithHint(ctx, protocol, internalPort, internalPort, netip.Addr{}, lifetime) +} + +// AddPortMappingWithHint requests a port mapping with suggested external port and IP. +// Use lifetime <= 0 to delete a mapping. +func (c *Client) AddPortMappingWithHint(ctx context.Context, protocol string, internalPort, suggestedExtPort int, suggestedExtIP net.IP, lifetime time.Duration) (*MapResponse, error) { + var extIP netip.Addr + if suggestedExtIP != nil { + var ok bool + extIP, ok = netip.AddrFromSlice(suggestedExtIP) + if ok { + extIP = extIP.Unmap() + } + } + return c.addPortMappingWithHint(ctx, protocol, internalPort, suggestedExtPort, extIP, lifetime) +} + +func (c *Client) addPortMappingWithHint(ctx context.Context, protocol string, internalPort, suggestedExtPort int, suggestedExtIP netip.Addr, lifetime time.Duration) (*MapResponse, error) { + if internalPort <= 0 || internalPort > 65535 { + return nil, fmt.Errorf("invalid internal port: %d", internalPort) + } + if suggestedExtPort < 0 || suggestedExtPort > 65535 { + return nil, fmt.Errorf("invalid suggested external port: %d", suggestedExtPort) + } + + localIP, err := c.getLocalIP() + if err != nil { + return nil, fmt.Errorf("get local IP: %w", err) + } + + proto, err := protocolNumber(protocol) + if err != nil { + return nil, fmt.Errorf("parse protocol: %w", err) + } + + // Convert lifetime to seconds. Lifetime 0 means delete, so only apply the + // default for positive durations that round to 0 seconds. + var lifetimeSec uint32 + if lifetime > 0 { + lifetimeSec = uint32(lifetime.Seconds()) + if lifetimeSec == 0 { + lifetimeSec = DefaultLifetime + } + } + + // Generate a candidate before taking the lock, which also guards the epoch + // and the cached external IP: an entropy read should not block them. + var fresh [12]byte + if _, err := rand.Read(fresh[:]); err != nil { + return nil, fmt.Errorf("generate nonce: %w", err) + } + + key := mappingKey{proto: proto, port: uint16(internalPort)} + defer c.lockMapping(key)() + + c.mu.Lock() + nonce, haveNonce := c.nonces[key] + if !haveNonce { + nonce = fresh + } + // Store the nonce before sending: if the server creates the mapping but + // the response is lost, a later delete must still present this nonce or + // the server answers NOT_AUTHORIZED and the mapping leaks (RFC 6887 §11.3). + if lifetimeSec > 0 { + if c.nonces == nil { + c.nonces = make(map[mappingKey][12]byte) + } + c.nonces[key] = nonce + } + c.mu.Unlock() + + req := buildMapRequest(localIP, nonce, proto, uint16(internalPort), uint16(suggestedExtPort), suggestedExtIP, lifetimeSec) + + resp, err := c.sendRequest(ctx, req) + if err != nil { + return nil, fmt.Errorf("send map request: %w", err) + } + + mapResp, err := parseMapResponse(resp) + if err != nil { + return nil, fmt.Errorf("parse map response: %w", err) + } + + if mapResp.Nonce != nonce { + return nil, errors.New("nonce mismatch in response") + } + if mapResp.Protocol != proto { + return nil, fmt.Errorf("protocol mismatch: requested %d, got %d", proto, mapResp.Protocol) + } + if mapResp.InternalPort != uint16(internalPort) { + return nil, fmt.Errorf("internal port mismatch: requested %d, got %d", internalPort, mapResp.InternalPort) + } + if mapResp.ResultCode != ResultSuccess { + return nil, &Error{ + Code: mapResp.ResultCode, + Message: ResultCodeString(mapResp.ResultCode), + } + } + + c.mu.Lock() + c.updateEpochLocked(mapResp.Epoch) + c.cacheExternalIPLocked(mapResp.ExternalIP) + if lifetimeSec == 0 { + delete(c.nonces, key) + } + c.mu.Unlock() + return mapResp, nil +} + +// DeletePortMapping removes a port mapping by requesting zero lifetime. +// Deleting a nonexistent mapping succeeds (RFC 6887 §15.1), so any error is +// a real failure and is returned. +// +// This only deletes mappings that this Client created. RFC 6887 §11.3 matches a +// MAP request to an existing mapping by nonce, and the nonces live in memory, +// so a mapping created by an earlier process cannot be deleted: the server sees +// an unknown nonce and treats the request as a no-op on a mapping that does not +// exist. Such a mapping expires on its own once its lifetime runs out. A caller +// that needs to clean up after a crash has to persist the nonce alongside the +// port and restore it before calling this. +func (c *Client) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error { + if _, err := c.addPortMappingWithHint(ctx, protocol, internalPort, 0, netip.Addr{}, 0); err != nil { + return fmt.Errorf("delete mapping: %w", err) + } + return nil +} + +// GetExternalAddress returns the external IP address. +// It first checks for a cached value from previous MAP responses. If not +// cached, it creates a short-lived mapping to discover the external IP. +func (c *Client) GetExternalAddress(ctx context.Context) (net.IP, error) { + c.mu.Lock() + if c.externalIP.IsValid() { + ip := append(net.IP(nil), c.externalIP.AsSlice()...) + c.mu.Unlock() + return ip, nil + } + c.mu.Unlock() + + // Use an ephemeral port in the dynamic range (49152-65535). Port 0 is not + // valid with UDP/TCP protocols per RFC 6887. + ephemeralPort := 49152 + int(uint16(time.Now().UnixNano()))%(65535-49152) + + // Use minimal lifetime (1 second) for discovery. + resp, err := c.AddPortMapping(ctx, "udp", ephemeralPort, time.Second) + if err != nil { + return nil, fmt.Errorf("create temporary mapping: %w", err) + } + + _ = c.DeletePortMapping(ctx, "udp", ephemeralPort) + + return append(net.IP(nil), resp.ExternalIP.AsSlice()...), nil +} + +// LastEpoch returns the last observed server epoch value. +// A decrease in epoch indicates the server may have restarted and mappings may be lost. +func (c *Client) LastEpoch() uint32 { + c.mu.Lock() + defer c.mu.Unlock() + return c.lastEpoch +} + +// EpochStateLost returns true if epoch state loss was detected and clears the flag. +func (c *Client) EpochStateLost() bool { + c.mu.Lock() + defer c.mu.Unlock() + lost := c.epochStateLost + c.epochStateLost = false + return lost +} + +// updateEpochLocked updates epoch tracking and detects potential state loss. +// It returns true if state loss was detected. Caller must hold c.mu. +func (c *Client) updateEpochLocked(newEpoch uint32) bool { + now := time.Now() + stateLost := false + + // RFC 6887 Section 8.5: Detect invalid epoch indicating server state loss. + // client_delta = time since last response + // server_delta = epoch change since last response + // Invalid if: client_delta+2 < server_delta - server_delta/16 + // OR: server_delta+2 < client_delta - client_delta/16 + // The +2 handles quantization, /16 (6.25%) handles clock drift. + if !c.epochTime.IsZero() && c.lastEpoch > 0 { + clientDelta := uint32(now.Sub(c.epochTime).Seconds()) + serverDelta := newEpoch - c.lastEpoch + + // Check for epoch going backwards or jumping unexpectedly. Subtraction is + // safe: serverDelta/16 is always <= serverDelta. + if clientDelta+2 < serverDelta-(serverDelta/16) || + serverDelta+2 < clientDelta-(clientDelta/16) { + stateLost = true + c.epochStateLost = true + } + } + + c.lastEpoch = newEpoch + c.epochTime = now + return stateLost +} + +// cacheExternalIPLocked stores the external IP from a successful MAP response. +// Caller must hold c.mu. +func (c *Client) cacheExternalIPLocked(ip netip.Addr) { + if ip.IsValid() && !ip.IsUnspecified() { + c.externalIP = ip + } +} + +// sendRequest sends a PCP request with retries per RFC 6887 Section 8.1.1. +func (c *Client) sendRequest(ctx context.Context, req []byte) ([]byte, error) { + if !c.gateway.IsValid() { + return nil, errors.New("gateway IP not set") + } + + addr := &net.UDPAddr{IP: c.gateway.AsSlice(), Port: Port, Zone: c.gateway.Zone()} + + var lastErr error + delay := initialRetryDelay + + for attempt := 0; attempt < maxRetries; attempt++ { + resp, err := c.sendOnce(ctx, addr, req) + if err == nil { + return resp, nil + } + lastErr = err + + if ctx.Err() != nil { + return nil, ctx.Err() + } + if attempt == maxRetries-1 { + break + } + + // RFC 6887 Section 8.1.1: RT = (1 + RAND) * MIN(2 * RTprev, MRT), + // where RAND is random between -0.1 and +0.1. + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(retryDelayWithJitter(delay)): + } + delay = min(delay*2, maxRetryDelay) + } + + return nil, fmt.Errorf("PCP request failed after %d retries: %w", maxRetries, lastErr) +} + +// retryDelayWithJitter applies RFC 6887 jitter: multiply by (1 + RAND) where RAND is [-0.1, +0.1]. +func retryDelayWithJitter(d time.Duration) time.Duration { + var b [1]byte + _, _ = rand.Read(b[:]) + // Convert byte to range [-0.1, +0.1]: (b/255 * 0.2) - 0.1. + jitter := (float64(b[0])/255.0)*0.2 - 0.1 + return time.Duration(float64(d) * (1 + jitter)) +} + +func (c *Client) sendOnce(ctx context.Context, addr *net.UDPAddr, req []byte) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + // Use ListenUDP instead of DialUDP to validate response source address per RFC 6887 §8.3. + conn, err := net.ListenUDP("udp", nil) + if err != nil { + return nil, fmt.Errorf("listen: %w", err) + } + defer func() { _ = conn.Close() }() + + timeout := c.timeout + if timeout <= 0 { + timeout = defaultTimeout + } + if deadline, ok := ctx.Deadline(); ok { + remaining := time.Until(deadline) + if remaining <= 0 { + return nil, ctx.Err() + } + if remaining < timeout { + timeout = remaining + } + } + + if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil { + return nil, fmt.Errorf("set deadline: %w", err) + } + stopCancel := context.AfterFunc(ctx, func() { + _ = conn.SetDeadline(time.Now()) + }) + defer stopCancel() + + if _, err := conn.WriteToUDP(req, addr); err != nil { + return nil, fmt.Errorf("write: %w", err) + } + + resp := make([]byte, responseBufferSize) + n, from, err := conn.ReadFromUDP(resp) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("read: %w", err) + } + + // RFC 6887 §8.3: Validate response came from expected PCP server. + if !from.IP.Equal(addr.IP) { + return nil, fmt.Errorf("response from unexpected source %s (expected %s)", from.IP, addr.IP) + } + + return resp[:n], nil +} + +func (c *Client) getLocalIP() (netip.Addr, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if !c.localIP.IsValid() { + return netip.Addr{}, fmt.Errorf("local IP not set for gateway %s", c.gateway) + } + return c.localIP, nil +} + +func protocolNumber(protocol string) (uint8, error) { + switch protocol { + case "udp", "UDP": + return ProtoUDP, nil + case "tcp", "TCP": + return ProtoTCP, nil + default: + return 0, fmt.Errorf("unsupported protocol: %s", protocol) + } +} + +// Error represents a PCP error response. +type Error struct { + Code uint8 + Message string +} + +func (e *Error) Error() string { + return fmt.Sprintf("PCP error: %s (%d)", e.Message, e.Code) +} diff --git a/internal/thirdparty/go-nat/pcp/client_test.go b/internal/thirdparty/go-nat/pcp/client_test.go new file mode 100644 index 0000000..abe3109 --- /dev/null +++ b/internal/thirdparty/go-nat/pcp/client_test.go @@ -0,0 +1,321 @@ +package pcp + +import ( + "context" + "encoding/binary" + "errors" + "net" + "net/netip" + "sync" + "testing" + "time" +) + +// fakePCPServer is a minimal RFC 6887 server. It enforces the MAP nonce rules +// (§11.3, §15.1): a MAP request for an existing mapping whose nonce differs is +// answered with NOT_AUTHORIZED and the mapping is left untouched. +type fakePCPServer struct { + conn *net.UDPConn + + mu sync.Mutex + mappings map[mappingKey][12]byte + // corruptNext truncates the next MAP response: the server creates the + // mapping but the client cannot parse the reply. + corruptNext bool + // refuseDeletes answers every zero-lifetime MAP with NO_RESOURCES and + // keeps the mapping, standing in for a server that will not release one. + refuseDeletes bool + // silent drops every request, standing in for a gateway that advertised + // PCP and then stopped answering. + silent bool + // gate, when non-nil, holds every request until a value is received, so a + // test can keep one exchange in flight while it observes the client. + gate chan struct{} + // arrivals reports each request as it reaches the server. + arrivals chan struct{} +} + +// newFakePCPServer binds a fake server on addr, which must use the well-known +// PCP port: Client always sends there, and the discovery tests build their +// clients inside the package, so the port cannot be overridden per test. These +// tests therefore need port 5351 free on the host running them, which rules out +// a machine already running a PCP or NAT-PMP responder. +func newFakePCPServer(t *testing.T, network, addr string) *fakePCPServer { + t.Helper() + udpAddr, err := net.ResolveUDPAddr(network, addr) + if err != nil { + t.Fatal(err) + } + conn, err := net.ListenUDP(network, udpAddr) + if err != nil { + t.Fatalf("bind fake PCP server on %s: %v", addr, err) + } + t.Cleanup(func() { _ = conn.Close() }) + + server := &fakePCPServer{conn: conn, mappings: make(map[mappingKey][12]byte)} + go server.serve() + return server +} + +func (s *fakePCPServer) serve() { + buf := make([]byte, 128) + for { + n, from, err := s.conn.ReadFromUDP(buf) + if err != nil { + return + } + // Each request is handled on its own goroutine so that gating one does + // not also stall the requests a test is watching for. + req := append([]byte(nil), buf[:n]...) + go func() { + if resp := s.handle(req); resp != nil { + _, _ = s.conn.WriteToUDP(resp, from) + } + }() + } +} + +func (s *fakePCPServer) handle(req []byte) []byte { + if len(req) < headerSize || req[0] != Version { + return nil + } + + s.mu.Lock() + silent, gate, arrivals := s.silent, s.gate, s.arrivals + s.mu.Unlock() + if arrivals != nil { + arrivals <- struct{}{} + } + if gate != nil { + <-gate + } + if silent { + return nil + } + + const epoch = 1000 + switch req[1] { + case OpAnnounce: + resp := make([]byte, headerSize) + resp[0] = Version + resp[1] = OpAnnounce | OpReply + binary.BigEndian.PutUint32(resp[8:12], epoch) + return resp + case OpMap: + if len(req) < mapRequestSize { + return nil + } + lifetime := binary.BigEndian.Uint32(req[4:8]) + var nonce [12]byte + copy(nonce[:], req[24:36]) + key := mappingKey{proto: req[36], port: binary.BigEndian.Uint16(req[40:42])} + + resp := make([]byte, mapRequestSize) + resp[0] = Version + resp[1] = OpMap | OpReply + binary.BigEndian.PutUint32(resp[4:8], lifetime) + binary.BigEndian.PutUint32(resp[8:12], epoch) + copy(resp[24:36], nonce[:]) + resp[36] = key.proto + binary.BigEndian.PutUint16(resp[40:42], key.port) + binary.BigEndian.PutUint16(resp[42:44], key.port) + extIP := addrTo16(netip.MustParseAddr("192.0.2.1")) + copy(resp[44:60], extIP[:]) + + s.mu.Lock() + defer s.mu.Unlock() + if stored, ok := s.mappings[key]; ok && stored != nonce { + resp[3] = ResultNotAuthorized + binary.BigEndian.PutUint32(resp[4:8], 0) + return resp + } + if lifetime == 0 { + if s.refuseDeletes { + resp[3] = ResultNoResources + return resp + } + delete(s.mappings, key) + } else { + s.mappings[key] = nonce + } + if s.corruptNext { + s.corruptNext = false + return resp[:headerSize] + } + return resp + } + return nil +} + +func (s *fakePCPServer) hasMapping(proto uint8, port uint16) bool { + s.mu.Lock() + defer s.mu.Unlock() + _, ok := s.mappings[mappingKey{proto: proto, port: port}] + return ok +} + +// RFC 6887 requires renewals and deletes to carry the nonce the mapping was +// created with; a server answers a different nonce with NOT_AUTHORIZED. +func TestPortMappingNonceReuse(t *testing.T) { + server := newFakePCPServer(t, "udp4", "127.0.0.1:5351") + + newTestClient := func() *Client { + client := NewClientWithTimeout(net.IPv4(127, 0, 0, 1), time.Second) + client.SetLocalIP(net.IPv4(127, 0, 0, 1)) + return client + } + + t.Run("renew reuses create nonce", func(t *testing.T) { + client := newTestClient() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if _, err := client.AddPortMapping(ctx, "udp", 4444, time.Hour); err != nil { + t.Fatalf("create mapping: %v", err) + } + if _, err := client.AddPortMapping(ctx, "udp", 4444, time.Hour); err != nil { + t.Fatalf("renew mapping: %v (renew must reuse the create nonce)", err) + } + }) + + t.Run("delete reuses nonce of create whose response was lost", func(t *testing.T) { + client := newTestClient() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + server.mu.Lock() + server.corruptNext = true + server.mu.Unlock() + + if _, err := client.AddPortMapping(ctx, "udp", 4446, time.Hour); err == nil { + t.Fatal("create with corrupted response: error = nil, want parse failure") + } + if !server.hasMapping(ProtoUDP, 4446) { + t.Fatal("server did not create the mapping") + } + if err := client.DeletePortMapping(ctx, "udp", 4446); err != nil { + t.Fatalf("delete mapping: %v (must reuse the nonce sent with the failed create)", err) + } + if server.hasMapping(ProtoUDP, 4446) { + t.Fatal("mapping still present on server: pinhole leaks after a lost create response") + } + }) + + t.Run("delete reuses create nonce and removes mapping", func(t *testing.T) { + client := newTestClient() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if _, err := client.AddPortMapping(ctx, "udp", 4445, time.Hour); err != nil { + t.Fatalf("create mapping: %v", err) + } + err := client.DeletePortMapping(ctx, "udp", 4445) + if server.hasMapping(ProtoUDP, 4445) { + if err == nil { + t.Fatal("mapping still present on server and DeletePortMapping returned nil: NOT_AUTHORIZED was silently swallowed") + } + t.Fatalf("mapping still present on server: %v", err) + } + if err != nil { + t.Fatalf("delete mapping: %v", err) + } + }) +} + +func TestSendOnceStopsOnCancel(t *testing.T) { + server, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = server.Close() }() + + received := make(chan error, 1) + go func() { + buffer := make([]byte, 1) + _, _, err := server.ReadFromUDP(buffer) + received <- err + }() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { + _, err := (&Client{timeout: 10 * time.Second}).sendOnce(ctx, server.LocalAddr().(*net.UDPAddr), []byte{0}) + done <- err + }() + + select { + case err := <-received: + if err != nil { + t.Fatal(err) + } + case err := <-done: + t.Fatalf("send stopped before cancellation: %v", err) + case <-time.After(time.Second): + t.Fatal("server did not receive PCP packet") + } + + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("send error = %v, want %v", err, context.Canceled) + } + case <-time.After(time.Second): + t.Fatal("send did not stop after cancellation") + } +} + +// A delete must not overtake a create for the same mapping. The nonce is the +// server's identity for a mapping, so a delete completing first drops the nonce +// the created mapping needs, stranding one no later delete can remove. +func TestPortMappingRequestsAreSerializedPerMapping(t *testing.T) { + server := newFakePCPServer(t, "udp4", "127.0.0.1:5351") + gate := make(chan struct{}) + arrivals := make(chan struct{}, 8) + server.mu.Lock() + server.gate, server.arrivals = gate, arrivals + server.mu.Unlock() + + client := NewClientWithTimeout(net.IPv4(127, 0, 0, 1), 5*time.Second) + client.SetLocalIP(net.IPv4(127, 0, 0, 1)) + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + createDone := make(chan error, 1) + go func() { + _, err := client.AddPortMapping(ctx, "udp", 4447, time.Hour) + createDone <- err + }() + + // Hold the create at the server. + select { + case <-arrivals: + case <-time.After(5 * time.Second): + t.Fatal("create never reached the server") + } + + deleteDone := make(chan error, 1) + go func() { deleteDone <- client.DeletePortMapping(ctx, "udp", 4447) }() + + // The delete must not reach the server while the create is in flight. + select { + case <-arrivals: + t.Fatal("delete reached the server while the create was still in flight") + case err := <-deleteDone: + t.Fatalf("delete completed before the create: %v", err) + case <-time.After(300 * time.Millisecond): + } + + close(gate) + if err := <-createDone; err != nil { + t.Fatalf("create mapping: %v", err) + } + if err := <-deleteDone; err != nil { + t.Fatalf("delete mapping: %v (the create's nonce must still be held)", err) + } + if server.hasMapping(ProtoUDP, 4447) { + t.Fatal("mapping still present on server") + } +} diff --git a/internal/thirdparty/go-nat/pcp/gateway_discoverer.go b/internal/thirdparty/go-nat/pcp/gateway_discoverer.go new file mode 100644 index 0000000..c9f8d11 --- /dev/null +++ b/internal/thirdparty/go-nat/pcp/gateway_discoverer.go @@ -0,0 +1,109 @@ +package pcp + +import ( + "context" + "net" + "runtime" + + "github.com/libp2p/go-netroute" +) + +type gatewayDiscoverer interface { + Discover(ctx context.Context) (gateway net.IP, localIP net.IP, err error) + DiscoverV6(ctx context.Context) (gateway net.IP, localIP net.IP, zone string, err error) +} + +type defaultGatewayDiscoverer struct{} + +var _ gatewayDiscoverer = (*defaultGatewayDiscoverer)(nil) + +var newRouter = netroute.New + +// Discover returns the default IPv4 gateway and local IP using the system routing table. +func (d *defaultGatewayDiscoverer) Discover(ctx context.Context) (gateway net.IP, localIP net.IP, err error) { + // router.Route is a synchronous system call that cannot observe + // cancellation, so this is the only point where ctx is honored. + if err := ctx.Err(); err != nil { + return nil, nil, err + } + + router, err := newRouter() + if err != nil { + return nil, nil, err + } + + _, gateway, localIP, err = router.Route(defaultRouteProbe4()) + if err != nil { + return nil, nil, err + } + + if gateway == nil { + return nil, nil, ErrNoNATFound + } + if localIP == nil { + return nil, nil, ErrNoInternalAddress + } + + return gateway, localIP, nil +} + +// DiscoverV6 returns the default IPv6 gateway, local IP, and scope zone. +func (d *defaultGatewayDiscoverer) DiscoverV6(ctx context.Context) (gateway net.IP, localIP net.IP, zone string, err error) { + // router.Route is a synchronous system call that cannot observe + // cancellation, so this is the only point where ctx is honored. + if err := ctx.Err(); err != nil { + return nil, nil, "", err + } + + router, err := newRouter() + if err != nil { + return nil, nil, "", err + } + + iface, gateway, localIP, err := router.Route(defaultRouteProbe6()) + if err != nil { + return nil, nil, "", err + } + + if gateway == nil { + return nil, nil, "", ErrNoNATFound + } + if localIP == nil { + return nil, nil, "", ErrNoInternalAddress + } + if gateway.IsLinkLocalUnicast() && iface != nil { + zone = iface.Name + } + + return gateway, localIP, zone, nil +} + +// defaultRouteProbe4 returns the destination to look up when asking for the +// default IPv4 route. +// +// On Linux and Android it is a concrete address rather than 0.0.0.0, because +// go-netroute's netlink implementation rejects an unspecified destination +// before it reaches the kernel. That implementation is newer than the version +// this module requires, so both must work: 0.0.0.0/8 is reserved and carries no +// route of its own, so 0.0.0.1 resolves to the default route on the older +// table-parsing implementation as well. +// +// TODO: on Android and iOS, use the platform APIs +// (ConnectivityManager.getLinkProperties / NWPathMonitor) when a netlink-based +// lookup is restricted or unavailable. +func defaultRouteProbe4() net.IP { + if runtime.GOOS == "linux" || runtime.GOOS == "android" { + return net.IPv4(0, 0, 0, 1) + } + return net.IPv4zero +} + +// defaultRouteProbe6 is the IPv6 counterpart of defaultRouteProbe4. ::2 sits in +// the reserved range around the unspecified address and carries no route of its +// own, so it resolves to the default route. +func defaultRouteProbe6() net.IP { + if runtime.GOOS == "linux" || runtime.GOOS == "android" { + return net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2} + } + return net.IPv6zero +} diff --git a/internal/thirdparty/go-nat/pcp/gateway_discoverer_test.go b/internal/thirdparty/go-nat/pcp/gateway_discoverer_test.go new file mode 100644 index 0000000..23081f3 --- /dev/null +++ b/internal/thirdparty/go-nat/pcp/gateway_discoverer_test.go @@ -0,0 +1,154 @@ +package pcp + +import ( + "context" + "errors" + "net" + "runtime" + "testing" + + "github.com/google/gopacket/routing" +) + +type fakeRouter struct { + iface *net.Interface + gateway net.IP + src net.IP + err error + gotDst net.IP +} + +func (f *fakeRouter) Route(dst net.IP) (*net.Interface, net.IP, net.IP, error) { + f.gotDst = dst + return f.iface, f.gateway, f.src, f.err +} + +func (f *fakeRouter) RouteWithSrc(_ net.HardwareAddr, _, dst net.IP) (*net.Interface, net.IP, net.IP, error) { + return f.Route(dst) +} + +func stubRouter(t *testing.T, r routing.Router, err error) { + t.Helper() + orig := newRouter + newRouter = func() (routing.Router, error) { return r, err } + t.Cleanup(func() { newRouter = orig }) +} + +func TestDiscover(t *testing.T) { + gw := net.ParseIP("192.168.1.1") + local := net.ParseIP("192.168.1.100") + + tests := []struct { + name string + router *fakeRouter + newErr error + wantErr error // nil means success; errors.New sentinels checked by identity + anyErr bool // just expect some error + }{ + {name: "netroute init error", newErr: errors.New("no netlink"), anyErr: true}, + {name: "route error", router: &fakeRouter{err: errors.New("no route")}, anyErr: true}, + {name: "nil gateway", router: &fakeRouter{src: local}, wantErr: ErrNoNATFound}, + {name: "nil local IP", router: &fakeRouter{gateway: gw}, wantErr: ErrNoInternalAddress}, + {name: "success", router: &fakeRouter{gateway: gw, src: local}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stubRouter(t, tt.router, tt.newErr) + + gateway, localIP, err := (&defaultGatewayDiscoverer{}).Discover(context.Background()) + + if tt.anyErr || tt.wantErr != nil { + if err == nil { + t.Fatal("Discover() error = nil, want error") + } + if tt.wantErr != nil && !errors.Is(err, tt.wantErr) { + t.Fatalf("Discover() error = %v, want %v", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + if !gateway.Equal(gw) || !localIP.Equal(local) { + t.Fatalf("Discover() = (%v, %v), want (%v, %v)", gateway, localIP, gw, local) + } + }) + } +} + +// go-netroute's netlink lookup rejects an unspecified destination before it +// reaches the kernel, so Discover must not pass 0.0.0.0 or :: on Linux and +// Android. See defaultRouteProbe4. +func TestDiscoverLinuxDst(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("linux-specific destination workaround") + } + + r := &fakeRouter{gateway: net.ParseIP("192.168.1.1"), src: net.ParseIP("192.168.1.100")} + stubRouter(t, r, nil) + if _, _, err := (&defaultGatewayDiscoverer{}).Discover(context.Background()); err != nil { + t.Fatalf("Discover() error = %v", err) + } + if r.gotDst.IsUnspecified() { + t.Fatalf("Discover() passed unspecified dst %v to Route on linux", r.gotDst) + } + + r6 := &fakeRouter{gateway: net.ParseIP("2001:db8::1"), src: net.ParseIP("2001:db8::100")} + stubRouter(t, r6, nil) + if _, _, _, err := (&defaultGatewayDiscoverer{}).DiscoverV6(context.Background()); err != nil { + t.Fatalf("DiscoverV6() error = %v", err) + } + if r6.gotDst.IsUnspecified() { + t.Fatalf("DiscoverV6() passed unspecified dst %v to Route on linux", r6.gotDst) + } +} + +func TestDiscoverV6(t *testing.T) { + local := net.ParseIP("2001:db8::100") + eth0 := &net.Interface{Index: 1, Name: "eth0"} + + tests := []struct { + name string + router *fakeRouter + newErr error + wantErr error // nil means success; errors.New sentinels checked by identity + anyErr bool // just expect some error + wantZone string + }{ + {name: "netroute init error", newErr: errors.New("no netlink"), anyErr: true}, + {name: "route error", router: &fakeRouter{err: errors.New("no route")}, anyErr: true}, + {name: "nil gateway", router: &fakeRouter{src: local}, wantErr: ErrNoNATFound}, + {name: "nil local IP", router: &fakeRouter{gateway: net.ParseIP("fe80::1")}, wantErr: ErrNoInternalAddress}, + {name: "global gateway has no zone", router: &fakeRouter{gateway: net.ParseIP("2001:db8::1"), src: local, iface: eth0}}, + {name: "link-local gateway gets iface zone", router: &fakeRouter{gateway: net.ParseIP("fe80::1"), src: local, iface: eth0}, wantZone: "eth0"}, + {name: "link-local gateway with nil iface", router: &fakeRouter{gateway: net.ParseIP("fe80::1"), src: local}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stubRouter(t, tt.router, tt.newErr) + + gateway, localIP, zone, err := (&defaultGatewayDiscoverer{}).DiscoverV6(context.Background()) + + if tt.anyErr || tt.wantErr != nil { + if err == nil { + t.Fatal("DiscoverV6() error = nil, want error") + } + if tt.wantErr != nil && !errors.Is(err, tt.wantErr) { + t.Fatalf("DiscoverV6() error = %v, want %v", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("DiscoverV6() error = %v", err) + } + if !gateway.Equal(tt.router.gateway) || !localIP.Equal(local) { + t.Fatalf("DiscoverV6() = (%v, %v), want (%v, %v)", gateway, localIP, tt.router.gateway, local) + } + if zone != tt.wantZone { + t.Fatalf("DiscoverV6() zone = %q, want %q", zone, tt.wantZone) + } + }) + } +} diff --git a/internal/thirdparty/go-nat/pcp/nat.go b/internal/thirdparty/go-nat/pcp/nat.go new file mode 100644 index 0000000..66176e9 --- /dev/null +++ b/internal/thirdparty/go-nat/pcp/nat.go @@ -0,0 +1,367 @@ +package pcp + +import ( + "context" + "errors" + "fmt" + "net" + "sync" + "time" +) + +// externalAddressTimeout bounds GetExternalAddress, which the NAT interface +// gives no context of its own. +const externalAddressTimeout = 10 * time.Second + +// pinholeTimeout bounds every best-effort IPv6 pinhole operation. The IPv4 +// mapping is what the caller asked for, and it waits on the pinhole so the +// outcome can be reported, so an unresponsive IPv6 server must not hold it for +// the full RFC 6887 §8.1.1 mapping schedule. It is a variable so tests can +// shorten it. +var pinholeTimeout = 5 * time.Second + +var ( + // ErrNoNATFound is returned when no PCP-capable gateway can be found. + ErrNoNATFound = errors.New("no NAT found") + // ErrNoInternalAddress is returned when the local address for the gateway is unavailable. + ErrNoInternalAddress = errors.New("no internal address") + + // errPinholeRolledBack marks a pinhole that was opened and then closed + // again because the IPv4 mapping it accompanied failed. + errPinholeRolledBack = errors.New("rolled back after the IPv4 mapping failed") + // errPinholeLeftOpen marks a pinhole that the rollback failed to close, so + // it stays open until its lifetime expires. + errPinholeLeftOpen = errors.New("left open after the IPv4 mapping failed") +) + +type natInterface interface { + Type() string + GetDeviceAddress() (net.IP, error) + GetExternalAddress() (net.IP, error) + GetInternalAddress() (net.IP, error) + AddPortMapping(context.Context, string, int, string, time.Duration) (int, error) + DeletePortMapping(context.Context, string, int) error +} + +var _ natInterface = (*NAT)(nil) + +// NAT implements the go-nat NAT interface using PCP. +// It supports dual-stack (IPv4 and IPv6) when available. +// All methods are safe for concurrent use. +// +// TODO: IPv6 pinholes use the local IPv6 address. If the address changes +// (e.g. due to SLAAC rotation or network change), the pinhole becomes stale +// and needs to be recreated with the new address. +type NAT struct { + client *Client + client6 *Client + + mu sync.Mutex + pinholeErr error +} + +// NewNAT creates a new NAT instance backed by PCP. +func NewNAT(gateway, localIP net.IP) *NAT { + client := NewClient(gateway) + client.SetLocalIP(localIP) + return &NAT{ + client: client, + } +} + +// Type returns "PCP" as the NAT type. +func (n *NAT) Type() string { + return "PCP" +} + +// primary returns the IPv4 client when present, falling back to the IPv6 +// client for v6-only discovery. Discovery always leaves at least one of the +// two set, so a nil result means the NAT was built by hand. +func (n *NAT) primary() (*Client, error) { + if n.client != nil { + return n.client, nil + } + if n.client6 != nil { + return n.client6, nil + } + return nil, ErrNoNATFound +} + +// GetDeviceAddress returns the gateway IP address. +func (n *NAT) GetDeviceAddress() (net.IP, error) { + client, err := n.primary() + if err != nil { + return nil, err + } + return client.Gateway(), nil +} + +// GetExternalAddress returns the external IP address. +func (n *NAT) GetExternalAddress() (net.IP, error) { + client, err := n.primary() + if err != nil { + return nil, err + } + + ctx, cancel := context.WithTimeout(context.Background(), externalAddressTimeout) + defer cancel() + return client.GetExternalAddress(ctx) +} + +// GetInternalAddress returns the local IP address used to communicate with the gateway. +func (n *NAT) GetInternalAddress() (net.IP, error) { + client, err := n.primary() + if err != nil { + return nil, err + } + + addr, err := client.getLocalIP() + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrNoInternalAddress, err) + } + return append(net.IP(nil), addr.AsSlice()...), nil +} + +// IPv6PinholeError returns the error from the most recent IPv6 pinhole +// operation, or nil when it succeeded. Pinholes are best effort and never fail +// AddPortMapping or DeletePortMapping on their own, so this is the only way to +// find out whether one was actually opened. +func (n *NAT) IPv6PinholeError() error { + n.mu.Lock() + defer n.mu.Unlock() + return n.pinholeErr +} + +func (n *NAT) setPinholeErr(err error) { + if err != nil { + err = fmt.Errorf("pcp ipv6: %w", err) + } + n.mu.Lock() + defer n.mu.Unlock() + n.pinholeErr = err +} + +// AddPortMapping creates a port mapping on IPv4 and, when an IPv6 gateway was +// also discovered, a firewall pinhole on IPv6. The pinhole complements the +// IPv4 mapping rather than replacing it, because a peer reaching this host may +// have no IPv6 connectivity at all, so its failure does not fail the call. +func (n *NAT) AddPortMapping(ctx context.Context, protocol string, internalPort int, _ string, timeout time.Duration) (int, error) { + if n.client == nil { + resp, err := n.client6.AddPortMapping(ctx, protocol, internalPort, timeout) + n.setPinholeErr(err) + if err != nil { + return 0, fmt.Errorf("add mapping: %w", err) + } + return int(resp.ExternalPort), nil + } + + client6 := n.client6 + + var client6Done chan struct{} + var err6 error + if client6 != nil { + client6Done = make(chan struct{}) + go func() { + defer close(client6Done) + pinholeCtx, cancel := context.WithTimeout(ctx, pinholeTimeout) + defer cancel() + _, err6 = client6.AddPortMapping(pinholeCtx, protocol, internalPort, timeout) + }() + } + + resp, err := n.client.AddPortMapping(ctx, protocol, internalPort, timeout) + if client6Done != nil { + <-client6Done + } + if err != nil { + if err6 == nil && client6 != nil { + err6 = errPinholeRolledBack + if rollbackErr := rollbackPinhole(ctx, client6, protocol, internalPort); rollbackErr != nil { + err6 = fmt.Errorf("%w: %w", errPinholeLeftOpen, rollbackErr) + } + } + n.setPinholeErr(err6) + return 0, fmt.Errorf("add mapping: %w", err) + } + + n.setPinholeErr(err6) + return int(resp.ExternalPort), nil +} + +// rollbackPinhole closes an IPv6 pinhole opened alongside a failed IPv4 +// mapping: the caller sees a failed mapping and will never delete it, so the +// pinhole would leak until its lifetime expires. Detached from ctx because the +// IPv4 failure may be the caller's deadline expiring. +func rollbackPinhole(ctx context.Context, client6 *Client, protocol string, internalPort int) error { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), pinholeTimeout) + defer cancel() + return client6.DeletePortMapping(ctx, protocol, internalPort) +} + +// DeletePortMapping removes the IPv4 mapping and, when present, the IPv6 +// pinhole. As with AddPortMapping, a pinhole failure is reported through +// IPv6PinholeError rather than failing the call: the IPv4 mapping the caller +// asked about is gone either way, and the pinhole expires on its own. +func (n *NAT) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error { + if n.client == nil { + err := n.client6.DeletePortMapping(ctx, protocol, internalPort) + n.setPinholeErr(err) + if err != nil { + return fmt.Errorf("delete mapping: %w", err) + } + return nil + } + + var client6Done chan struct{} + var err6 error + if n.client6 != nil { + client6Done = make(chan struct{}) + go func() { + defer close(client6Done) + pinholeCtx, cancel := context.WithTimeout(ctx, pinholeTimeout) + defer cancel() + err6 = n.client6.DeletePortMapping(pinholeCtx, protocol, internalPort) + }() + } + + err := n.client.DeletePortMapping(ctx, protocol, internalPort) + if client6Done != nil { + <-client6Done + } + + n.setPinholeErr(err6) + if err != nil { + return fmt.Errorf("delete mapping: %w", err) + } + return nil +} + +// CheckServerHealth sends an ANNOUNCE to verify the servers are still +// responsive. It reports the primary server's epoch and whether either stack +// lost state, since an IPv6 restart drops the pinhole even when IPv4 is +// healthy. An error is returned only when neither stack could be reached: one +// unreachable server is not evidence that it restarted, and reporting a +// restart would recreate a mapping that is very likely fine. +func (n *NAT) CheckServerHealth(ctx context.Context) (epoch uint32, serverRestarted bool, err error) { + client, err := n.primary() + if err != nil { + return 0, false, err + } + + dualStack := n.client6 != nil && n.client6 != client + + // Bounded and concurrent: announced in series on the caller's context, an + // unresponsive IPv6 server runs the full retry schedule and leaves nothing + // for the IPv4 gateway, which disables health checking altogether. + var v6Done chan struct{} + var epoch6 uint32 + var err6 error + if dualStack { + v6Done = make(chan struct{}) + go func() { + defer close(v6Done) + pinholeCtx, cancel := context.WithTimeout(ctx, pinholeTimeout) + defer cancel() + epoch6, err6 = n.client6.Announce(pinholeCtx) + }() + } + + epoch, err = client.Announce(ctx) + restarted := err == nil && client.EpochStateLost() + + if !dualStack { + if err != nil { + return 0, false, fmt.Errorf("announce: %w", err) + } + return epoch, restarted, nil + } + + <-v6Done + restarted6 := err6 == nil && n.client6.EpochStateLost() + + switch { + case err != nil && err6 != nil: + return 0, false, fmt.Errorf("announce: %w; pcp ipv6: %w", err, err6) + case err != nil: + return epoch6, restarted6, nil + } + return epoch, restarted || restarted6, nil +} + +// DiscoverPCP attempts to discover PCP-capable IPv4 and IPv6 gateways +// independently. It fails only when neither is found, so a v6-only network +// still yields a NAT that can open firewall pinholes. +func DiscoverPCP(ctx context.Context) (*NAT, error) { + return discoverPCP(ctx, &defaultGatewayDiscoverer{}) +} + +func discoverPCP(ctx context.Context, gds gatewayDiscoverer) (*NAT, error) { + type v6Result struct { + client *Client + err error + } + v6Ch := make(chan v6Result, 1) + go func() { + client, err := discoverPCPIPv6(ctx, gds) + v6Ch <- v6Result{client, err} + }() + + result, v4Err := discoverPCPIPv4(ctx, gds) + v6 := <-v6Ch + + if v4Err != nil && v6.err != nil { + return nil, errors.Join(v4Err, v6.err) + } + if result == nil { + result = &NAT{} + } + if v6.err == nil { + result.client6 = v6.client + } + return result, nil +} + +// DiscoverPCPIPv4 discovers a PCP-capable IPv4 gateway. +func DiscoverPCPIPv4(ctx context.Context) (*NAT, error) { + return discoverPCPIPv4(ctx, &defaultGatewayDiscoverer{}) +} + +func discoverPCPIPv4(ctx context.Context, gds gatewayDiscoverer) (*NAT, error) { + gateway, localIP, err := gds.Discover(ctx) + if err != nil { + return nil, fmt.Errorf("get default gateway: %w", err) + } + + result := NewNAT(gateway, localIP) + if _, err := result.client.Announce(ctx); err != nil { + return nil, fmt.Errorf("PCP announce: %w", err) + } + return result, nil +} + +// DiscoverPCPIPv6 discovers a PCP-capable IPv6 gateway for firewall pinholes. +func DiscoverPCPIPv6(ctx context.Context) (*Client, error) { + return discoverPCPIPv6(ctx, &defaultGatewayDiscoverer{}) +} + +func discoverPCPIPv6(ctx context.Context, gds gatewayDiscoverer) (*Client, error) { + gateway, localIP, zone, err := gds.DiscoverV6(ctx) + if err != nil { + return nil, fmt.Errorf("get default IPv6 gateway: %w", err) + } + + client := NewClient(gateway) + if zone != "" { + client.gateway = client.gateway.WithZone(zone) + } + client.SetLocalIP(localIP) + if _, err := client.Announce(ctx); err != nil { + return nil, fmt.Errorf("PCP IPv6 announce: %w", err) + } + return client, nil +} + +// Discover is an alias for DiscoverPCP. +func Discover(ctx context.Context) (*NAT, error) { + return discoverPCP(ctx, &defaultGatewayDiscoverer{}) +} diff --git a/internal/thirdparty/go-nat/pcp/nat_test.go b/internal/thirdparty/go-nat/pcp/nat_test.go new file mode 100644 index 0000000..5e51be8 --- /dev/null +++ b/internal/thirdparty/go-nat/pcp/nat_test.go @@ -0,0 +1,216 @@ +package pcp + +import ( + "context" + "errors" + "net" + "testing" + "time" +) + +type fakeGatewayDiscoverer struct{} + +func (f *fakeGatewayDiscoverer) Discover(ctx context.Context) (net.IP, net.IP, error) { + return nil, nil, errors.New("no IPv4 route") +} +func (f *fakeGatewayDiscoverer) DiscoverV6(ctx context.Context) (net.IP, net.IP, string, error) { + return net.ParseIP("::1"), net.ParseIP("::1"), "", nil +} + +// A v6 pinhole opened alongside a failed v4 mapping must be rolled back: the +// caller treats the whole mapping as failed and never deletes it. +func TestAddPortMappingRollsBackIPv6OnIPv4Failure(t *testing.T) { + server4 := newFakePCPServer(t, "udp4", "127.0.0.1:5351") + server6 := newFakePCPServer(t, "udp6", "[::1]:5351") + + // Seed the v4 mapping with a foreign nonce so the v4 MAP fails fast with + // NOT_AUTHORIZED while the v6 pinhole succeeds. + server4.mu.Lock() + server4.mappings[mappingKey{proto: ProtoTCP, port: 1234}] = [12]byte{1} + server4.mu.Unlock() + + client := NewClient(net.ParseIP("127.0.0.1")) + client.SetLocalIP(net.ParseIP("127.0.0.1")) + client6 := NewClient(net.ParseIP("::1")) + client6.SetLocalIP(net.ParseIP("::1")) + nat := &NAT{client: client, client6: client6} + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if _, err := nat.AddPortMapping(ctx, "tcp", 1234, "", time.Minute); err == nil { + t.Fatal("AddPortMapping() error = nil, want v4 NOT_AUTHORIZED") + } + if server6.hasMapping(ProtoTCP, 1234) { + t.Fatal("v6 pinhole still open after failed v4 mapping") + } + if err := nat.IPv6PinholeError(); !errors.Is(err, errPinholeRolledBack) { + t.Fatalf("IPv6PinholeError() = %v, want it to report the rollback", err) + } +} + +// When the rollback itself fails the pinhole stays open, so reporting that it +// was closed would be a lie the caller cannot check. +func TestAddPortMappingReportsPinholeLeftOpen(t *testing.T) { + server4 := newFakePCPServer(t, "udp4", "127.0.0.1:5351") + server6 := newFakePCPServer(t, "udp6", "[::1]:5351") + + // A foreign nonce fails the v4 MAP with NOT_AUTHORIZED. The v6 pinhole + // opens, but the server then refuses to release it. + server4.mu.Lock() + server4.mappings[mappingKey{proto: ProtoTCP, port: 1234}] = [12]byte{1} + server4.mu.Unlock() + server6.mu.Lock() + server6.refuseDeletes = true + server6.mu.Unlock() + + client := NewClient(net.ParseIP("127.0.0.1")) + client.SetLocalIP(net.ParseIP("127.0.0.1")) + client6 := NewClient(net.ParseIP("::1")) + client6.SetLocalIP(net.ParseIP("::1")) + nat := &NAT{client: client, client6: client6} + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if _, err := nat.AddPortMapping(ctx, "tcp", 1234, "", time.Minute); err == nil { + t.Fatal("AddPortMapping() error = nil, want v4 NOT_AUTHORIZED") + } + + pinholeErr := nat.IPv6PinholeError() + if !errors.Is(pinholeErr, errPinholeLeftOpen) { + t.Fatalf("IPv6PinholeError() = %v, want it to report the pinhole left open", pinholeErr) + } + var pcpErr *Error + if !errors.As(pinholeErr, &pcpErr) || pcpErr.Code != ResultNoResources { + t.Fatalf("IPv6PinholeError() = %v, want it to carry the delete failure", pinholeErr) + } + if !server6.hasMapping(ProtoTCP, 1234) { + t.Fatal("fake server released a pinhole it refused to delete") + } +} + +// A failing v6 pinhole must not fail the call, since the IPv4 mapping the +// caller asked about is fine, but it must not be silently dropped either: the +// pinhole stays open and the caller needs to be able to see why. +func TestDeletePortMappingReportsIPv6Error(t *testing.T) { + newFakePCPServer(t, "udp4", "127.0.0.1:5351") + server6 := newFakePCPServer(t, "udp6", "[::1]:5351") + + // A pinhole whose nonce the client does not hold: the delete's fresh + // nonce is answered with NOT_AUTHORIZED. + server6.mu.Lock() + server6.mappings[mappingKey{proto: ProtoTCP, port: 4321}] = [12]byte{1} + server6.mu.Unlock() + + client := NewClient(net.ParseIP("127.0.0.1")) + client.SetLocalIP(net.ParseIP("127.0.0.1")) + client6 := NewClient(net.ParseIP("::1")) + client6.SetLocalIP(net.ParseIP("::1")) + nat := &NAT{client: client, client6: client6} + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := nat.DeletePortMapping(ctx, "tcp", 4321); err != nil { + t.Fatalf("DeletePortMapping() error = %v, want nil: an IPv6 pinhole does not fail the call", err) + } + + pinholeErr := nat.IPv6PinholeError() + if pinholeErr == nil { + t.Fatal("IPv6PinholeError() = nil, want the v6 NOT_AUTHORIZED") + } + var pcpErr *Error + if !errors.As(pinholeErr, &pcpErr) || pcpErr.Code != ResultNotAuthorized { + t.Fatalf("IPv6PinholeError() = %v, want NOT_AUTHORIZED", pinholeErr) + } + if !server6.hasMapping(ProtoTCP, 4321) { + t.Fatal("fake server dropped a mapping it answered with NOT_AUTHORIZED") + } +} + +// A dual-stack health check must announce on both servers, so that an IPv6 +// restart is caught even when the IPv4 gateway is healthy. +func TestCheckServerHealthAnnouncesBothStacks(t *testing.T) { + newFakePCPServer(t, "udp4", "127.0.0.1:5351") + newFakePCPServer(t, "udp6", "[::1]:5351") + + client := NewClient(net.ParseIP("127.0.0.1")) + client.SetLocalIP(net.ParseIP("127.0.0.1")) + client6 := NewClient(net.ParseIP("::1")) + client6.SetLocalIP(net.ParseIP("::1")) + nat := &NAT{client: client, client6: client6} + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + epoch, restarted, err := nat.CheckServerHealth(ctx) + if err != nil { + t.Fatal(err) + } + if epoch == 0 { + t.Fatal("CheckServerHealth() epoch = 0, want the IPv4 server's epoch") + } + if restarted { + t.Fatal("CheckServerHealth() reported a restart on a first announce") + } + if client6.LastEpoch() == 0 { + t.Fatal("IPv6 server was never announced to") + } +} + +// A successful IPv4 mapping must not wait out an unresponsive IPv6 server. +// Unbounded, the pinhole runs the full RFC 6887 §8.1.1 schedule and holds the +// mapping the caller actually asked for behind it for about 30 seconds. +func TestAddPortMappingDoesNotWaitOutAnUnresponsiveIPv6Server(t *testing.T) { + newFakePCPServer(t, "udp4", "127.0.0.1:5351") + server6 := newFakePCPServer(t, "udp6", "[::1]:5351") + server6.mu.Lock() + server6.silent = true + server6.mu.Unlock() + + restore := pinholeTimeout + pinholeTimeout = 50 * time.Millisecond + t.Cleanup(func() { pinholeTimeout = restore }) + + client := NewClient(net.ParseIP("127.0.0.1")) + client.SetLocalIP(net.ParseIP("127.0.0.1")) + client6 := NewClient(net.ParseIP("::1")) + client6.SetLocalIP(net.ParseIP("::1")) + nat := &NAT{client: client, client6: client6} + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + if _, err := nat.AddPortMapping(ctx, "tcp", 1234, "", time.Minute); err != nil { + t.Errorf("AddPortMapping() error = %v, want the IPv4 mapping to succeed", err) + } + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("AddPortMapping is still waiting on the IPv6 pinhole") + } + + if err := nat.IPv6PinholeError(); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("IPv6PinholeError() = %v, want the pinhole deadline", err) + } +} + +// A PCP-capable IPv6 gateway must be usable even when IPv4 discovery fails: +// v6-only networks still need firewall pinholes. +func TestDiscoverPCPIPv6WithoutIPv4(t *testing.T) { + newFakePCPServer(t, "udp6", "[::1]:5351") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + nat, err := discoverPCP(ctx, &fakeGatewayDiscoverer{}) + if err != nil { + t.Fatalf("DiscoverPCP() error = %v, want IPv6-only NAT when IPv4 discovery fails", err) + } + if nat.client6 == nil { + t.Fatal("DiscoverPCP() NAT has no IPv6 client despite a working IPv6 gateway") + } +} diff --git a/internal/thirdparty/go-nat/pcp/protocol.go b/internal/thirdparty/go-nat/pcp/protocol.go new file mode 100644 index 0000000..2ced945 --- /dev/null +++ b/internal/thirdparty/go-nat/pcp/protocol.go @@ -0,0 +1,229 @@ +// Package pcp implements the Port Control Protocol (RFC 6887). +// +// # Implemented Features +// +// - ANNOUNCE opcode: Discovers PCP server support +// - MAP opcode: Creates/deletes port mappings (IPv4 NAT) and firewall pinholes (IPv6) +// - Dual-stack: Simultaneous IPv4 and IPv6 support via separate clients +// - Nonce validation: Prevents response spoofing +// - Epoch tracking: Detects server restarts per Section 8.5 +// - RFC-compliant retry timing: 3s initial, exponential backoff to 1024s max (Section 8.1.1) +// +// # Not Implemented +// +// - PEER opcode: For outbound peer connections (not needed for inbound NAT traversal) +// - THIRD_PARTY option: For managing mappings on behalf of other devices +// - PREFER_FAILURE option: Requires exact external port or fail +// - FILTER option: To restrict remote peer addresses +package pcp + +import ( + "encoding/binary" + "fmt" + "net/netip" +) + +const ( + // Version is the PCP protocol version (RFC 6887). + Version = 2 + + // Port is the standard PCP server port. + Port = 5351 + + // DefaultLifetime is the default requested mapping lifetime in seconds. + DefaultLifetime = 7200 // 2 hours + + // Header sizes. + headerSize = 24 + mapPayloadSize = 36 + mapRequestSize = headerSize + mapPayloadSize // 60 bytes +) + +// Opcodes. +const ( + OpAnnounce = 0 + OpMap = 1 + OpPeer = 2 + OpReply = 0x80 // OR'd with opcode in responses. +) + +// Protocol numbers for MAP requests. +const ( + ProtoUDP = 17 + ProtoTCP = 6 +) + +// Result codes (RFC 6887 Section 7.4). +const ( + ResultSuccess = 0 + ResultUnsuppVersion = 1 + ResultNotAuthorized = 2 + ResultMalformedRequest = 3 + ResultUnsuppOpcode = 4 + ResultUnsuppOption = 5 + ResultMalformedOption = 6 + ResultNetworkFailure = 7 + ResultNoResources = 8 + ResultUnsuppProtocol = 9 + ResultUserExQuota = 10 + ResultCannotProvideExt = 11 + ResultAddressMismatch = 12 + ResultExcessiveRemotePeers = 13 +) + +// ResultCodeString returns a human-readable string for a result code. +func ResultCodeString(code uint8) string { + switch code { + case ResultSuccess: + return "SUCCESS" + case ResultUnsuppVersion: + return "UNSUPP_VERSION" + case ResultNotAuthorized: + return "NOT_AUTHORIZED" + case ResultMalformedRequest: + return "MALFORMED_REQUEST" + case ResultUnsuppOpcode: + return "UNSUPP_OPCODE" + case ResultUnsuppOption: + return "UNSUPP_OPTION" + case ResultMalformedOption: + return "MALFORMED_OPTION" + case ResultNetworkFailure: + return "NETWORK_FAILURE" + case ResultNoResources: + return "NO_RESOURCES" + case ResultUnsuppProtocol: + return "UNSUPP_PROTOCOL" + case ResultUserExQuota: + return "USER_EX_QUOTA" + case ResultCannotProvideExt: + return "CANNOT_PROVIDE_EXTERNAL" + case ResultAddressMismatch: + return "ADDRESS_MISMATCH" + case ResultExcessiveRemotePeers: + return "EXCESSIVE_REMOTE_PEERS" + default: + return fmt.Sprintf("UNKNOWN(%d)", code) + } +} + +// Response represents a parsed PCP response header. +type Response struct { + Version uint8 + Opcode uint8 + ResultCode uint8 + Lifetime uint32 + Epoch uint32 +} + +// MapResponse contains the full response to a MAP request. +type MapResponse struct { + Response + Nonce [12]byte + Protocol uint8 + InternalPort uint16 + ExternalPort uint16 + ExternalIP netip.Addr +} + +// addrTo16 converts an address to its 16-byte IPv4-mapped IPv6 representation. +func addrTo16(addr netip.Addr) [16]byte { + if addr.Is4() { + return netip.AddrFrom4(addr.As4()).As16() + } + return addr.As16() +} + +// addrFrom16 extracts an address from a 16-byte representation, unmapping IPv4. +func addrFrom16(b [16]byte) netip.Addr { + return netip.AddrFrom16(b).Unmap() +} + +// buildAnnounceRequest creates a PCP ANNOUNCE request packet. +func buildAnnounceRequest(clientIP netip.Addr) []byte { + req := make([]byte, headerSize) + req[0] = Version + req[1] = OpAnnounce + mapped := addrTo16(clientIP) + copy(req[8:24], mapped[:]) + return req +} + +// buildMapRequest creates a PCP MAP request packet. +func buildMapRequest(clientIP netip.Addr, nonce [12]byte, protocol uint8, internalPort, suggestedExtPort uint16, suggestedExtIP netip.Addr, lifetime uint32) []byte { + req := make([]byte, mapRequestSize) + + // Header. + req[0] = Version + req[1] = OpMap + binary.BigEndian.PutUint32(req[4:8], lifetime) + mapped := addrTo16(clientIP) + copy(req[8:24], mapped[:]) + + // MAP payload. + copy(req[24:36], nonce[:]) + req[36] = protocol + binary.BigEndian.PutUint16(req[40:42], internalPort) + binary.BigEndian.PutUint16(req[42:44], suggestedExtPort) + if !suggestedExtIP.IsValid() { + // RFC 6887 §11.1: no preference is the address-family all-zeros + // address, which for IPv4 is ::ffff:0.0.0.0 (§5), not ::. + if clientIP.Is4() || clientIP.Is4In6() { + suggestedExtIP = netip.IPv4Unspecified() + } else { + suggestedExtIP = netip.IPv6Unspecified() + } + } + extMapped := addrTo16(suggestedExtIP) + copy(req[44:60], extMapped[:]) + + return req +} + +// parseResponse parses the common PCP response header. +func parseResponse(data []byte) (*Response, error) { + if len(data) < headerSize { + return nil, fmt.Errorf("response too short: %d bytes", len(data)) + } + + resp := &Response{ + Version: data[0], + Opcode: data[1], + ResultCode: data[3], // Byte 2 is reserved, byte 3 is result code (RFC 6887 §7.2). + Lifetime: binary.BigEndian.Uint32(data[4:8]), + Epoch: binary.BigEndian.Uint32(data[8:12]), + } + + if resp.Version != Version { + return nil, fmt.Errorf("unsupported PCP version: %d", resp.Version) + } + + if resp.Opcode&OpReply == 0 { + return nil, fmt.Errorf("response missing reply bit: opcode=0x%02x", resp.Opcode) + } + + return resp, nil +} + +// parseMapResponse parses a complete MAP response. +func parseMapResponse(data []byte) (*MapResponse, error) { + if len(data) < mapRequestSize { + return nil, fmt.Errorf("MAP response too short: %d bytes", len(data)) + } + + resp, err := parseResponse(data) + if err != nil { + return nil, fmt.Errorf("parse header: %w", err) + } + + mapResp := &MapResponse{ + Response: *resp, + Protocol: data[36], + InternalPort: binary.BigEndian.Uint16(data[40:42]), + ExternalPort: binary.BigEndian.Uint16(data[42:44]), + ExternalIP: addrFrom16([16]byte(data[44:60])), + } + copy(mapResp.Nonce[:], data[24:36]) + + return mapResp, nil +} diff --git a/internal/thirdparty/go-nat/pcp/protocol_test.go b/internal/thirdparty/go-nat/pcp/protocol_test.go new file mode 100644 index 0000000..d6ae31b --- /dev/null +++ b/internal/thirdparty/go-nat/pcp/protocol_test.go @@ -0,0 +1,353 @@ +package pcp + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "net/netip" + "strings" + "testing" +) + +var testNonce = [12]byte{0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08} + +func TestBuildMapRequestWireFormat(t *testing.T) { + tests := []struct { + name string + clientIP string + protocol uint8 + internalPort uint16 + suggestedExtPort uint16 + suggestedExtIP string // empty = no preference + lifetime uint32 + want []byte + }{ + { + name: "IPv4 mapping with suggested external", + clientIP: "192.168.1.10", + protocol: ProtoUDP, + internalPort: 8080, + suggestedExtPort: 9090, + suggestedExtIP: "203.0.113.9", + lifetime: 7200, + want: []byte{ + // Version, opcode MAP, reserved. + 0x02, 0x01, 0x00, 0x00, + // Requested lifetime: 7200s. + 0x00, 0x00, 0x1c, 0x20, + // Client IP: ::ffff:192.168.1.10. + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xff, 0xff, 0xc0, 0xa8, 0x01, 0x0a, + // Mapping nonce. + 0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + // Protocol UDP, reserved. + 0x11, 0x00, 0x00, 0x00, + // Internal port 8080, suggested external port 9090. + 0x1f, 0x90, 0x23, 0x82, + // Suggested external IP: ::ffff:203.0.113.9. + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xff, 0xff, 0xcb, 0x00, 0x71, 0x09, + }, + }, + { + name: "IPv4 delete with no external preference", + clientIP: "192.168.1.10", + protocol: ProtoUDP, + internalPort: 8080, + lifetime: 0, + want: []byte{ + // Version, opcode MAP, reserved. + 0x02, 0x01, 0x00, 0x00, + // Requested lifetime: 0 (delete). + 0x00, 0x00, 0x00, 0x00, + // Client IP: ::ffff:192.168.1.10. + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xff, 0xff, 0xc0, 0xa8, 0x01, 0x0a, + // Mapping nonce. + 0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + // Protocol UDP, reserved. + 0x11, 0x00, 0x00, 0x00, + // Internal port 8080, suggested external port 0. + 0x1f, 0x90, 0x00, 0x00, + // Suggested external IP. RFC 6887 §11.1 + §5: no preference for an + // IPv4 mapping is the all-zeros IPv4-mapped address ::ffff:0.0.0.0, + // not the IPv6 unspecified address ::. + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, + }, + }, + { + name: "IPv6 pinhole", + clientIP: "2001:db8::1", + protocol: ProtoTCP, + internalPort: 4242, + suggestedExtPort: 4242, + lifetime: 300, + want: []byte{ + // Version, opcode MAP, reserved. + 0x02, 0x01, 0x00, 0x00, + // Requested lifetime: 300s. + 0x00, 0x00, 0x01, 0x2c, + // Client IP: 2001:db8::1. + 0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + // Mapping nonce. + 0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + // Protocol TCP, reserved. + 0x06, 0x00, 0x00, 0x00, + // Internal port 4242, suggested external port 4242. + 0x10, 0x92, 0x10, 0x92, + // Suggested external IP: no preference for IPv6 is ::. + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var extIP netip.Addr + if tt.suggestedExtIP != "" { + extIP = netip.MustParseAddr(tt.suggestedExtIP) + } + got := buildMapRequest(netip.MustParseAddr(tt.clientIP), testNonce, tt.protocol, + tt.internalPort, tt.suggestedExtPort, extIP, tt.lifetime) + if !bytes.Equal(got, tt.want) { + t.Errorf("buildMapRequest() =\n%swant:\n%s", hex.Dump(got), hex.Dump(tt.want)) + } + }) + } +} + +func TestParseMapResponseWireFormat(t *testing.T) { + tests := []struct { + name string + data []byte + want MapResponse + }{ + { + name: "IPv4 mapping response", + data: []byte{ + // Version, MAP reply, reserved, result SUCCESS. + 0x02, 0x81, 0x00, 0x00, + // Lifetime: 7200s. + 0x00, 0x00, 0x1c, 0x20, + // Epoch: 1000. + 0x00, 0x00, 0x03, 0xe8, + // Reserved. + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // Mapping nonce. + 0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + // Protocol UDP, reserved. + 0x11, 0x00, 0x00, 0x00, + // Internal port 8080, assigned external port 9090. + 0x1f, 0x90, 0x23, 0x82, + // Assigned external IP: ::ffff:203.0.113.9. + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xff, 0xff, 0xcb, 0x00, 0x71, 0x09, + }, + want: MapResponse{ + Response: Response{ + Version: Version, + Opcode: OpMap | OpReply, + ResultCode: ResultSuccess, + Lifetime: 7200, + Epoch: 1000, + }, + Nonce: testNonce, + Protocol: ProtoUDP, + InternalPort: 8080, + ExternalPort: 9090, + ExternalIP: netip.MustParseAddr("203.0.113.9"), + }, + }, + { + name: "IPv6 pinhole response", + data: []byte{ + // Version, MAP reply, reserved, result SUCCESS. + 0x02, 0x81, 0x00, 0x00, + // Lifetime: 300s. + 0x00, 0x00, 0x01, 0x2c, + // Epoch: 1000. + 0x00, 0x00, 0x03, 0xe8, + // Reserved. + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // Mapping nonce. + 0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + // Protocol TCP, reserved. + 0x06, 0x00, 0x00, 0x00, + // Internal port 4242, assigned external port 4242. + 0x10, 0x92, 0x10, 0x92, + // Assigned external IP: 2001:db8::1. + 0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + }, + want: MapResponse{ + Response: Response{ + Version: Version, + Opcode: OpMap | OpReply, + ResultCode: ResultSuccess, + Lifetime: 300, + Epoch: 1000, + }, + Nonce: testNonce, + Protocol: ProtoTCP, + InternalPort: 4242, + ExternalPort: 4242, + ExternalIP: netip.MustParseAddr("2001:db8::1"), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseMapResponse(tt.data) + if err != nil { + t.Fatalf("parseMapResponse() error = %v", err) + } + if *got != tt.want { + t.Errorf("parseMapResponse() = %+v, want %+v", *got, tt.want) + } + }) + } +} + +func TestParseMapResponseRejectsMalformed(t *testing.T) { + valid := func() []byte { + resp := make([]byte, mapRequestSize) + resp[0] = Version + resp[1] = OpMap | OpReply + return resp + } + + tests := []struct { + name string + data []byte + }{ + {name: "empty", data: nil}, + {name: "header only", data: valid()[:headerSize]}, + {name: "one byte short", data: valid()[:mapRequestSize-1]}, + {name: "unsupported version", data: func() []byte { r := valid(); r[0] = Version + 1; return r }()}, + {name: "request, not a reply", data: func() []byte { r := valid(); r[1] = OpMap; return r }()}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := parseMapResponse(tt.data); err == nil { + t.Fatal("parseMapResponse() error = nil, want a rejection") + } + }) + } +} + +func TestAddrRoundTrip(t *testing.T) { + tests := []string{"192.168.1.100", "127.0.0.1", "2001:db8::1", "::1"} + for _, addr := range tests { + t.Run(addr, func(t *testing.T) { + want := netip.MustParseAddr(addr) + if got := addrFrom16(addrTo16(want)); got != want { + t.Fatalf("round trip = %v, want %v", got, want) + } + }) + } +} + +func TestBuildAnnounceRequestWireFormat(t *testing.T) { + req := buildAnnounceRequest(netip.MustParseAddr("192.168.1.100")) + + if len(req) != headerSize { + t.Fatalf("length = %d, want %d", len(req), headerSize) + } + if req[0] != Version { + t.Errorf("version = %d, want %d", req[0], Version) + } + if req[1] != OpAnnounce { + t.Errorf("opcode = %d, want %d", req[1], OpAnnounce) + } + // RFC 6887 §5: the client address is carried as an IPv4-mapped IPv6 address. + want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 192, 168, 1, 100} + if !bytes.Equal(req[8:24], want) { + t.Errorf("client address = % x, want % x", req[8:24], want) + } +} + +func TestParseResponseRejectsMalformed(t *testing.T) { + valid := func() []byte { + resp := make([]byte, headerSize) + resp[0] = Version + resp[1] = OpAnnounce | OpReply + binary.BigEndian.PutUint32(resp[8:12], 12345) + return resp + } + + t.Run("valid", func(t *testing.T) { + parsed, err := parseResponse(valid()) + if err != nil { + t.Fatal(err) + } + if parsed.ResultCode != ResultSuccess { + t.Errorf("result code = %d, want %d", parsed.ResultCode, ResultSuccess) + } + if parsed.Epoch != 12345 { + t.Errorf("epoch = %d, want 12345", parsed.Epoch) + } + }) + + tests := []struct { + name string + resp []byte + }{ + {name: "too short", resp: []byte{1, 2, 3}}, + {name: "unsupported version", resp: func() []byte { r := valid(); r[0] = Version + 1; return r }()}, + {name: "request, not a reply", resp: func() []byte { r := valid(); r[1] = OpAnnounce; return r }()}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := parseResponse(tt.resp); err == nil { + t.Fatal("parseResponse() error = nil, want a rejection") + } + }) + } +} + +func TestResultCodeString(t *testing.T) { + tests := map[uint8]string{ + ResultSuccess: "SUCCESS", + ResultNotAuthorized: "NOT_AUTHORIZED", + ResultAddressMismatch: "ADDRESS_MISMATCH", + } + for code, want := range tests { + if got := ResultCodeString(code); got != want { + t.Errorf("ResultCodeString(%d) = %q, want %q", code, got, want) + } + } + if got := ResultCodeString(255); !strings.Contains(got, "UNKNOWN") { + t.Errorf("ResultCodeString(255) = %q, want it to mention UNKNOWN", got) + } +} + +func TestProtocolNumber(t *testing.T) { + tests := []struct { + protocol string + want uint8 + wantErr bool + }{ + {protocol: "udp", want: ProtoUDP}, + {protocol: "UDP", want: ProtoUDP}, + {protocol: "tcp", want: ProtoTCP}, + {protocol: "TCP", want: ProtoTCP}, + {protocol: "icmp", wantErr: true}, + {protocol: "", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.protocol, func(t *testing.T) { + got, err := protocolNumber(tt.protocol) + if (err != nil) != tt.wantErr { + t.Fatalf("protocolNumber(%q) error = %v, wantErr %v", tt.protocol, err, tt.wantErr) + } + if !tt.wantErr && got != tt.want { + t.Fatalf("protocolNumber(%q) = %d, want %d", tt.protocol, got, tt.want) + } + }) + } +} diff --git a/internal/thirdparty/go-nat/pcp_ipv6.go b/internal/thirdparty/go-nat/pcp_ipv6.go new file mode 100644 index 0000000..d90102c --- /dev/null +++ b/internal/thirdparty/go-nat/pcp_ipv6.go @@ -0,0 +1,180 @@ +package nat + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/netbirdio/go-nat/pcp" +) + +// pinholeTimeout bounds every best-effort IPv6 pinhole operation. The IPv4 +// mapping is what the caller asked for, and it waits on the pinhole so the +// outcome can be reported, so an unresponsive IPv6 server must not hold it for +// the full RFC 6887 mapping retry schedule. It is a variable so tests can +// shorten it. +var pinholeTimeout = 5 * time.Second + +var ( + // errPinholeRolledBack marks a pinhole that was opened and then closed + // again because the IPv4 mapping it accompanied failed. + errPinholeRolledBack = errors.New("rolled back after the IPv4 mapping failed") + // errPinholeLeftOpen marks a pinhole that the rollback failed to close, so + // it stays open until its lifetime expires. + errPinholeLeftOpen = errors.New("left open after the IPv4 mapping failed") +) + +// pcpIPv6Client is the part of pcp.Client needed for IPv6 pinholes. The +// interface keeps dual-stack behavior testable without network access. +type pcpIPv6Client interface { + AddPortMapping(context.Context, string, int, time.Duration) (*pcp.MapResponse, error) + DeletePortMapping(context.Context, string, int) error + Announce(context.Context) (uint32, error) + EpochStateLost() bool +} + +// natWithPCPIPv6 adds best-effort PCPv6 pinholes to an IPv4 NAT. Pinholes +// complement the IPv4 mapping rather than replace it, because a peer reaching +// this host may have no IPv6 connectivity at all, so a pinhole failure never +// fails the call. Read IPv6PinholeError to find out what happened. +type natWithPCPIPv6 struct { + NAT + pcp6 pcpIPv6Client + + mu sync.Mutex + pinholeErr error +} + +var ( + _ NAT = (*natWithPCPIPv6)(nil) + _ HealthChecker = (*natWithPCPIPv6)(nil) + _ IPv6PinholeReporter = (*natWithPCPIPv6)(nil) +) + +func newNATWithPCPIPv6(ipv4 NAT, pcp6 pcpIPv6Client) NAT { + return &natWithPCPIPv6{NAT: ipv4, pcp6: pcp6} +} + +// Type reports the IPv4 protocol with a PCPv6 suffix, so that a caller logging +// the gateway type can tell whether IPv6 pinholes are in play. +func (n *natWithPCPIPv6) Type() string { + return n.NAT.Type() + "+PCPv6" +} + +func (n *natWithPCPIPv6) IPv6PinholeError() error { + n.mu.Lock() + defer n.mu.Unlock() + return n.pinholeErr +} + +func (n *natWithPCPIPv6) setIPv6PinholeError(err error) { + n.mu.Lock() + defer n.mu.Unlock() + n.pinholeErr = err +} + +func (n *natWithPCPIPv6) AddPortMapping(ctx context.Context, protocol string, internalPort int, description string, timeout time.Duration) (int, error) { + pcp6Done := make(chan struct{}) + var pcp6Err error + go func() { + defer close(pcp6Done) + pinholeCtx, cancel := context.WithTimeout(ctx, pinholeTimeout) + defer cancel() + _, pcp6Err = n.pcp6.AddPortMapping(pinholeCtx, protocol, internalPort, timeout) + }() + + port, err := n.NAT.AddPortMapping(ctx, protocol, internalPort, description, timeout) + <-pcp6Done + + if err != nil && pcp6Err == nil { + pcp6Err = errPinholeRolledBack + if rollbackErr := n.rollbackPinhole(ctx, protocol, internalPort); rollbackErr != nil { + pcp6Err = fmt.Errorf("%w: %w", errPinholeLeftOpen, rollbackErr) + } + } + + n.setIPv6PinholeError(wrapPinholeErr(pcp6Err)) + return port, err +} + +// rollbackPinhole closes a pinhole opened alongside an IPv4 mapping that then +// failed: the caller sees a failed mapping and will never delete it, so the +// pinhole would leak until its lifetime expires. Detached from ctx because the +// IPv4 failure may be the caller's deadline expiring. +func (n *natWithPCPIPv6) rollbackPinhole(ctx context.Context, protocol string, internalPort int) error { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), pinholeTimeout) + defer cancel() + return n.pcp6.DeletePortMapping(ctx, protocol, internalPort) +} + +func (n *natWithPCPIPv6) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error { + pcp6Done := make(chan struct{}) + var pcp6Err error + go func() { + defer close(pcp6Done) + pinholeCtx, cancel := context.WithTimeout(ctx, pinholeTimeout) + defer cancel() + pcp6Err = n.pcp6.DeletePortMapping(pinholeCtx, protocol, internalPort) + }() + + err := n.NAT.DeletePortMapping(ctx, protocol, internalPort) + <-pcp6Done + + n.setIPv6PinholeError(wrapPinholeErr(pcp6Err)) + return err +} + +// CheckServerHealth reports the IPv4 gateway's epoch and whether either stack +// lost mapping state, since a PCPv6 server restart drops the pinhole even when +// the IPv4 gateway is healthy. An error is returned only when neither stack +// could be reached: one unreachable server is not evidence that it restarted, +// and reporting a restart would recreate a mapping that is very likely fine. +func (n *natWithPCPIPv6) CheckServerHealth(ctx context.Context) (uint32, bool, error) { + // Bounded and concurrent: announced in series on the caller's context, an + // unresponsive IPv6 server runs the full retry schedule and leaves nothing + // for the IPv4 gateway, which disables health checking altogether. + v6Done := make(chan struct{}) + var epoch6 uint32 + var err6 error + go func() { + defer close(v6Done) + pinholeCtx, cancel := context.WithTimeout(ctx, pinholeTimeout) + defer cancel() + epoch6, err6 = n.pcp6.Announce(pinholeCtx) + }() + + checker, ok := n.NAT.(HealthChecker) + var epoch uint32 + var restarted bool + var err error + if ok { + epoch, restarted, err = checker.CheckServerHealth(ctx) + } + + <-v6Done + restarted6 := err6 == nil && n.pcp6.EpochStateLost() + + if !ok { + if err6 != nil { + return 0, false, wrapPinholeErr(err6) + } + return epoch6, restarted6, nil + } + + switch { + case err != nil && err6 != nil: + return 0, false, fmt.Errorf("%w; %w", err, wrapPinholeErr(err6)) + case err != nil: + return epoch6, restarted6, nil + } + return epoch, restarted || restarted6, nil +} + +func wrapPinholeErr(err error) error { + if err == nil { + return nil + } + return fmt.Errorf("pcp ipv6: %w", err) +} diff --git a/internal/thirdparty/go-nat/upnp.go b/internal/thirdparty/go-nat/upnp.go new file mode 100644 index 0000000..669b394 --- /dev/null +++ b/internal/thirdparty/go-nat/upnp.go @@ -0,0 +1,636 @@ +package nat + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/netip" + "net/url" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/huin/goupnp" + "github.com/huin/goupnp/dcps/internetgateway1" + "github.com/huin/goupnp/dcps/internetgateway2" + "github.com/huin/goupnp/httpu" + "github.com/koron/go-ssdp" +) + +const ssdpPort uint16 = 1900 + +const ( + searchRepeats = 3 + + // defaultHTTPPort is what a UPnP device URL means when it omits the port. + defaultHTTPPort = "80" +) + +// deviceTimeout bounds a call to the gateway made by a NAT interface method +// that takes no context of its own. +const deviceTimeout = 10 * time.Second + +// upnpDiscovery names how a UPnP device was found. It ends up in the NAT type, +// which is what callers log. +type upnpDiscovery string + +const ( + upnpDiscoveryUnicast upnpDiscovery = "unicast" + upnpDiscoveryMulticast upnpDiscovery = "multicast" +) + +// upnpSearchTarget is an SSDP ST header value. +type upnpSearchTarget string + +const ( + searchTargetIGDv2 upnpSearchTarget = "urn:schemas-upnp-org:device:InternetGatewayDevice:2" + searchTargetIGDv1 upnpSearchTarget = "urn:schemas-upnp-org:device:InternetGatewayDevice:1" + searchTargetAll upnpSearchTarget = "ssdp:all" +) + +// errNoDeviceHost is returned when a UPnP device description carries no usable +// base URL, leaving nothing to address the device by. +var errNoDeviceHost = errors.New("device description has no host") + +// upnpUnicastSearchTimeout is how long each unicast SSDP search waits for +// responses. It is a variable so tests can shorten it. +var upnpUnicastSearchTimeout = 2 * time.Second + +// upnpUnicastSearchTargets are tried in order until one yields a usable gateway. +var upnpUnicastSearchTargets = []upnpSearchTarget{ + searchTargetIGDv2, + searchTargetIGDv1, + searchTargetAll, +} + +// upnpServiceRank orders WAN connection services by preference, matching the +// existing multicast discovery preference. +var upnpServiceRank = map[string]int{ + internetgateway2.URN_WANIPConnection_2: 3, + internetgateway2.URN_WANIPConnection_1: 2, + internetgateway2.URN_WANPPPConnection_1: 1, +} + +var _ NAT = (*upnp_NAT)(nil) + +func discoverUPNP_IG1(ctx context.Context) <-chan NAT { + res := make(chan NAT) + go func() { + defer close(res) + + // find devices + devs, err := goupnp.DiscoverDevicesCtx(ctx, internetgateway1.URN_WANConnectionDevice_1) + if err != nil { + return + } + + for _, dev := range devs { + if dev.Root == nil { + continue + } + + dev.Root.Device.VisitServices(func(srv *goupnp.Service) { + if ctx.Err() != nil { + return + } + switch srv.ServiceType { + case internetgateway1.URN_WANIPConnection_1: + client := &internetgateway1.WANIPConnection1{ServiceClient: goupnp.ServiceClient{ + SOAPClient: srv.NewSOAPClient(), + RootDevice: dev.Root, + Location: dev.Location, + Service: srv, + }} + _, isNat, err := client.GetNATRSIPStatusCtx(ctx) + if err == nil && isNat { + select { + case res <- newUPNPNAT(client, "UPNP (IG1-IP1)", dev.Root): + case <-ctx.Done(): + } + } + + case internetgateway1.URN_WANPPPConnection_1: + client := &internetgateway1.WANPPPConnection1{ServiceClient: goupnp.ServiceClient{ + SOAPClient: srv.NewSOAPClient(), + RootDevice: dev.Root, + Location: dev.Location, + Service: srv, + }} + _, isNat, err := client.GetNATRSIPStatusCtx(ctx) + if err == nil && isNat { + select { + case res <- newUPNPNAT(client, "UPNP (IG1-PPP1)", dev.Root): + case <-ctx.Done(): + } + } + + } + }) + } + + }() + return res +} + +func discoverUPNP_IG2(ctx context.Context) <-chan NAT { + res := make(chan NAT) + go func() { + defer close(res) + + // find devices + devs, err := goupnp.DiscoverDevicesCtx(ctx, internetgateway2.URN_WANConnectionDevice_2) + if err != nil { + return + } + + for _, dev := range devs { + if dev.Root == nil { + continue + } + + dev.Root.Device.VisitServices(func(srv *goupnp.Service) { + if ctx.Err() != nil { + return + } + switch srv.ServiceType { + case internetgateway2.URN_WANIPConnection_1: + client := &internetgateway2.WANIPConnection1{ServiceClient: goupnp.ServiceClient{ + SOAPClient: srv.NewSOAPClient(), + RootDevice: dev.Root, + Location: dev.Location, + Service: srv, + }} + _, isNat, err := client.GetNATRSIPStatusCtx(ctx) + if err == nil && isNat { + select { + case res <- newUPNPNAT(client, "UPNP (IG2-IP1)", dev.Root): + case <-ctx.Done(): + } + } + + case internetgateway2.URN_WANIPConnection_2: + client := &internetgateway2.WANIPConnection2{ServiceClient: goupnp.ServiceClient{ + SOAPClient: srv.NewSOAPClient(), + RootDevice: dev.Root, + Location: dev.Location, + Service: srv, + }} + _, isNat, err := client.GetNATRSIPStatusCtx(ctx) + if err == nil && isNat { + select { + case res <- newUPNPNAT(client, "UPNP (IG2-IP2)", dev.Root): + case <-ctx.Done(): + } + } + + case internetgateway2.URN_WANPPPConnection_1: + client := &internetgateway2.WANPPPConnection1{ServiceClient: goupnp.ServiceClient{ + SOAPClient: srv.NewSOAPClient(), + RootDevice: dev.Root, + Location: dev.Location, + Service: srv, + }} + _, isNat, err := client.GetNATRSIPStatusCtx(ctx) + if err == nil && isNat { + select { + case res <- newUPNPNAT(client, "UPNP (IG2-PPP1)", dev.Root): + case <-ctx.Done(): + } + } + + } + }) + } + + }() + return res +} + +func discoverUPNP_Unicast(ctx context.Context) <-chan NAT { + gateway, err := getDefaultGateway() + if err != nil { + return nil + } + addr, ok := netip.AddrFromSlice(gateway) + if !ok { + return nil + } + return discoverUPNPUnicastWithAddr(ctx, netip.AddrPortFrom(addr.Unmap(), ssdpPort)) +} + +func discoverUPNPUnicastWithAddr(ctx context.Context, gatewayAddr netip.AddrPort) <-chan NAT { + res := make(chan NAT, 1) + go func() { + defer close(res) + + gateway, err := discoverUPNPUnicast(ctx, gatewayAddr) + if err != nil { + return + } + + select { + case res <- gateway: + case <-ctx.Done(): + } + }() + return res +} + +// discoverUPNPUnicast attempts to find a UPnP IGD by sending unicast SSDP +// searches to gatewayAddr. This is useful on networks where multicast SSDP is +// filtered but gateways still answer direct M-SEARCH requests. +func discoverUPNPUnicast(ctx context.Context, gatewayAddr netip.AddrPort) (NAT, error) { + client, err := httpu.NewHTTPUClient() + if err != nil { + return nil, fmt.Errorf("create SSDP client: %w", err) + } + defer func() { _ = client.Close() }() + + checked := make(map[string]bool) + for _, target := range upnpUnicastSearchTargets { + if err := ctx.Err(); err != nil { + return nil, err + } + + locations, err := searchUPNPUnicast(client, gatewayAddr, target) + if err != nil { + continue + } + + for _, location := range locations { + if checked[location] { + continue + } + checked[location] = true + + // The search was addressed to the gateway, but any host on the + // link can answer it. Only follow a description URL that names + // the gateway itself, so a rogue responder cannot redirect + // discovery at a device of its choosing. + if !locationHasAddr(location, gatewayAddr.Addr()) { + continue + } + + gateway, err := natFromUPNPLocation(ctx, location, upnpDiscoveryUnicast) + if err == nil { + return gateway, nil + } + } + } + + return nil, fmt.Errorf("no UPnP gateway found at %s", gatewayAddr) +} + +// locationHasAddr reports whether an SSDP description URL names the device at +// addr. A URL carrying a host name rather than an address is rejected: it +// cannot be checked without resolving it, and a responder free to choose the +// name would then also be choosing what the resolver returns. Gateways +// advertise themselves by address, since a client on the link has no way to +// resolve a name they might publish instead. +func locationHasAddr(location string, addr netip.Addr) bool { + loc, err := url.Parse(location) + if err != nil { + return false + } + locAddr, err := netip.ParseAddr(loc.Hostname()) + if err != nil { + return false + } + return locAddr.Unmap() == addr.Unmap() +} + +// searchUPNPUnicast sends a unicast M-SEARCH to gatewayAddr and returns the +// description URLs of devices that answered. +func searchUPNPUnicast(client *httpu.HTTPUClient, gatewayAddr netip.AddrPort, searchTarget upnpSearchTarget) ([]string, error) { + mx := max(int(upnpUnicastSearchTimeout/time.Second), 1) + host := gatewayAddr.String() + req := &http.Request{ + Method: "M-SEARCH", + // httpu sends the request to req.Host, making the search unicast. + Host: host, + URL: &url.URL{Opaque: "*"}, + // Header keys are set verbatim (map literal keys bypass Go's + // canonicalization) to keep the upper-case field names SSDP + // implementations expect. + Header: http.Header{ + "HOST": []string{host}, + "MAN": []string{`"ssdp:discover"`}, + "MX": []string{strconv.Itoa(mx)}, + "ST": []string{string(searchTarget)}, + }, + } + + responses, err := client.Do(req, upnpUnicastSearchTimeout, searchRepeats) + if err != nil { + return nil, err + } + + var locations []string + for _, resp := range responses { + if resp.Body != nil { + _ = resp.Body.Close() + } + if resp.StatusCode != http.StatusOK { + continue + } + if location := resp.Header.Get("Location"); location != "" { + locations = append(locations, location) + } + } + return locations, nil +} + +func discoverUPNP_GenIGDev(ctx context.Context) <-chan NAT { + res := make(chan NAT, 1) + go func() { + defer close(res) + + deviceList, err := ssdp.Search(ssdp.All, 5, "") + if err != nil { + return + } + + for _, service := range deviceList { + if !strings.Contains(service.Type, "InternetGatewayDevice") { + continue + } + + gateway, err := natFromUPNPLocation(ctx, service.Location, upnpDiscoveryMulticast) + if err != nil { + continue + } + + select { + case res <- gateway: + case <-ctx.Done(): + } + return + } + }() + return res +} + +// natFromUPNPLocation fetches the device description and returns a NAT backed +// by the most preferred WAN connection service it offers. +func natFromUPNPLocation(ctx context.Context, location string, discovery upnpDiscovery) (NAT, error) { + loc, err := url.Parse(location) + if err != nil { + return nil, fmt.Errorf("parse location: %w", err) + } + + root, err := goupnp.DeviceByURLCtx(ctx, loc) + if err != nil { + return nil, fmt.Errorf("fetch device description: %w", err) + } + + var services []*goupnp.Service + root.Device.VisitServices(func(srv *goupnp.Service) { + if upnpServiceRank[srv.ServiceType] > 0 { + services = append(services, srv) + } + }) + sort.SliceStable(services, func(i, j int) bool { + return upnpServiceRank[services[i].ServiceType] > upnpServiceRank[services[j].ServiceType] + }) + + for _, srv := range services { + gateway, err := natFromUPNPService(ctx, root, loc, srv, discovery) + if err == nil { + return gateway, nil + } + } + + return nil, fmt.Errorf("no usable WAN connection service in device at %s", location) +} + +// natFromUPNPService builds a NAT from one WAN connection service. discovery +// names how the device was found and ends up in the NAT type. +func natFromUPNPService(ctx context.Context, root *goupnp.RootDevice, loc *url.URL, srv *goupnp.Service, discovery upnpDiscovery) (NAT, error) { + serviceClient := goupnp.ServiceClient{ + SOAPClient: srv.NewSOAPClient(), + RootDevice: root, + Location: loc, + Service: srv, + } + + var client upnp_NAT_Client + var service string + switch srv.ServiceType { + case internetgateway2.URN_WANIPConnection_2: + client = &internetgateway2.WANIPConnection2{ServiceClient: serviceClient} + service = "IP2" + case internetgateway2.URN_WANIPConnection_1: + client = &internetgateway2.WANIPConnection1{ServiceClient: serviceClient} + service = "IP1" + case internetgateway2.URN_WANPPPConnection_1: + client = &internetgateway2.WANPPPConnection1{ServiceClient: serviceClient} + service = "PPP1" + default: + return nil, fmt.Errorf("unsupported service type %s", srv.ServiceType) + } + + _, isNAT, err := client.GetNATRSIPStatusCtx(ctx) + if err != nil { + return nil, fmt.Errorf("get NAT status: %w", err) + } + if !isNAT { + return nil, errors.New("gateway reports NAT disabled") + } + + return newUPNPNAT(client, fmt.Sprintf("UPnP %s (%s)", discovery, service), root), nil +} + +type upnp_NAT_Client interface { + GetExternalIPAddressCtx(context.Context) (string, error) + AddPortMappingCtx(context.Context, string, uint16, string, uint16, string, bool, string, uint32) error + DeletePortMappingCtx(context.Context, string, uint16, string) error + GetNATRSIPStatusCtx(context.Context) (bool, bool, error) +} + +// upnpMappingKey identifies one mapping on the device. The protocol is part of +// the key because a device holds independent TCP and UDP mappings for the same +// internal port. +type upnpMappingKey struct { + protocol string + port uint16 +} + +type upnp_NAT struct { + c upnp_NAT_Client + typ string + rootDevice *goupnp.RootDevice + + // mu is held across the device call and the cache update together, so that + // two callers cannot both miss the cache and create competing mappings, or + // drop a cache entry while the other is still installing it. A UPnP device + // answers one SOAP request at a time anyway. + mu sync.Mutex + ports map[upnpMappingKey]uint16 +} + +func newUPNPNAT(client upnp_NAT_Client, typ string, root *goupnp.RootDevice) *upnp_NAT { + return &upnp_NAT{ + c: client, + typ: typ, + rootDevice: root, + ports: make(map[upnpMappingKey]uint16), + } +} + +func (u *upnp_NAT) GetExternalAddress() (addr net.IP, err error) { + ctx, cancel := context.WithTimeout(context.Background(), deviceTimeout) + defer cancel() + + ipString, err := u.c.GetExternalIPAddressCtx(ctx) + if err != nil { + return nil, err + } + + ip := net.ParseIP(ipString) + if ip == nil { + return nil, ErrNoExternalAddress + } + + return ip, nil +} + +func mapProtocol(s string) (string, error) { + switch s { + case "udp": + return "UDP", nil + case "tcp": + return "TCP", nil + default: + return "", fmt.Errorf("invalid protocol: %s", s) + } +} + +func (u *upnp_NAT) AddPortMapping(ctx context.Context, protocol string, internalPort int, description string, timeout time.Duration) (int, error) { + proto, err := mapProtocol(protocol) + if err != nil { + return 0, err + } + + ip, err := u.internalAddress(ctx) + if err != nil { + return 0, fmt.Errorf("get internal address: %w", err) + } + + timeoutInSeconds := uint32(timeout / time.Second) + key := upnpMappingKey{protocol: proto, port: uint16(internalPort)} + + u.mu.Lock() + defer u.mu.Unlock() + + if externalPort := u.ports[key]; externalPort > 0 { + err = u.c.AddPortMappingCtx(ctx, "", externalPort, proto, key.port, ip.String(), true, description, timeoutInSeconds) + if err == nil { + return int(externalPort), nil + } + } + + for range 3 { + externalPort := uint16(randomPort()) + err = u.c.AddPortMappingCtx(ctx, "", externalPort, proto, key.port, ip.String(), true, description, timeoutInSeconds) + if err == nil { + u.ports[key] = externalPort + return int(externalPort), nil + } + } + + return 0, err +} + +func (u *upnp_NAT) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error { + proto, err := mapProtocol(protocol) + if err != nil { + return err + } + + key := upnpMappingKey{protocol: proto, port: uint16(internalPort)} + + u.mu.Lock() + defer u.mu.Unlock() + + externalPort, ok := u.ports[key] + if !ok { + return nil + } + + if err := u.c.DeletePortMappingCtx(ctx, "", externalPort, proto); err != nil { + return err + } + // Drop the entry only once the device confirms, so a failed delete leaves + // the mapping addressable for a retry instead of orphaning it. + delete(u.ports, key) + return nil +} + +func (u *upnp_NAT) GetDeviceAddress() (net.IP, error) { + host, err := deviceHostPort(u.rootDevice) + if err != nil { + return nil, err + } + + addr, err := net.ResolveUDPAddr("udp", host) + if err != nil { + return nil, err + } + + return addr.IP, nil +} + +// GetInternalAddress returns the local address the gateway sees us on. The NAT +// interface takes no context here, so it applies its own timeout; callers that +// have one reach internalAddress directly. +func (u *upnp_NAT) GetInternalAddress() (net.IP, error) { + ctx, cancel := context.WithTimeout(context.Background(), deviceTimeout) + defer cancel() + return u.internalAddress(ctx) +} + +// internalAddress asks the routing table which source address would be used to +// reach the device, which picks the right one when several interfaces share a +// subnet with it. +func (u *upnp_NAT) internalAddress(ctx context.Context) (net.IP, error) { + host, err := deviceHostPort(u.rootDevice) + if err != nil { + return nil, err + } + + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "udp", host) + if err != nil { + return nil, fmt.Errorf("resolve local address for %s: %w", host, err) + } + defer func() { _ = conn.Close() }() + + localAddr, ok := conn.LocalAddr().(*net.UDPAddr) + if !ok { + return nil, ErrNoInternalAddress + } + return localAddr.IP, nil +} + +// deviceHostPort returns the device's host:port, supplying the HTTP default +// port when the device's URLBase omits it, as the UPnP Device Architecture +// allows it to. +func deviceHostPort(root *goupnp.RootDevice) (string, error) { + // Hostname and Port handle the brackets around an IPv6 literal, which + // splitting URLBase.Host by hand would not. + host := root.URLBase.Hostname() + if host == "" { + return "", errNoDeviceHost + } + port := root.URLBase.Port() + if port == "" { + port = defaultHTTPPort + } + return net.JoinHostPort(host, port), nil +} + +func (n *upnp_NAT) Type() string { return n.typ } diff --git a/internal/thirdparty/go-nat/upnp_test.go b/internal/thirdparty/go-nat/upnp_test.go new file mode 100644 index 0000000..3175abc --- /dev/null +++ b/internal/thirdparty/go-nat/upnp_test.go @@ -0,0 +1,242 @@ +package nat + +import ( + "context" + "errors" + "maps" + "net/netip" + "net/url" + "sync" + "testing" + "time" + + "github.com/huin/goupnp" +) + +// testRootDevice is a device description with a reachable base URL, which +// AddPortMapping needs to resolve the local address. +func testRootDevice(t *testing.T) *goupnp.RootDevice { + t.Helper() + base, err := url.Parse("http://127.0.0.1:5000/") + if err != nil { + t.Fatal(err) + } + return &goupnp.RootDevice{URLBase: *base} +} + +func TestLocationHasAddr(t *testing.T) { + gateway := netip.MustParseAddr("192.168.1.1") + + tests := []struct { + name string + location string + want bool + }{ + { + name: "the gateway itself", + location: "http://192.168.1.1:5000/rootDesc.xml", + want: true, + }, + { + name: "the gateway without a port", + location: "http://192.168.1.1/rootDesc.xml", + want: true, + }, + { + name: "another host on the link answered", + // A rogue responder steering discovery at a device it controls. + location: "http://192.168.1.99:5000/rootDesc.xml", + }, + { + name: "an off-link address", + location: "http://203.0.113.7/rootDesc.xml", + }, + { + name: "a host name cannot be checked without resolving it", + // A responder free to choose the name is also choosing what the + // resolver returns, so the name proves nothing. + location: "http://gateway.local:5000/rootDesc.xml", + }, + { + name: "unparseable", + location: "://nonsense", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := locationHasAddr(tt.location, gateway); got != tt.want { + t.Fatalf("locationHasAddr(%q) = %v, want %v", tt.location, got, tt.want) + } + }) + } +} + +func TestLocationHasAddrMatchesMappedGateway(t *testing.T) { + // A gateway parsed from a net.IP may arrive as a v4-mapped v6 address. + mapped := netip.MustParseAddr("::ffff:192.168.1.1") + if !locationHasAddr("http://192.168.1.1:5000/rootDesc.xml", mapped) { + t.Fatal("a v4-mapped gateway did not match its own address") + } +} + +func TestDeviceHostPort(t *testing.T) { + tests := []struct { + name string + urlBase string + want string + wantErr error + }{ + { + name: "explicit port", + urlBase: "http://192.168.1.1:5000/", + want: "192.168.1.1:5000", + }, + { + name: "no port defaults to HTTP", + // The UPnP Device Architecture lets a device URL omit the port. + urlBase: "http://192.168.1.1/", + want: "192.168.1.1:80", + }, + { + name: "IPv6 literal keeps its brackets", + urlBase: "http://[fe80::1]:5000/", + want: "[fe80::1]:5000", + }, + { + name: "IPv6 literal without a port", + urlBase: "http://[fe80::1]/", + want: "[fe80::1]:80", + }, + { + name: "hostname", + urlBase: "http://gateway.local:49152/", + want: "gateway.local:49152", + }, + { + name: "no base URL at all", + urlBase: "", + wantErr: errNoDeviceHost, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + base, err := url.Parse(tt.urlBase) + if err != nil { + t.Fatal(err) + } + + got, err := deviceHostPort(&goupnp.RootDevice{URLBase: *base}) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("deviceHostPort() error = %v, want %v", err, tt.wantErr) + } + if got != tt.want { + t.Fatalf("deviceHostPort() = %q, want %q", got, tt.want) + } + }) + } +} + +// fakeUPNPClient records the mappings a device holds. +type fakeUPNPClient struct { + mu sync.Mutex + mappings map[string]uint16 // protocol -> external port + addErr error + delErr error + adds int + deletes int +} + +func newFakeUPNPClient() *fakeUPNPClient { + return &fakeUPNPClient{mappings: make(map[string]uint16)} +} + +func (c *fakeUPNPClient) GetExternalIPAddressCtx(context.Context) (string, error) { + return "203.0.113.1", nil +} + +func (c *fakeUPNPClient) GetNATRSIPStatusCtx(context.Context) (bool, bool, error) { + return false, true, nil +} + +func (c *fakeUPNPClient) AddPortMappingCtx(_ context.Context, _ string, extPort uint16, protocol string, _ uint16, _ string, _ bool, _ string, _ uint32) error { + c.mu.Lock() + defer c.mu.Unlock() + c.adds++ + if c.addErr != nil { + return c.addErr + } + c.mappings[protocol] = extPort + return nil +} + +func (c *fakeUPNPClient) DeletePortMappingCtx(_ context.Context, _ string, _ uint16, protocol string) error { + c.mu.Lock() + defer c.mu.Unlock() + c.deletes++ + if c.delErr != nil { + return c.delErr + } + delete(c.mappings, protocol) + return nil +} + +func (c *fakeUPNPClient) held() map[string]uint16 { + c.mu.Lock() + defer c.mu.Unlock() + return maps.Clone(c.mappings) +} + +func TestUPNPKeepsTCPAndUDPMappingsApart(t *testing.T) { + client := newFakeUPNPClient() + u := newUPNPNAT(client, "test", testRootDevice(t)) + + ctx := context.Background() + if _, err := u.AddPortMapping(ctx, "udp", 51820, "wg", time.Hour); err != nil { + t.Fatal(err) + } + if _, err := u.AddPortMapping(ctx, "tcp", 51820, "wg", time.Hour); err != nil { + t.Fatal(err) + } + if got := len(client.held()); got != 2 { + t.Fatalf("device holds %d mappings, want 2 (TCP and UDP are independent)", got) + } + + // Deleting one protocol must not make the other undeletable: keyed by port + // alone, the shared cache entry is gone and the second delete is skipped. + if err := u.DeletePortMapping(ctx, "udp", 51820); err != nil { + t.Fatal(err) + } + if err := u.DeletePortMapping(ctx, "tcp", 51820); err != nil { + t.Fatal(err) + } + + if held := client.held(); len(held) != 0 { + t.Fatalf("device still holds %v, want both mappings removed", held) + } +} + +func TestUPNPKeepsMappingAddressableAfterFailedDelete(t *testing.T) { + client := newFakeUPNPClient() + u := newUPNPNAT(client, "test", testRootDevice(t)) + ctx := context.Background() + + if _, err := u.AddPortMapping(ctx, "udp", 51820, "wg", time.Hour); err != nil { + t.Fatal(err) + } + + client.delErr = errors.New("device busy") + if err := u.DeletePortMapping(ctx, "udp", 51820); err == nil { + t.Fatal("DeletePortMapping() error = nil, want the device failure") + } + + // The mapping is still on the device, so a retry must still address it. + client.delErr = nil + if err := u.DeletePortMapping(ctx, "udp", 51820); err != nil { + t.Fatal(err) + } + if held := client.held(); len(held) != 0 { + t.Fatalf("device still holds %v after retry", held) + } +} diff --git a/licenses/README.md b/licenses/README.md index a5656fd..ad0cf51 100644 --- a/licenses/README.md +++ b/licenses/README.md @@ -45,4 +45,6 @@ The Linux license texts come from the GNU license pages: `https://www.gnu.org/licenses/gpl-3.0.txt`. Update the corresponding source text and index when a pinned runtime changes. -No dependency source tree, plugin, or license-scanning dependency is bundled. +The two [scoped NAT dependency repairs](../internal/thirdparty/README.md) retain +upstream source and Apache-2.0 attribution; their license files enter App notices +through the same Go module collector. No license-scanning dependency is needed. diff --git a/package.json b/package.json index 6cb2eb5..61d0c91 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "package:client-candidate": "node scripts/package-client-candidate.mjs", "check:client": "node scripts/check-client.mjs", "check:client-core": "node scripts/check-client.mjs --core", + "check:client-race": "node scripts/check-client.mjs --race", + "check:container": "node scripts/check-container.mjs", "check:client-capture": "node scripts/check-client.mjs --capture-only", "gate:client-media": "tsx scripts/client-media-gate.ts", "gate:client-native-host": "tsx scripts/client-native-host-gate.ts", diff --git a/scripts/check-client.mjs b/scripts/check-client.mjs index 279f229..76c278c 100644 --- a/scripts/check-client.mjs +++ b/scripts/check-client.mjs @@ -15,13 +15,16 @@ import { CLIENT_PACKAGE_TARGETS, clientPackageTarget, clientGoEnvironment } from // fail without it. The Hosted binary is cross-built for its deployment target. const CLIENT_INDEX = join("internal", "server", "webassets", "dist", "index.html"); const SERVER_TARGET = clientPackageTarget("linux-amd64"); +// Local dependency repairs retain upstream tests, including PCPv6 composition. +// Nested modules need explicit test patterns; remove these with the replacements. +const GO_TEST_PACKAGES = ["./...", "github.com/netbirdio/go-nat/...", "github.com/jackpal/go-nat-pmp"]; const root = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), "..")); const captureRoot = join(root, "native", "capture"); const mode = process.argv[2] ?? "--all"; -if (!["--all", "--core", "--capture-only"].includes(mode)) { - throw new Error("Usage: node scripts/check-client.mjs [--all|--core|--capture-only]"); +if (!["--all", "--core", "--capture-only", "--race"].includes(mode)) { + throw new Error("Usage: node scripts/check-client.mjs [--all|--core|--capture-only|--race]"); } function run(command, args, options = {}) { @@ -61,7 +64,7 @@ function checkCore() { throw new Error(`Go source is not formatted:\n${unformatted}`); } runClientTests(go); - run(go, ["vet", "./..."]); + run(go, ["vet", ...GO_TEST_PACKAGES]); const buildRoot = join(root, "build", "client-check"); mkdirSync(buildRoot, { recursive: true }); @@ -86,15 +89,18 @@ function checkCore() { } } -function runClientTests(go) { +function runClientTests(go, packagesToTest = GO_TEST_PACKAGES, flags = []) { if (process.platform !== "win32") { - run(go, ["test", "./..."]); + run(go, ["test", ...flags, ...packagesToTest.filter((entry) => entry.startsWith("./"))]); + // PCP and NAT-PMP both require port 5351. Serialize only their fixture + // packages, keeping ordinary Piik tests parallel and using portable loopback. + run(go, ["test", ...flags, "-p=1", ...packagesToTest.filter((entry) => !entry.startsWith("./"))]); return; } // Windows firewall permissions follow executable paths, including tests that // open sockets indirectly. Never execute a test from Go's temporary directory. - const packages = run(go, ["list", "-f", '{{if or .TestGoFiles .XTestGoFiles}}[{{printf "%q" .ImportPath}},{{printf "%q" .Dir}}]{{end}}', "./..."], { capture: true }) + const packages = run(go, ["list", "-f", '{{if or .TestGoFiles .XTestGoFiles}}[{{printf "%q" .ImportPath}},{{printf "%q" .Dir}}]{{end}}', ...packagesToTest], { capture: true }) .split(/\r?\n/) .map((value) => value.trim()) .filter(Boolean) @@ -103,8 +109,8 @@ function runClientTests(go) { mkdirSync(stableRoot, { recursive: true }); for (const [entry, directory] of packages) { const binary = join(stableRoot, `${basename(entry)}.test.exe`); - run(go, ["test", "-c", "-o", binary, entry]); - run(binary, [], { cwd: directory }); + run(go, ["test", ...flags, "-c", "-o", binary, entry]); + run(binary, flags.includes("-race") ? ["-test.timeout=2m"] : [], { cwd: directory }); } } @@ -203,6 +209,14 @@ function checkPlatformCapture() { } } -if (mode !== "--capture-only") checkCore(); -if (mode !== "--core") checkPlatformCapture(); +if (mode === "--race") { + buildWebAssets(); + runClientTests(process.env.PIIK_GO?.trim() || "go", [ + "./internal/app/portmapping", "./internal/app/mediaedge", + "github.com/netbirdio/go-nat/...", "github.com/jackpal/go-nat-pmp", + ], ["-race", "-count=1", "-timeout=2m"]); +} else { + if (mode !== "--capture-only") checkCore(); + if (mode !== "--core") checkPlatformCapture(); +} process.stdout.write("Piik App checks passed.\n"); diff --git a/scripts/check-container.mjs b/scripts/check-container.mjs new file mode 100644 index 0000000..0955b14 --- /dev/null +++ b/scripts/check-container.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { randomBytes, randomUUID } from "node:crypto"; +import { createSocket } from "node:dgram"; +import { copyFileSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import { resetBuildWorkspace } from "./build-workspace.mjs"; + +const [image, revision] = process.argv.slice(2); +if (process.argv.length !== 4 || !image || !/^[0-9a-f]{40}$/.test(revision ?? "")) { + throw new Error("Usage: node scripts/check-container.mjs "); +} +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const project = `piik-check-${randomUUID()}`; +const workspace = resetBuildWorkspace(root, "container-check", project); +const environment = { ...process.env, PIIK_IMAGE: image }; +function docker(...args) { + return execFileSync("docker", args, { + cwd: workspace, env: environment, encoding: "utf8", timeout: 90_000, + stdio: ["ignore", "pipe", "pipe"], windowsHide: true, + }).trim(); +} +function compose(...args) { + return docker("compose", "--project-name", project, "--file", "compose.yaml", ...args); +} +async function until(check, label) { + const deadline = Date.now() + 20_000; + let failure; + do { + try { return await check(); } catch (error) { failure = error; } + await delay(200); + } while (Date.now() < deadline); + throw new Error(`Container check timed out: ${label}`, { cause: failure }); +} +async function request(path, options) { + const response = await fetch(`http://127.0.0.1:8787${path}`, { + ...options, signal: AbortSignal.timeout(2_000), + }); + assert.ok(response.ok, `${path}: HTTP ${response.status}`); + return response; +} +async function binding(port) { + const socket = createSocket("udp4"); + const packet = Buffer.alloc(20); + packet.writeUInt16BE(1); // RFC 8489 Binding Request, no attributes. + packet.writeUInt32BE(0x2112a442, 4); + randomBytes(12).copy(packet, 8); + try { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`STUN ${port} timed out`)), 2_000); + const finish = (error) => { clearTimeout(timer); error ? reject(error) : resolve(); }; + socket.once("error", finish); + socket.once("message", (reply) => { + try { + assert.ok(reply.length >= 20); + assert.equal(reply.readUInt16BE(0), 0x0101); + assert.deepEqual(reply.subarray(4, 20), packet.subarray(4, 20)); + finish(); + } catch (error) { finish(error); } + }); + socket.send(packet, port, "127.0.0.1", (error) => { if (error) finish(error); }); + }); + } finally { socket.close(); } +} + +copyFileSync(join(root, "deploy/container/compose.yaml"), join(workspace, "compose.yaml")); +const sample = readFileSync(join(root, "deploy/container/.env.example"), "utf8"); +writeFileSync(join(workspace, ".env"), sample); +try { + const metadata = JSON.parse(docker("image", "inspect", image))[0]; + assert.equal(metadata.Config.User, "65532:65532"); + assert.equal(metadata.Config.Labels["org.opencontainers.image.revision"], revision); + assert.equal(metadata.Os, "linux"); + assert.equal(metadata.Architecture, "amd64"); + assert.match(compose("run", "--rm", "--no-deps", "piik", "--check-config"), /config=ok/); + compose("up", "-d", "--pull", "never"); + await until(async () => assert.deepEqual(await (await request("/healthz")).json(), { status: "ok" }), "ready"); + assert.deepEqual(await (await request("/api/capabilities")).json(), { sfu: false, natPrediction: false }); + assert.equal((await (await request("/api/site-access")).json()).required, false); + const html = await (await request("/")).text(); + const asset = html.match(/src="(\/assets\/index-[A-Za-z0-9_-]+\.js)"/)?.[1]; + assert.ok(asset, "Server must embed the Web UI"); + const script = await (await request(asset)).text(); + assert.ok(script.includes(revision), "Web and Server must have the same source identity"); + await binding(3478); + const room = await (await request("/api/rooms", { + method: "POST", headers: { Origin: "https://share.example.com", "Content-Type": "application/json" }, + body: JSON.stringify({ codeEntryPolicy: "open" }), + })).json(); + assert.match(room.roomId, /^[1-9][0-9]{3}$/); + assert.ok(room.hostToken); + const container = JSON.parse(docker("inspect", compose("ps", "-q", "piik")))[0]; + assert.equal(container.HostConfig.ReadonlyRootfs, true); + assert.ok(container.HostConfig.CapDrop.includes("ALL")); + assert.ok(container.HostConfig.SecurityOpt.some((value) => value.startsWith("no-new-privileges"))); + + // Recreate with optional services on the same persistent room volume. + writeFileSync(join(workspace, ".env"), `${sample}\nSFU_UDP_PORT=7882\nSFU_PUBLIC_IP=127.0.0.1\nNAT_PREDICTION_ENABLED=true\nPIIK_DEBUG=server\n`); + compose("up", "-d", "--force-recreate", "--pull", "never"); + await until(async () => assert.deepEqual(await (await request("/api/capabilities")).json(), { sfu: true, natPrediction: true }), "optional services"); + await request(`/api/rooms/${room.roomId}/access`, { + method: "POST", + headers: { Origin: "https://share.example.com", Authorization: `Bearer ${room.hostToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ action: "set-code-entry-policy", policy: "private" }), + }); + for (const port of [3478, 3479, 3480]) await binding(port); + compose("kill", "--signal", "SIGUSR1", "piik"); + const state = join(workspace, "state"); + mkdirSync(state); + await until(() => { + compose("cp", "piik:/home/nonroot/.", state); + assert.ok(readdirSync(join(state, "logs")).some((name) => name.endsWith(".zip"))); + }, "diagnostic export"); + assert.equal(readFileSync(join(state, "rooms.sqlite")).subarray(0, 16).toString(), "SQLite format 3\0"); + compose("stop"); + const stopped = JSON.parse(docker("inspect", compose("ps", "-aq", "piik")))[0]; + assert.equal(stopped.State.ExitCode, 0, "SIGTERM must shut down cleanly"); + process.stdout.write("Container checks passed: P2P, embedded services, room persistence, diagnostics and shutdown.\n"); +} catch (error) { + try { process.stderr.write(`${compose("logs", "--no-color", "--tail", "60")}\n`); } catch { /* Startup may have failed. */ } + throw error; +} finally { + try { compose("down", "--volumes", "--remove-orphans"); } + finally { rmSync(workspace, { recursive: true, force: true }); } +} diff --git a/scripts/package-app-release.mjs b/scripts/package-app-release.mjs index a1133fb..7b2cd88 100644 --- a/scripts/package-app-release.mjs +++ b/scripts/package-app-release.mjs @@ -172,8 +172,13 @@ function mainAssetOf(distributionRoot) { return match[1]; } -if (process.argv.length !== 3) { - fail("Usage: node scripts/package-app-release.mjs "); +if (process.argv.length !== 3 && + !(process.argv.length === 5 && process.argv[3] === "--container-image")) { + fail("Usage: node scripts/package-app-release.mjs [--container-image ]"); +} +const containerImage = process.argv[4]; +if (containerImage && !/^[a-z0-9][a-z0-9._/:+-]*$/.test(containerImage)) { + fail("Container image tag is invalid"); } const repositoryRoot = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), "..")); @@ -262,6 +267,17 @@ try { mainAsset, }; writeFileSync(descriptorPath, `${JSON.stringify(descriptor, null, 2)}\n`, "ascii"); + if (containerImage) { + // Reuse the verified, extracted release: no second Server or Web build. + run("docker", [ + "build", "--platform", "linux/amd64", + "--file", join(repositoryRoot, "deploy", "container", "Dockerfile"), + "--build-arg", `PIIK_VERSION=${version}`, + "--build-arg", `PIIK_REVISION=${revision}`, + "--build-arg", `PIIK_SERVER_SHA256=${descriptor.artifactSha256}`, + "--tag", containerImage, verifyRoot, + ], repositoryRoot); + } process.stdout.write(`${JSON.stringify({ descriptor: descriptorPath, ...descriptor })}\n`); } finally { rmSync(temporaryRoot, { recursive: true, force: true }); diff --git a/scripts/publish-container.mjs b/scripts/publish-container.mjs new file mode 100644 index 0000000..17d750e --- /dev/null +++ b/scripts/publish-container.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { readReleaseArtifacts } from "./release-artifacts.mjs"; + +const [image, directory, version, revision] = process.argv.slice(2); +if (process.argv.length !== 6 || !image) { + throw new Error("Usage: node scripts/publish-container.mjs "); +} +const { files } = readReleaseArtifacts(directory, version, revision); +const server = files.find((file) => file.name.endsWith("-runtime.tar.gz")); +assert.ok(server, "Server archive is missing"); +const repository = process.env.GITHUB_REPOSITORY || "TNTcraftHIM/Piik"; +const destination = `ghcr.io/${repository.toLowerCase()}`; +const run = (command, ...args) => execFileSync(command, args, { + encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 300_000, +}).trim(); +const gh = (path) => JSON.parse(run("gh", "api", `repos/${repository}/${path}`)); +const release = gh(`releases/tags/${version}`); +assert.equal(release.draft, false, "Container publication requires a published GitHub release"); +assert.equal(release.prerelease, false); +assert.equal(release.target_commitish, revision); +assert.equal(gh(`commits/${version}`).sha, revision, "Published source tag changed"); +assert.equal(release.assets.find((asset) => asset.name === server.name)?.digest, + `sha256:${server.sha256}`, "Container must wrap the published Server archive"); + +function verifyImage(reference) { + const metadata = JSON.parse(run("docker", "image", "inspect", reference))[0]; + assert.equal(metadata.Os, "linux"); + assert.equal(metadata.Architecture, "amd64"); + const labels = metadata.Config.Labels; + assert.equal(labels["org.opencontainers.image.version"], version); + assert.equal(labels["org.opencontainers.image.revision"], revision); + assert.equal(labels["tv.piik.server.sha256"], server.sha256); +} +verifyImage(image); +const tag = `${destination}:${version}`; +let exists = true; +try { run("docker", "manifest", "inspect", tag); } +catch (error) { + // Authentication/network failures are not evidence that a tag is absent. + if (!/manifest unknown|no such manifest/i.test(String(error.stderr ?? ""))) throw error; + exists = false; +} +if (exists) { + run("docker", "pull", tag); + verifyImage(tag); + process.stdout.write(`${tag} already exists; retained without replacement.\n`); +} else { + run("docker", "tag", image, tag); + run("docker", "push", tag); +} +// Retrying an older release must never move latest backwards. +if (gh("releases/latest").tag_name === version) { + run("docker", "tag", tag, `${destination}:latest`); + run("docker", "push", `${destination}:latest`); +} +process.stdout.write(`Container publication complete: ${tag}\n`); diff --git a/tests/publish-container.test.ts b/tests/publish-container.test.ts new file mode 100644 index 0000000..3c33be1 --- /dev/null +++ b/tests/publish-container.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; + +const { command, artifacts } = vi.hoisted(() => ({ command: vi.fn(), artifacts: vi.fn() })); +vi.mock("node:child_process", () => ({ execFileSync: command })); +vi.mock("../scripts/release-artifacts.mjs", () => ({ readReleaseArtifacts: artifacts })); + +const version = "v1.2.1", revision = "a".repeat(40), digest = "b".repeat(64); +const repository = "TNTcraftHIM/Piik", image = `piik-candidate:${revision}`; +const tag = `ghcr.io/tntcrafthim/piik:${version}`; +type Scenario = { existing?: boolean; draft?: boolean; badArchive?: boolean; + wrongSource?: boolean; wrongImage?: boolean; remoteMismatch?: boolean; + registryError?: boolean; newerRelease?: boolean }; + +async function publish(options: Scenario = {}) { + vi.resetModules(); + artifacts.mockReturnValue({ files: [{ name: "piik-aaaaaaa-runtime.tar.gz", sha256: digest }] }); + command.mockReset().mockImplementation((program: string, args: string[]) => { + if (program === "gh") { + if (args[1].endsWith("/commits/" + version)) { + return JSON.stringify({ sha: options.wrongSource ? "c".repeat(40) : revision }); + } + if (args[1].endsWith("/releases/latest")) { + return JSON.stringify({ tag_name: options.newerRelease ? "v1.3.0" : version }); + } + return JSON.stringify({ draft: options.draft ?? false, prerelease: false, + target_commitish: revision, assets: [{ name: "piik-aaaaaaa-runtime.tar.gz", + digest: `sha256:${options.badArchive ? "c".repeat(64) : digest}` }] }); + } + if (args[0] === "image" && args[1] === "inspect") { + return JSON.stringify([{ Os: "linux", Architecture: "amd64", Config: { Labels: { + "org.opencontainers.image.version": version, + "org.opencontainers.image.revision": options.wrongImage || (options.remoteMismatch && args[2] === tag) + ? "c".repeat(40) : revision, + "tv.piik.server.sha256": digest, + } } }]); + } + if (args[0] === "manifest" && (options.registryError || !options.existing)) { + throw Object.assign(new Error("Registry failure"), { + stderr: options.registryError ? "unauthorized: authentication required" : "manifest unknown", + }); + } + return "{}"; + }); + const argv = process.argv; + vi.stubEnv("GITHUB_REPOSITORY", repository); + process.argv = ["node", "publish-container.mjs", image, "artifacts", version, revision]; + try { await import("../scripts/publish-container.mjs"); } + finally { process.argv = argv; vi.unstubAllEnvs(); } +} +const pushes = () => command.mock.calls.filter(([program, args]) => program === "docker" && args[0] === "push") + .map(([, args]) => args[1]); + +describe("container publication", () => { + it("publishes the verified Server image with the GitHub version and latest", async () => { + await publish(); + expect(artifacts).toHaveBeenCalledWith("artifacts", version, revision); + expect(pushes()).toEqual([tag, "ghcr.io/tntcrafthim/piik:latest"]); + }); + it.each([ + { draft: true }, { badArchive: true }, { wrongSource: true }, { wrongImage: true }, + { registryError: true }, { existing: true, remoteMismatch: true }, + ])("blocks invalid publication before pushing (%j)", async (options) => { + await expect(publish(options)).rejects.toThrow(); + expect(pushes()).toEqual([]); + }); + it("retains an existing version and cannot move latest backwards", async () => { + await publish({ existing: true, newerRelease: true }); + expect(pushes()).toEqual([]); + }); +});