probe: downloadable executables for machines without Docker
ci / check (push) Successful in 59s
ci / images (push) Successful in 49s

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.
This commit is contained in:
iamdoubz committed 2026-10-21 12:57:04 -05:00
1 parent 65a0438dde
commit 4143f6d064
9 files changed
+232 -5

No files matched your search

+27 -1
View File
@@ -5,13 +5,14 @@
# Gitea: <gitea host>/<owner>/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 <major>.<minor> 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
+3 -2
View File
@@ -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
+13
View File
@@ -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 /
+94
View File
@@ -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'));
+4
View File
@@ -7,5 +7,9 @@
"scripts": {
"start": "node src/index.ts",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"esbuild": "0.28.2",
"postject": "1.0.0-alpha.6"
}
}
+35
View File
@@ -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
+25 -1
View File
@@ -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://<machine>: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
+8
View File
@@ -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.
+23 -1
View File
@@ -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: {}