From 4143f6d064bea8c1b80d0e25e19f47d63be78460 Mon Sep 17 00:00:00 2001 From: iamdoubz Date: Wed, 21 Oct 2026 12:57:04 -0500 Subject: [PATCH] probe: downloadable executables for machines without Docker Each release attaches selfphoneng-probe-linux-x64 and -arm64 (Node's single executable applications: esbuild bundle + postject into the official Node binary, checked against nodejs.org's SHASUMS256.txt), the bundle as selfphoneng-probe.cjs (Node 22+) and SHA256SUMS. Built in binary.Dockerfile with BuildKit's local output, so Gitea's runner can build it too. deploy/probe/selfphoneng-probe.service runs it under systemd, sandboxed. --- .github/workflows/release.yml | 28 +++++++- CHANGELOG.md | 5 +- apps/probe/binary.Dockerfile | 13 ++++ apps/probe/build-binary.mjs | 94 ++++++++++++++++++++++++++ apps/probe/package.json | 4 ++ deploy/probe/selfphoneng-probe.service | 35 ++++++++++ docs/admin-guide/network.md | 26 ++++++- docs/spec/08-security-ops.md | 8 +++ pnpm-lock.yaml | 24 ++++++- 9 files changed, 232 insertions(+), 5 deletions(-) create mode 100644 apps/probe/binary.Dockerfile create mode 100644 apps/probe/build-binary.mjs create mode 100644 deploy/probe/selfphoneng-probe.service diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b17e0ca..91d1702 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,13 +5,14 @@ # Gitea: //selfphoneng-{api,asterisk,probe} (secrets REGISTRY_USER + REGISTRY_TOKEN: # a personal access token with write:package; Gitea's job token can't push packages) # Tags: the version (0.0.1), plus . and latest; a pre-release gets the version and dev. +# The release also gets the probe as downloads (selfphoneng-probe-linux-{x64,arm64}, .cjs, SHA256SUMS). name: release on: release: types: [published] permissions: - contents: read + contents: write # attaching the probe downloads to the release packages: write jobs: @@ -102,3 +103,28 @@ jobs: done done echo "pushed: $p-{api,asterisk,probe}:{${{ steps.v.outputs.tags }}}" | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}" + - name: Probe downloads + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + # The probe without Docker (08 §2.6): a .cjs for Node 22+, and Linux executables with Node + # inside. Built in BuildKit and handed back as files, so it works on Gitea's runner too. + docker build -f apps/probe/binary.Dockerfile --output type=local,dest=probe-dist . + id="${{ github.event.release.id }}" + for f in probe-dist/*; do + name=$(basename "$f") + if [ "${{ steps.v.outputs.registry }}" = ghcr.io ]; then + curl -fsS -o /dev/null -X POST -H "Authorization: Bearer $GH_TOKEN" \ + -H 'Content-Type: application/octet-stream' --data-binary "@$f" \ + "https://uploads.github.com/repos/$GITHUB_REPOSITORY/releases/$id/assets?name=$name" + else + # The REGISTRY_TOKEN personal access token needs write:repository for this. + curl -fsS -o /dev/null -X POST -H "Authorization: token ${REGISTRY_TOKEN:-$GH_TOKEN}" \ + -F "attachment=@$f" \ + "$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/$id/assets?name=$name" + fi + echo "attached $name" | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}" + done diff --git a/CHANGELOG.md b/CHANGELOG.md index c2a274d..1db5891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,9 @@ tag (08 §3.1): `selfphoneng-api` and `selfphoneng-asterisk`, on ghcr.io and on - **System → Diagnostics:** checks what usually keeps phones off the network (public IP, DNS, local networks, the phone network limit, SIP listening, free audio ports), with how to fix each. A **Quick** or **Complete network test** checks the ports from the internet through a network - test probe (`apps/probe`, image `selfphoneng-probe`) you run outside your network; set its URL - in Settings → Network. + test probe you run outside your network; set its URL in Settings → Network. The probe comes as + a Docker image (`selfphoneng-probe`), and, for a machine without Docker, as a single Linux + executable (x64 and arm64) attached to each release, with a systemd unit. - The call audio port range is configurable: `RTP_PORT_START` and `RTP_PORT_END` (default 10000–20000). - Updating (Docker only): a new version backs up before it changes the database, and stops diff --git a/apps/probe/binary.Dockerfile b/apps/probe/binary.Dockerfile new file mode 100644 index 0000000..24f9ea9 --- /dev/null +++ b/apps/probe/binary.Dockerfile @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Builds the probe's downloadable files (build-binary.mjs) and hands them back, no image: +# docker build -f apps/probe/binary.Dockerfile --output type=local,dest=probe-dist . +FROM node:24-trixie-slim AS build +RUN apt-get update && apt-get install -y --no-install-recommends xz-utils \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /src +COPY . . +RUN corepack enable && pnpm install --frozen-lockfile --filter @selfphoneng/probe \ + && node apps/probe/build-binary.mjs + +FROM scratch +COPY --from=build /src/apps/probe/dist / diff --git a/apps/probe/build-binary.mjs b/apps/probe/build-binary.mjs new file mode 100644 index 0000000..31e0485 --- /dev/null +++ b/apps/probe/build-binary.mjs @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Builds the network test probe (08 §2.6) for machines without Docker: +// dist/selfphoneng-probe.cjs one file; runs with Node 22 or newer +// dist/selfphoneng-probe-linux-x64 standalone executables with Node inside (Node's +// dist/selfphoneng-probe-linux-arm64 single executable applications), nothing to install +// dist/SHA256SUMS +// The executables carry this Node's version, downloaded from nodejs.org and checked against its +// SHASUMS256.txt. Run on Linux (needs tar with xz): node apps/probe/build-binary.mjs +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmodSync, + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; +import postject from 'postject'; + +const here = dirname(fileURLToPath(import.meta.url)); +const dist = join(here, 'dist'); +const NAME = 'selfphoneng-probe'; +const version = process.version; +const sha256 = (buf) => createHash('sha256').update(buf).digest('hex'); + +rmSync(dist, { recursive: true, force: true }); +mkdirSync(dist); +const script = join(dist, `${NAME}.cjs`); +await build({ + entryPoints: [join(here, 'src/index.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node22', + outfile: script, + banner: { js: '#!/usr/bin/env node\n// SPDX-License-Identifier: AGPL-3.0-or-later' }, +}); +chmodSync(script, 0o755); + +const work = mkdtempSync(join(tmpdir(), 'sp-probe-')); +try { + const blob = join(work, 'sea.blob'); + writeFileSync( + join(work, 'sea.json'), + JSON.stringify({ + main: script, + output: blob, + disableExperimentalSEAWarning: true, + // Neither is portable between CPU architectures; the probe starts in milliseconds anyway. + useSnapshot: false, + useCodeCache: false, + }), + ); + execFileSync(process.execPath, ['--experimental-sea-config', join(work, 'sea.json')], { + stdio: 'inherit', + }); + + const base = `https://nodejs.org/dist/${version}`; + const sums = await (await fetch(`${base}/SHASUMS256.txt`)).text(); + for (const arch of ['x64', 'arm64']) { + const file = `node-${version}-linux-${arch}.tar.xz`; + const want = sums + .split('\n') + .find((l) => l.endsWith(` ${file}`)) + ?.split(' ')[0]; + const tarball = Buffer.from(await (await fetch(`${base}/${file}`)).arrayBuffer()); + if (!want || sha256(tarball) !== want) throw new Error(`${file}: checksum doesn't match`); + writeFileSync(join(work, file), tarball); + const inner = `node-${version}-linux-${arch}/bin/node`; + execFileSync('tar', ['-xJf', join(work, file), '-C', work, inner]); + const out = join(dist, `${NAME}-linux-${arch}`); + copyFileSync(join(work, inner), out); + await postject.inject(out, 'NODE_SEA_BLOB', readFileSync(blob), { + sentinelFuse: 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2', + }); + chmodSync(out, 0o755); + console.log(`${out} (Node ${version})`); + } +} finally { + rmSync(work, { recursive: true, force: true }); +} + +const files = [`${NAME}.cjs`, `${NAME}-linux-x64`, `${NAME}-linux-arm64`]; +writeFileSync( + join(dist, 'SHA256SUMS'), + files.map((f) => `${sha256(readFileSync(join(dist, f)))} ${f}\n`).join(''), +); +console.log(readFileSync(join(dist, 'SHA256SUMS'), 'utf8')); diff --git a/apps/probe/package.json b/apps/probe/package.json index 95726e7..e3dd002 100644 --- a/apps/probe/package.json +++ b/apps/probe/package.json @@ -7,5 +7,9 @@ "scripts": { "start": "node src/index.ts", "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "esbuild": "0.28.2", + "postject": "1.0.0-alpha.6" } } diff --git a/deploy/probe/selfphoneng-probe.service b/deploy/probe/selfphoneng-probe.service new file mode 100644 index 0000000..8393f76 --- /dev/null +++ b/deploy/probe/selfphoneng-probe.service @@ -0,0 +1,35 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# The network test probe (08 §2.6) as a service, without Docker. With the downloaded executable at +# /usr/local/bin/selfphoneng-probe: +# sudo cp selfphoneng-probe.service /etc/systemd/system/ +# sudo systemctl enable --now selfphoneng-probe +# sudo ufw allow 8080/tcp +[Unit] +Description=SelfPhoneNG network test probe +After=network-online.target +Wants=network-online.target + +[Service] +ExecStart=/usr/local/bin/selfphoneng-probe +Environment=PORT=8080 +# Behind your own HTTPS reverse proxy that sets X-Forwarded-For: PORT to a local one, and +#Environment=TRUST_PROXY=true +Restart=always +RestartSec=5 +# It needs nothing but the network: no files, no privileges. +DynamicUser=yes +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_INET AF_INET6 +RestrictNamespaces=yes +LockPersonality=yes +MemoryMax=200M + +[Install] +WantedBy=multi-user.target diff --git a/docs/admin-guide/network.md b/docs/admin-guide/network.md index 042ba46..663b6a4 100644 --- a/docs/admin-guide/network.md +++ b/docs/admin-guide/network.md @@ -74,7 +74,31 @@ public address back in, or route it without going through the port forwards. - **Complete network test**: also SIP over TLS (if on), the browser phone's `/ws`, and a sample of the call audio ports (the server listens on them while the probe sends to them). -To run a probe, on any machine outside your network (a small VPS is plenty): +To run a probe, on any machine outside your network (a small VPS is plenty). It needs about +50 MB of memory and one open TCP port (8080). + +**Without Docker:** each release has the probe as a single executable for Linux, with everything it +needs inside (`selfphoneng-probe-linux-x64`, or `-arm64` for ARM machines), next to `SHA256SUMS`. +With Node 22 or newer installed you can take the small `selfphoneng-probe.cjs` instead. + +```sh +v=v0.0.3 # the release +curl -fLO https://git.dou.bet/spng/selfphoneng/releases/download/$v/selfphoneng-probe-linux-x64 +curl -fLO https://git.dou.bet/spng/selfphoneng/releases/download/$v/SHA256SUMS +sha256sum --check --ignore-missing SHA256SUMS +sudo install -m 755 selfphoneng-probe-linux-x64 /usr/local/bin/selfphoneng-probe +curl -fLO https://git.dou.bet/spng/selfphoneng/raw/tag/$v/deploy/probe/selfphoneng-probe.service +sudo cp selfphoneng-probe.service /etc/systemd/system/ +sudo systemctl enable --now selfphoneng-probe +sudo ufw allow 8080/tcp +``` + +(On GitHub, the downloads are at `https://github.com/iamdoubz/selfphoneng/releases/download/$v/…`.) +To try it first without the service, run `./selfphoneng-probe-linux-x64` and open +`http://:8080/healthz`. `PORT` changes the port; behind your own HTTPS reverse proxy +that sets X-Forwarded-For, also set `TRUST_PROXY=true`. + +**With Docker:** ```sh docker compose -f deploy/probe/compose.yml up -d diff --git a/docs/spec/08-security-ops.md b/docs/spec/08-security-ops.md index b3d0f3f..49231c5 100644 --- a/docs/spec/08-security-ops.md +++ b/docs/spec/08-security-ops.md @@ -107,6 +107,14 @@ how to fix it. `apps/probe` (no dependencies, its own image `selfphoneng-probe`, `deploy/probe/compose.yml`), run outside the network; `network.probeUrl` points at it. A server can't test its own ports: hairpin NAT is missing or skips the port forwards, and browsers refuse port 5060. + - Without Docker (owner, 2026-10-21): each release also attaches `selfphoneng-probe-linux-x64` + and `-arm64` (Node's single executable applications: esbuild bundles the probe into one CJS + file, postject injects it into that Node's official binary, checked against nodejs.org's + SHASUMS256.txt), the bundle itself (`selfphoneng-probe.cjs`, Node 22+) and `SHA256SUMS`. + `apps/probe/build-binary.mjs`, run in `binary.Dockerfile` with BuildKit's local output (so it + works on Gitea's runner, where the job's checkout can't be bind-mounted). + `deploy/probe/selfphoneng-probe.service` runs it under systemd with `DynamicUser` and a + read-only system. About 45 MB of memory. - `POST /v1/check {host, tests}`: the probe tests **only the address the request came from** (the last X-Forwarded-For entry with `TRUST_PROXY=true`), so it can't be aimed at anyone else; 20 tests at most, 10 checks per address per 10 minutes, 4 s per test. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44156ec..5f3a79a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,7 +88,14 @@ importers: specifier: ^0.31.11 version: 0.31.11 - apps/probe: {} + apps/probe: + devDependencies: + esbuild: + specifier: 0.28.2 + version: 0.28.2 + postject: + specifier: 1.0.0-alpha.6 + version: 1.0.0-alpha.6 apps/web: dependencies: @@ -1521,6 +1528,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + content-disposition@3.0.0: resolution: {integrity: sha512-ZH/0Xs9rMIFWCOmGdmS9eHBTF62qqQYNz4nVjQhkdIO/a0fCP4UIM3mRz/wiqL0L14YgAz/1xio4OaSY4+ON/A==} engines: {node: '>=22'} @@ -2282,6 +2293,11 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + postject@1.0.0-alpha.6: + resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} + engines: {node: '>=14.0.0'} + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -3691,6 +3707,8 @@ snapshots: color-name@1.1.4: {} + commander@9.5.0: {} + content-disposition@3.0.0: {} convert-source-map@2.0.0: {} @@ -4370,6 +4388,10 @@ snapshots: dependencies: xtend: 4.0.2 + postject@1.0.0-alpha.6: + dependencies: + commander: 9.5.0 + prelude-ls@1.2.1: {} prettier@3.9.10: {}