## 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.
223 lines
8.5 KiB
JavaScript
223 lines
8.5 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
realpathSync,
|
|
} from "node:fs";
|
|
import { basename, dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { CLIENT_PACKAGE_TARGETS, clientPackageTarget, clientGoEnvironment } from "./client-package-targets.mjs";
|
|
|
|
// Every Go command embeds the Vite output, so the build, vet and test steps all
|
|
// 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", "--race"].includes(mode)) {
|
|
throw new Error("Usage: node scripts/check-client.mjs [--all|--core|--capture-only|--race]");
|
|
}
|
|
|
|
function run(command, args, options = {}) {
|
|
const result = spawnSync(command, args, {
|
|
cwd: options.cwd ?? root,
|
|
env: options.env ?? process.env,
|
|
encoding: "utf8",
|
|
stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
});
|
|
if (result.error) throw result.error;
|
|
if (result.status !== 0) {
|
|
const detail = options.capture
|
|
? (result.stderr || result.stdout).trim()
|
|
: "";
|
|
throw new Error(`${basename(command)} failed${detail ? `: ${detail}` : ""}`);
|
|
}
|
|
return options.capture ? result.stdout.trim() : "";
|
|
}
|
|
|
|
function buildWebAssets() {
|
|
if (existsSync(join(root, CLIENT_INDEX))) return;
|
|
if (process.platform === "win32") {
|
|
run(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "npm run build:client"]);
|
|
return;
|
|
}
|
|
run("npm", ["run", "build:client"]);
|
|
}
|
|
|
|
function checkCore() {
|
|
buildWebAssets();
|
|
const go = process.env.PIIK_GO?.trim() || "go";
|
|
const goRoot = run(go, ["env", "GOROOT"], { capture: true });
|
|
const gofmt = process.env.PIIK_GOFMT?.trim() ||
|
|
join(goRoot, "bin", process.platform === "win32" ? "gofmt.exe" : "gofmt");
|
|
const unformatted = run(gofmt, ["-l", "."], { capture: true });
|
|
if (unformatted) {
|
|
throw new Error(`Go source is not formatted:\n${unformatted}`);
|
|
}
|
|
runClientTests(go);
|
|
run(go, ["vet", ...GO_TEST_PACKAGES]);
|
|
|
|
const buildRoot = join(root, "build", "client-check");
|
|
mkdirSync(buildRoot, { recursive: true });
|
|
const buildTargets = CLIENT_PACKAGE_TARGETS.filter((target) => {
|
|
if (target.cgo && process.platform !== target.nodePlatform) {
|
|
process.stderr.write(`Skipped ${target.id}: its cgo dependencies require a native macOS runner and SDK; Darwin acceptance remains pending.\n`);
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
const builds = buildTargets.flatMap((target) =>
|
|
["piik-app", "piik-peer-gate"].map((command) => ({ target, command })),
|
|
);
|
|
builds.push({ target: SERVER_TARGET, command: "piik-server" });
|
|
for (const { target, command } of builds) {
|
|
const outputName = target.goos === "windows"
|
|
? `${command}.exe`
|
|
: `${command}-${target.id}`;
|
|
run(go, ["build", "-trimpath", "-o", join(buildRoot, outputName), `./cmd/${command}`], {
|
|
env: clientGoEnvironment(target),
|
|
});
|
|
}
|
|
}
|
|
|
|
function runClientTests(go, packagesToTest = GO_TEST_PACKAGES, flags = []) {
|
|
if (process.platform !== "win32") {
|
|
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}}', ...packagesToTest], { capture: true })
|
|
.split(/\r?\n/)
|
|
.map((value) => value.trim())
|
|
.filter(Boolean)
|
|
.map((value) => JSON.parse(value));
|
|
const stableRoot = join(root, "build", "client-check");
|
|
mkdirSync(stableRoot, { recursive: true });
|
|
for (const [entry, directory] of packages) {
|
|
const binary = join(stableRoot, `${basename(entry)}.test.exe`);
|
|
run(go, ["test", ...flags, "-c", "-o", binary, entry]);
|
|
run(binary, flags.includes("-race") ? ["-test.timeout=2m"] : [], { cwd: directory });
|
|
}
|
|
}
|
|
|
|
function checkPlatformCapture() {
|
|
if (!["win32", "darwin", "linux"].includes(process.platform)) {
|
|
if (mode === "--capture-only") {
|
|
throw new Error("No native capture check exists for this platform");
|
|
}
|
|
return;
|
|
}
|
|
const buildRoot = join(root, "build", "client-check");
|
|
mkdirSync(buildRoot, { recursive: true });
|
|
let executable;
|
|
if (process.platform === "win32") {
|
|
const systemPowerShell = join(
|
|
process.env.SystemRoot || "C:\\Windows",
|
|
"System32",
|
|
"WindowsPowerShell",
|
|
"v1.0",
|
|
"powershell.exe",
|
|
);
|
|
const modernPowerShell = join(
|
|
process.env.ProgramFiles || "C:\\Program Files",
|
|
"PowerShell",
|
|
"7",
|
|
"pwsh.exe",
|
|
);
|
|
const powershell = process.env.PIIK_POWERSHELL?.trim() ||
|
|
(existsSync(modernPowerShell)
|
|
? modernPowerShell
|
|
: (existsSync(systemPowerShell) ? systemPowerShell : "pwsh"));
|
|
run(powershell, [
|
|
"-NoProfile",
|
|
"-ExecutionPolicy",
|
|
"Bypass",
|
|
"-File",
|
|
join(captureRoot, "windows", "build.ps1"),
|
|
"-OutputDirectory",
|
|
buildRoot,
|
|
"-Check",
|
|
]);
|
|
executable = join(buildRoot, "piik-capture.exe");
|
|
} else if (process.platform === "darwin") {
|
|
run("sh", [join(captureRoot, "darwin", "build.sh"), buildRoot]);
|
|
executable = join(buildRoot, "piik-capture");
|
|
} else {
|
|
run("sh", [join(captureRoot, "linux", "build.sh"), buildRoot]);
|
|
executable = join(buildRoot, "piik-capture");
|
|
}
|
|
if (!existsSync(executable)) {
|
|
throw new Error("Native capture build did not produce its executable");
|
|
}
|
|
if (process.platform === "darwin") {
|
|
run(executable, ["--self-test"]);
|
|
}
|
|
const raw = run(executable, ["--probe"], { capture: true });
|
|
const probe = JSON.parse(raw);
|
|
const expectedPlatform = process.platform === "win32" ? "windows" : process.platform;
|
|
if (
|
|
probe?.protocol !== 7 ||
|
|
probe.platform !== expectedPlatform ||
|
|
typeof probe.platformBuild !== "string" ||
|
|
typeof probe.videoCapture !== "boolean" ||
|
|
typeof probe.softwareVP8 !== "boolean" ||
|
|
typeof probe.processAudio !== "boolean" ||
|
|
typeof probe.systemAudio !== "boolean" ||
|
|
!Array.isArray(probe.adapters) ||
|
|
probe.adapters.some((adapter) =>
|
|
!Number.isInteger(adapter?.index) ||
|
|
typeof adapter?.name !== "string" ||
|
|
typeof adapter?.identity !== "string" ||
|
|
!Array.isArray(adapter?.hardwareH264) ||
|
|
adapter.hardwareH264.some((encoder) =>
|
|
!Number.isInteger(encoder?.index) ||
|
|
typeof encoder?.name !== "string" ||
|
|
typeof encoder?.identity !== "string"
|
|
)
|
|
)
|
|
) {
|
|
throw new Error("Native capture probe returned an invalid contract");
|
|
}
|
|
const sources = JSON.parse(run(executable, ["--list"], { capture: true }));
|
|
if (!Array.isArray(sources) || sources.some((target) =>
|
|
!["window", "display", "picker"].includes(target?.kind) ||
|
|
!/^[1-9][0-9]{0,19}$/.test(target?.sourceId) ||
|
|
typeof target?.title !== "string" ||
|
|
(target.kind === "window" &&
|
|
(!Number.isInteger(target?.pid) ||
|
|
!/^[1-9][0-9]{0,19}$/.test(target?.creationTime))) ||
|
|
(target.kind === "display" &&
|
|
(target?.pid !== undefined || target?.creationTime !== undefined)) ||
|
|
(target.kind === "picker" &&
|
|
(target?.pid !== undefined || target?.creationTime !== undefined))
|
|
)) {
|
|
throw new Error("Windows capture process returned an invalid source list");
|
|
}
|
|
}
|
|
|
|
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");
|