From 7be93e81a19ecdaba6c018135204a927ce9a4ad6 Mon Sep 17 00:00:00 2001 From: Talon Date: Wed, 16 Sep 2026 17:08:17 +0200 Subject: [PATCH] Package managed server for Linux production --- .dockerignore | 3 + Dockerfile | 111 ++++-------------- PROGRESS.md | 14 +++ deploy/linux/install.sh | 7 ++ deploy/linux/smoke.sh | 23 ++++ deploy/linux/voicecat.service | 42 +++++-- docker-compose.yml | 15 ++- docs/deployment.md | 45 +++++-- dotnet/soak-server.ps1 | 41 +++++++ .../packages.publish.linux-x64.lock.json | 31 +++++ .../packages.publish.linux-x64.lock.json | 26 ++++ dotnet/src/VoiceCat.Server/ServerCommand.cs | 40 ++++++- .../VoiceCat.Server/VoiceCat.Server.csproj | 1 + .../packages.publish.linux-x64.lock.json | 90 ++++++++++++++ .../VoiceCat.Tests/ProductionServerTests.cs | 14 +++ 15 files changed, 384 insertions(+), 119 deletions(-) create mode 100755 deploy/linux/install.sh create mode 100755 deploy/linux/smoke.sh create mode 100644 dotnet/soak-server.ps1 create mode 100644 dotnet/src/VoiceCat.Crypto/packages.publish.linux-x64.lock.json create mode 100644 dotnet/src/VoiceCat.Protocol/packages.publish.linux-x64.lock.json create mode 100644 dotnet/src/VoiceCat.Server/packages.publish.linux-x64.lock.json diff --git a/.dockerignore b/.dockerignore index 305007d..2b25ffb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,9 @@ # Previous build outputs build/ +dotnet/artifacts/ +dotnet/**/bin/ +dotnet/**/obj/ # Native GUI client code (Swift/Xcode, C#/WinForms) — server build doesn't need these clients/ diff --git a/Dockerfile b/Dockerfile index 973447c..07b0188 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,98 +1,27 @@ -# syntax=docker/dockerfile:1 -# ───────────────────────────────────────────────────────────────────────────── -# Stage 1 — Build -# ───────────────────────────────────────────────────────────────────────────── -FROM ubuntu:24.04 AS builder - -ENV DEBIAN_FRONTEND=noninteractive - -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - cmake \ - ninja-build \ - git \ - curl \ - zip \ - unzip \ - tar \ - pkg-config \ - ca-certificates \ - autoconf \ - autoconf-archive \ - automake \ - libtool \ - nasm \ - python3 \ - && rm -rf /var/lib/apt/lists/* - -# Fetch vcpkg at the exact commit pinned in vcpkg.json builtin-baseline. -# vcpkg resolves baselines via `git show :versions/baseline.json`, so it -# needs a real .git repo — not a tarball. A single shallow fetch is fast (~30 MB) -# and gives vcpkg exactly what it needs. -ARG VCPKG_COMMIT=d46283cf33cf5de7bd88e12156ce03882be1f179 -RUN git init /vcpkg \ - && git -C /vcpkg remote add origin https://github.com/microsoft/vcpkg.git \ - && git -C /vcpkg fetch --depth=1 origin "${VCPKG_COMMIT}" \ - && git -C /vcpkg checkout FETCH_HEAD \ - && /vcpkg/bootstrap-vcpkg.sh -disableMetrics -ENV VCPKG_ROOT=/vcpkg -ENV VCPKG_DISABLE_METRICS=1 - +# syntax=docker/dockerfile:1.7 +FROM mcr.microsoft.com/dotnet/sdk:10.0.203-noble AS build WORKDIR /src -COPY . . +COPY global.json Directory.Build.props Directory.Build.targets ./ +COPY core/proto/voicecat.proto core/proto/voicecat.proto +COPY dotnet/ dotnet/ +RUN dotnet restore dotnet/src/VoiceCat.Server/VoiceCat.Server.csproj -r linux-x64 --locked-mode -p:NuGetLockFilePath=packages.publish.linux-x64.lock.json +RUN dotnet publish dotnet/src/VoiceCat.Server/VoiceCat.Server.csproj -c Release -r linux-x64 --self-contained true --no-restore \ + -p:PublishSingleFile=true -p:PublishTrimmed=false -p:IncludeNativeLibrariesForSelfExtract=true -o /out \ + && mkdir /empty-data -ARG TARGETARCH -# Three cache mounts: -# downloads — source tarballs (~200 MB); safe to share across arches -# vcpkg-cache — vcpkg binary cache (pre-built .zip archives per package ABI); -# restores packages in seconds on subsequent builds instead of -# recompiling. Scoped by arch so amd64/arm64 don't collide. -# buildtrees — NOT cached; deleted at end of layer so neither the Docker -# image nor the BuildKit cache accumulates several GB of -# intermediate build artifacts. -ENV VCPKG_BINARY_SOURCES="clear;files,/vcpkg-cache,readwrite" -RUN --mount=type=cache,target=/vcpkg/downloads \ - --mount=type=cache,target=/vcpkg-cache,id=vc-bin-${TARGETARCH} \ - cmake --preset server-release \ - && cmake --build --preset server-release \ - && rm -rf /vcpkg/buildtrees - -# ───────────────────────────────────────────────────────────────────────────── -# Stage 2 — Export (binary-only, used by scripts/build-linux-binaries.sh) -# docker buildx build --target export --output type=local,dest=./dist/linux-amd64 . -# ───────────────────────────────────────────────────────────────────────────── FROM scratch AS export -COPY --from=builder /src/build/server-release/bin/voicecat-server /voicecat-server -COPY --from=builder /src/build/server-release/bin/voicecat-admin /voicecat-admin +COPY --from=build /out/VoiceCat.Server /VoiceCat.Server -# ───────────────────────────────────────────────────────────────────────────── -# Stage 3 — Runtime (default stage — must be last) -# ───────────────────────────────────────────────────────────────────────────── -FROM ubuntu:24.04 AS runtime - -ENV DEBIAN_FRONTEND=noninteractive - -# ca-certificates is useful if the server ever makes outbound TLS calls; also -# satisfies any mbedTLS system-CA lookup at runtime. -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ - && rm -rf /var/lib/apt/lists/* - -RUN groupadd -r voicecat && useradd -r -g voicecat -s /sbin/nologin voicecat - -COPY --from=builder /src/build/server-release/bin/voicecat-server /usr/local/bin/voicecat-server -COPY --from=builder /src/build/server-release/bin/voicecat-admin /usr/local/bin/voicecat-admin - -RUN mkdir -p /data && chown voicecat:voicecat /data - -USER voicecat - -# Persistent state: Ed25519 identity key, self-signed TLS cert, SQLite database. +FROM mcr.microsoft.com/dotnet/runtime-deps:10.0.10-noble-chiseled-extra AS runtime +WORKDIR /app +COPY --from=build --chown=1654:1654 /out/ ./ +COPY --from=build --chown=1654:1654 /empty-data/ /data/ +USER 1654:1654 VOLUME ["/data"] - -# Control (TLS 1.3) and media (ChaCha20-Poly1305) share one port number on TCP+UDP. EXPOSE 8384/tcp EXPOSE 8384/udp - -ENTRYPOINT ["/usr/local/bin/voicecat-server"] -CMD ["--data-dir", "/data"] +ENV VOICECAT_DATA_DIR=/data \ + VOICECAT_BIND_ADDRESS=0.0.0.0 \ + VOICECAT_BIND_PORT=8384 +HEALTHCHECK --interval=30s --timeout=6s --start-period=10s --retries=3 CMD ["/app/VoiceCat.Server", "--health-check", "127.0.0.1:8384"] +ENTRYPOINT ["/app/VoiceCat.Server"] diff --git a/PROGRESS.md b/PROGRESS.md index 90d2e52..03b33a9 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,6 +10,20 @@ up instantly. Newest status at the top. ## ▶ Where we left off / next action +- **Done (2026-09-16): Linux production packaging checkpoint.** Added a real TLS 1.3 + `--health-check` with optional certificate pin verification. Linux x64 now has separate + locked self-contained publish graphs and invariant-globalization startup without a system + ICU dependency. Replaced the native Docker build with a non-root managed multi-stage build + on Microsoft's chiseled runtime-dependencies image; Compose uses a read-only root, + capability drop and no-new-privileges. Added a hardened DynamicUser systemd unit, install + helper, Linux smoke and reusable concurrent text/voice soak driver. **Verified:** the + published ELF starts in WSL, completes TLS health with its emitted pin, creates mode-0700 + state, and exits cleanly on SIGTERM; `systemd-analyze verify` accepts the unit. The + published Windows server completed four soak cycles / 16 independent client sessions. + Dockerfile execution could not be checked because the local Docker daemon is unavailable. + **Next:** run the 30-minute+ soak and container build in release infrastructure, publish/ + sign the image, and perform Windows NVDA/listen gates. Then begin the C# AppKit client port. + - **Done (2026-09-16): Managed CLI and cross-generation client exit.** Added `VoiceCat.Cli` with interactive channel text, TOFU, channel selection and deterministic headless text/tone verification. Independent process tests run two managed CLIs in channels 1 and 2 diff --git a/deploy/linux/install.sh b/deploy/linux/install.sh new file mode 100755 index 0000000..dae6468 --- /dev/null +++ b/deploy/linux/install.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu +install -d -m 0755 /usr/local/lib/voicecat +install -m 0755 VoiceCat.Server /usr/local/lib/voicecat/VoiceCat.Server +install -m 0644 voicecat.service /etc/systemd/system/voicecat.service +systemctl daemon-reload +systemctl enable --now voicecat.service diff --git a/deploy/linux/smoke.sh b/deploy/linux/smoke.sh new file mode 100755 index 0000000..034c4c0 --- /dev/null +++ b/deploy/linux/smoke.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -eu +server=${1:-./VoiceCat.Server} +work=$(mktemp -d) +pid= +cleanup() { if [ -n "${pid}" ] && kill -0 "$pid" 2>/dev/null; then kill -TERM "$pid"; wait "$pid" || true; fi; rm -rf "$work"; } +trap cleanup EXIT INT TERM +"$server" --data-dir "$work/data" --bind 127.0.0.1 --port 0 >"$work/server.log" 2>"$work/server.err" & +pid=$! +i=0 +while ! grep -q '"event":"ready"' "$work/server.log"; do + if ! kill -0 "$pid" 2>/dev/null; then cat "$work/server.err" >&2; exit 1; fi + i=$((i + 1)); [ "$i" -lt 100 ] || { echo "server readiness timeout" >&2; exit 1; } + sleep 0.1 +done +port=$(sed -n 's/.*"port":\([0-9][0-9]*\).*/\1/p' "$work/server.log" | head -n 1) +fingerprint=$(sed -n 's/.*"certificate_fingerprint":"\([0-9A-F]*\)".*/\1/p' "$work/server.log" | head -n 1) +"$server" --health-check "127.0.0.1:$port" --expect-fingerprint "$fingerprint" | grep -q '"status":"healthy"' +[ "$(stat -c '%a' "$work/data")" = 700 ] +kill -TERM "$pid" +wait "$pid" +pid= +echo "Linux publish startup, TLS health, private data permissions and SIGTERM shutdown passed." diff --git a/deploy/linux/voicecat.service b/deploy/linux/voicecat.service index 95c6cb8..ecff7d8 100644 --- a/deploy/linux/voicecat.service +++ b/deploy/linux/voicecat.service @@ -1,23 +1,39 @@ [Unit] -Description=VoiceCat Voice & Text Server -Documentation=https://github.com/org/voicecat -After=network.target +Description=VoiceCat encrypted voice server +Documentation=https://github.com/Talon/voice-cat/blob/main/docs/deployment.md +Wants=network-online.target +After=network-online.target [Service] Type=simple -User=voicecat -Group=voicecat -ExecStart=/usr/local/bin/voicecat-server --data-dir /var/lib/voicecat +DynamicUser=yes +StateDirectory=voicecat +StateDirectoryMode=0700 +ExecStart=/usr/local/lib/voicecat/VoiceCat.Server --data-dir /var/lib/voicecat Restart=on-failure RestartSec=5s -# Allow binding port 8384 without running as root -AmbientCapabilities=CAP_NET_BIND_SERVICE -# Harden the process -NoNewPrivileges=true +TimeoutStopSec=15s +NoNewPrivileges=yes +PrivateDevices=yes +PrivateTmp=yes ProtectSystem=strict -ProtectHome=true -ReadWritePaths=/var/lib/voicecat -PrivateTmp=true +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +ProtectProc=invisible +ProcSubset=pid +RestrictSUIDSGID=yes +RestrictRealtime=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +CapabilityBoundingSet= +AmbientCapabilities= +UMask=0077 [Install] WantedBy=multi-user.target diff --git a/docker-compose.yml b/docker-compose.yml index 4785b56..ea059da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,23 @@ services: voicecat: build: . - image: ghcr.io/org/voicecat:latest - container_name: voicecat + image: voicecat:local restart: unless-stopped + init: true ports: - "8384:8384/tcp" - "8384:8384/udp" volumes: - voicecat-data:/data - # Override CMD to customise the server name; add --no-guests to require accounts. - command: ["--data-dir", "/data", "--name", "My VoiceCat Server"] + environment: + VOICECAT_SERVER_NAME: "My VoiceCat Server" + read_only: true + tmpfs: + - /tmp:size=16m,mode=1777 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true volumes: voicecat-data: diff --git a/docs/deployment.md b/docs/deployment.md index 5af8f35..f220298 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -2,11 +2,12 @@ ## Managed server deployment checkpoint -The .NET server preserves protocol v2 and the native schema/credentials. Publish the -Windows self-contained executable (no installed .NET runtime required): +The .NET server preserves protocol v2 and the native schema/credentials. Publish a +self-contained executable (no installed .NET runtime required): ```powershell ./dotnet/publish-server.ps1 +./dotnet/publish-server.ps1 -Runtime linux-x64 ./dotnet/artifacts/server/win-x64/VoiceCat.Server.exe --help ./dotnet/artifacts/server/win-x64/VoiceCat.Server.exe account add Operator --admin --data-dir ./voicecat-data ./dotnet/artifacts/server/win-x64/VoiceCat.Server.exe --data-dir ./voicecat-data --allow-guests false @@ -42,6 +43,8 @@ Bind accepts IP literals; IPv6 listeners are IPv6-only. Open/forward both protoc An exclusive data-directory instance lock prevents duplicate managed server processes. Ctrl+C and Unix SIGINT/SIGTERM stop all transport tasks; shutdown has a ten-second deadline. Fatal listener/media/reaper failure exits the host rather than leaving a broken listener. +`--health-check HOST:PORT` performs a real protocol TLS 1.3 handshake and emits JSON. +Automation can add `--expect-fingerprint SHA256` to verify the persisted certificate. Password authentication is limited before Argon2 by source address and username across connections: burst 5, refill one attempt per ten seconds. Starting at three failed attempts, @@ -49,11 +52,32 @@ backoff grows from one to thirty seconds. Success clears backoff but does not re tokens. State is bounded to 4096 keys; idle entries retire after ten minutes when full. Throttle and credential failures share the generic auth error. Limits are process-local. -The publish script uses separate runtime lock files so deployment and development restore -graphs remain reproducible. The checked deployment target is currently Windows x64; -Linux container publishing, service packaging, TOML/reload support and long-running -operational validation remain before broad production rollout. The native deployment -paths and planned operational features below remain available as the migration oracle. +The publish script uses separate per-RID lock files so deployment and development restore +graphs remain reproducible. Windows x64 and Linux x64 self-contained outputs are checked. +The Linux smoke starts the published ELF, validates TLS health and the pin, checks mode 0700 +data creation, sends SIGTERM and requires a clean exit: + +```bash +sh deploy/linux/smoke.sh dotnet/artifacts/server/linux-x64/VoiceCat.Server +``` + +`deploy/linux/voicecat.service` runs under a systemd dynamic user with a private state +directory, no capabilities and filesystem/kernel hardening. Install a published binary and +the unit from `deploy/linux/` as root, or use the helper there. The default Dockerfile now +builds the managed server with locked packages and runs it as UID 1654 in Microsoft's +chiseled .NET runtime-dependencies image. Compose drops all capabilities, sets the root +filesystem read-only and persists only `/data`. + +For operational endurance against an already running server: + +```powershell +./dotnet/soak-server.ps1 -HostName 127.0.0.1 -Port 8384 -Minutes 30 -Pairs 4 +``` + +Every cycle creates independent managed processes that exchange text and decoded voice. +A short 16-session published-server soak is part of this checkpoint. A release candidate +still needs the long soak on its target host. TOML/reload and signed image publication remain +before broad production rollout. The product goal: someone looks at this and thinks *"oh, I (or my agent) can stand this up in a few minutes."* Everything below is in service of that. Three install paths, all @@ -64,11 +88,14 @@ in a few minutes."* Everything below is in service of that. Three install paths, ### A. Docker (recommended) ```bash +docker build -t voicecat:local . docker run -d --name voicecat \ -p 8384:8384/tcp \ # control (TLS 1.3) -p 8384:8384/udp \ # media (encrypted) -v voicecat-data:/data \ - ghcr.io//voicecat:latest + --read-only --tmpfs /tmp:rw,noexec,nosuid,size=16m \ + --cap-drop ALL --security-opt no-new-privileges \ + voicecat:local ``` That's the whole thing. On first start it generates its Ed25519 identity + self-signed @@ -192,7 +219,7 @@ enabled). ## 5. Operational niceties (planned, not blocking v1) -- `voicecat-server --print-fingerprint` and a `/healthz` TCP check. +- Signed, multi-architecture image publication. - Graceful reload of `server.toml` on `SIGHUP`. - `voicecat-admin` (see §3a) also handles bans and channel admin, talking to the same SQLite file or a running server. diff --git a/dotnet/soak-server.ps1 b/dotnet/soak-server.ps1 new file mode 100644 index 0000000..787eb7c --- /dev/null +++ b/dotnet/soak-server.ps1 @@ -0,0 +1,41 @@ +param( + [string]$HostName = "127.0.0.1", + [int]$Port = 8384, + [double]$Minutes = 30, + [int]$Pairs = 2, + [string]$CliDll = "$PSScriptRoot/src/VoiceCat.Cli/bin/Release/net10.0/VoiceCat.Cli.dll" +) +$ErrorActionPreference = "Stop" +if ($Minutes -le 0 -or $Pairs -lt 1) { throw "Minutes and Pairs must be positive." } +$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) +$work = [IO.Path]::GetFullPath((Join-Path $tempRoot ("voicecat-soak-" + [Guid]::NewGuid().ToString("N")))) +if (-not $work.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase) -or (Split-Path $work -Leaf) -notlike 'voicecat-soak-*') { throw "Unsafe soak workspace path." } +[IO.Directory]::CreateDirectory($work) | Out-Null +$deadline = [DateTime]::UtcNow.AddMinutes($Minutes) +$cycles = 0 +try { + while ([DateTime]::UtcNow -lt $deadline) { + $processes = @() + for ($pair = 0; $pair -lt $Pairs; $pair++) { + $stamp = "$cycles-$pair" + foreach ($side in 0,1) { + $name = "Soak-$stamp-$side"; $send = "message-$stamp-$side"; $expect = "message-$stamp-$([int](1-$side))" + $start = [Diagnostics.ProcessStartInfo]::new("dotnet") + $start.UseShellExecute = $false; $start.RedirectStandardOutput = $true; $start.RedirectStandardError = $true; $start.CreateNoWindow = $true + $arguments = @($CliDll,"--host",$HostName,"--port","$Port","--nickname",$name,"--pins",(Join-Path $work "$name.pins"),"--trust-first","--voice","--expect-voice","--send-text",$send,"--expect-text",$expect,"--start-delay-ms","1000","--timeout-seconds","20") + $start.Arguments = ($arguments | ForEach-Object { '"' + $_.Replace('"','\"') + '"' }) -join ' ' + $processes += [Diagnostics.Process]::Start($start) + } + } + foreach ($process in $processes) { + $stdout = $process.StandardOutput.ReadToEndAsync(); $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(30000)) { $process.Kill($true); throw "Soak client timed out." } + if ($process.ExitCode -ne 0) { throw "Soak client failed: $($stderr.Result) $($stdout.Result)" } + $process.Dispose() + } + $cycles++ + Write-Host "Completed soak cycle $cycles ($($Pairs * 2) clients)." + } + Write-Host "Soak passed: $cycles cycles, $($cycles * $Pairs * 2) client sessions." +} +finally { if (Test-Path $work) { Remove-Item -LiteralPath $work -Recurse -Force } } diff --git a/dotnet/src/VoiceCat.Crypto/packages.publish.linux-x64.lock.json b/dotnet/src/VoiceCat.Crypto/packages.publish.linux-x64.lock.json new file mode 100644 index 0000000..0a58cc2 --- /dev/null +++ b/dotnet/src/VoiceCat.Crypto/packages.publish.linux-x64.lock.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "BouncyCastle.Cryptography": { + "type": "Direct", + "requested": "[2.6.2, )", + "resolved": "2.6.2", + "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw==" + }, + "Google.Protobuf": { + "type": "Transitive", + "resolved": "3.36.1", + "contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ==" + }, + "voicecat.protocol": { + "type": "Project", + "dependencies": { + "Google.Protobuf": "[3.36.1, )" + } + } + }, + "net10.0/linux-x64": {} + } +} \ No newline at end of file diff --git a/dotnet/src/VoiceCat.Protocol/packages.publish.linux-x64.lock.json b/dotnet/src/VoiceCat.Protocol/packages.publish.linux-x64.lock.json new file mode 100644 index 0000000..6afa3c4 --- /dev/null +++ b/dotnet/src/VoiceCat.Protocol/packages.publish.linux-x64.lock.json @@ -0,0 +1,26 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Google.Protobuf": { + "type": "Direct", + "requested": "[3.36.1, )", + "resolved": "3.36.1", + "contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ==" + }, + "Grpc.Tools": { + "type": "Direct", + "requested": "[2.83.0, )", + "resolved": "2.83.0", + "contentHash": "vK2Go/83W0v2Nn7tTP9fGrX4IjmOa93s3M0SZeFimU1vIIr2wL9yNJlIyK21y85SGm3++JncB8IF751cjoLHuQ==" + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw==" + } + }, + "net10.0/linux-x64": {} + } +} \ No newline at end of file diff --git a/dotnet/src/VoiceCat.Server/ServerCommand.cs b/dotnet/src/VoiceCat.Server/ServerCommand.cs index 8b99c7c..852167a 100644 --- a/dotnet/src/VoiceCat.Server/ServerCommand.cs +++ b/dotnet/src/VoiceCat.Server/ServerCommand.cs @@ -1,9 +1,13 @@ using System.Globalization; using System.Net; +using System.Net.Sockets; using System.Runtime.InteropServices; +using System.Security.Authentication; +using System.Security.Cryptography; using System.Text.Json; using VoiceCat.Crypto; using VoiceCat.Server.Data; +using VoiceCat.Transport; namespace VoiceCat.Server; @@ -55,11 +59,20 @@ internal static class ServerCommand { if (args.Contains("--help")) { - await output.WriteLineAsync("VoiceCat TLS/UDP server\n--data-dir PATH --bind IP --port PORT --name NAME --allow-guests true|false\n--max-connections N --handshake-seconds N --idle-seconds N --reaper-seconds N\n--auth-burst N --auth-refill-seconds N --print-config --print-fingerprint\naccount add|reset|delete|list [USERNAME] [--admin]\nAccount passwords: hidden prompt, or VOICECAT_ADMIN_PASSWORD (never command arguments).\nDefaults: 0.0.0.0:8384 TCP+UDP, ./voicecat-data; VOICECAT_* environment overrides supported."); + await output.WriteLineAsync("VoiceCat TLS/UDP server\n--data-dir PATH --bind IP --port PORT --name NAME --allow-guests true|false\n--max-connections N --handshake-seconds N --idle-seconds N --reaper-seconds N\n--auth-burst N --auth-refill-seconds N --print-config --print-fingerprint\n--health-check HOST:PORT [--expect-fingerprint SHA256]\naccount add|reset|delete|list [USERNAME] [--admin]\nAccount passwords: hidden prompt, or VOICECAT_ADMIN_PASSWORD (never command arguments).\nDefaults: 0.0.0.0:8384 TCP+UDP, ./voicecat-data; VOICECAT_* environment overrides supported."); return 0; } try { + int healthIndex = Array.IndexOf(args, "--health-check"); + if (healthIndex >= 0) + { + if (healthIndex + 1 >= args.Length) throw new ArgumentException("Health endpoint required."); + string? expected = null; + int fingerprintIndex = Array.IndexOf(args, "--expect-fingerprint"); + if (fingerprintIndex >= 0) expected = fingerprintIndex + 1 < args.Length ? args[fingerprintIndex + 1] : throw new ArgumentException("Expected fingerprint required."); + return await HealthCheckAsync(args[healthIndex + 1], expected, output, cancellationToken).ConfigureAwait(false); + } ServerConfiguration config = Parse(args, Environment.GetEnvironmentVariable); if (args.Contains("--print-config")) { await output.WriteLineAsync(JsonSerializer.Serialize(config)); return 0; } CreateDataDirectory(config.Directory); @@ -96,13 +109,36 @@ internal static class ServerCommand if (server is not null) await server.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); } } - catch (Exception exception) when (exception is ArgumentException or FormatException or OverflowException or IOException or InvalidOperationException or System.Net.Sockets.SocketException or Microsoft.Data.Sqlite.SqliteException or TimeoutException) + catch (Exception exception) when (exception is ArgumentException or FormatException or OverflowException or IOException or InvalidOperationException or SocketException or AuthenticationException or CryptographicException or Microsoft.Data.Sqlite.SqliteException or TimeoutException || exception is OperationCanceledException && !cancellationToken.IsCancellationRequested) { await error.WriteLineAsync("VoiceCat command failed: " + exception.GetType().Name + ". Check configuration, data files and port availability."); return 1; } } + private static async Task HealthCheckAsync(string endpoint, string? expectedFingerprint, TextWriter output, CancellationToken cancellationToken) + { + int separator = endpoint.LastIndexOf(':'); + if (separator < 1 || !ushort.TryParse(endpoint[(separator + 1)..], out ushort port)) throw new ArgumentException("Health endpoint must be HOST:PORT."); + string host = endpoint[..separator].Trim('[', ']'); + byte[]? expected = null; + if (expectedFingerprint is not null) + { + try { expected = Convert.FromHexString(expectedFingerprint); } catch (FormatException) { throw new ArgumentException("Expected fingerprint must be hexadecimal."); } + if (expected.Length != 32) throw new ArgumentException("Expected fingerprint must be SHA-256."); + } + string? actual = null; + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); deadline.CancelAfter(TimeSpan.FromSeconds(5)); + using var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); + await socket.ConnectAsync(host, port, deadline.Token).ConfigureAwait(false); + using var tls = TlsSession.CreateClient(fingerprint => { actual = fingerprint; return true; }); + await using var connection = new TlsControlConnection(socket, tls, deadline.Token, TimeSpan.FromSeconds(5)); + using MediaSessionCrypto crypto = await connection.TakeMediaCryptoAsync(deadline.Token).ConfigureAwait(false); + if (actual is null || expected is not null && !CryptographicOperations.FixedTimeEquals(Convert.FromHexString(actual), expected)) return 1; + await output.WriteLineAsync(JsonSerializer.Serialize(new { status = "healthy", certificate_fingerprint = actual })); + return 0; + } + private static void CreateDataDirectory(string directory) { if (OperatingSystem.IsWindows()) System.IO.Directory.CreateDirectory(directory); diff --git a/dotnet/src/VoiceCat.Server/VoiceCat.Server.csproj b/dotnet/src/VoiceCat.Server/VoiceCat.Server.csproj index b1fe16a..885228d 100644 --- a/dotnet/src/VoiceCat.Server/VoiceCat.Server.csproj +++ b/dotnet/src/VoiceCat.Server/VoiceCat.Server.csproj @@ -1,6 +1,7 @@ Exe + true diff --git a/dotnet/src/VoiceCat.Server/packages.publish.linux-x64.lock.json b/dotnet/src/VoiceCat.Server/packages.publish.linux-x64.lock.json new file mode 100644 index 0000000..feeb74b --- /dev/null +++ b/dotnet/src/VoiceCat.Server/packages.publish.linux-x64.lock.json @@ -0,0 +1,90 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Microsoft.Data.Sqlite.Core": { + "type": "Direct", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "jFYXnh7s0RShCw6Vkf+ReGCw+mVi7ISg1YaEzYCJcXnUifmbW+aqvCsRJuSRj2ZuQ+oqetpjxlZtbpMmk5FKqQ==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.7, )", + "resolved": "10.0.7", + "contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw==" + }, + "SourceGear.sqlite3": { + "type": "Direct", + "requested": "[3.50.4.2, )", + "resolved": "3.50.4.2", + "contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g==" + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Direct", + "requested": "[3.0.2, )", + "resolved": "3.0.2", + "contentHash": "nzPPFpELY9U1scLvQpA1k1GIgR9ror83DCPmirT2/i5NCPdTBfhTDA6MZqFZonGDayye5mUQRQLOVyEiJNYr0g==", + "dependencies": { + "SQLitePCLRaw.config.e_sqlite3": "3.0.2", + "SourceGear.sqlite3": "3.50.4.2" + } + }, + "BouncyCastle.Cryptography": { + "type": "Transitive", + "resolved": "2.6.2", + "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" + }, + "Google.Protobuf": { + "type": "Transitive", + "resolved": "3.36.1", + "contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ==" + }, + "SQLitePCLRaw.config.e_sqlite3": { + "type": "Transitive", + "resolved": "3.0.2", + "contentHash": "QPHR1Axs8YCCapb0TnmT7PxY9DX3sg4I4T9HOSKeFBiT5l482mjrOIxuyt+xOCwEQ2Enq5h0tgDOXMnJi+i0sw==", + "dependencies": { + "SQLitePCLRaw.provider.e_sqlite3": "3.0.2" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "3.0.2", + "contentHash": "tnbRf0muOOSJK1RLCfyYK13jynFScgL4xMj7yC3oy8lrrGKXTKmOoWjfdV+cFfBRdppm4qST31hvp8ihgIgvMQ==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "3.0.2", + "contentHash": "RQIliDp47mQxGYNcBB6W+ezHbegkImrSZVTuWjQCSTTl3pQ37Q3rALkkkdTAMEmcIz71PEOCqNZMp7lXCnVqEQ==", + "dependencies": { + "SQLitePCLRaw.core": "3.0.2" + } + }, + "voicecat.crypto": { + "type": "Project", + "dependencies": { + "BouncyCastle.Cryptography": "[2.6.2, )", + "VoiceCat.Protocol": "[1.0.0, )" + } + }, + "voicecat.protocol": { + "type": "Project", + "dependencies": { + "Google.Protobuf": "[3.36.1, )" + } + } + }, + "net10.0/linux-x64": { + "SourceGear.sqlite3": { + "type": "Direct", + "requested": "[3.50.4.2, )", + "resolved": "3.50.4.2", + "contentHash": "eV9HwQ88WyoU+reGVxJz1SwME9NbYnl9h2LOY15j0LGdXN4JkTJDk8JRRg/yNgt00O3Cn5/qnska10FEZNoU5g==" + } + } + } +} \ No newline at end of file diff --git a/dotnet/tests/VoiceCat.Tests/ProductionServerTests.cs b/dotnet/tests/VoiceCat.Tests/ProductionServerTests.cs index aae12b8..76d40db 100644 --- a/dotnet/tests/VoiceCat.Tests/ProductionServerTests.cs +++ b/dotnet/tests/VoiceCat.Tests/ProductionServerTests.cs @@ -12,6 +12,20 @@ namespace VoiceCat.Tests; public class ProductionServerTests { + [Fact] + public async Task HealthCheckPerformsTlsHandshakeAndCanPinCertificate() + { + await using var fixture = new ServerFixture(); + using var credentials = ServerCredentials.LoadOrCreate(fixture.Directory, "VoiceCat Server"); + var output = new StringWriter(); var error = new StringWriter(); + string endpoint = "127.0.0.1:" + fixture.Server.EndPoint.Port; + Assert.Equal(0, await ServerCommand.RunAsync(["--health-check", endpoint], output, error)); + Assert.Contains("\"status\":\"healthy\"", output.ToString()); + output.GetStringBuilder().Clear(); + Assert.Equal(0, await ServerCommand.RunAsync(["--health-check", endpoint, "--expect-fingerprint", credentials.CertificateFingerprint], output, error)); + Assert.Equal(1, await ServerCommand.RunAsync(["--health-check", endpoint, "--expect-fingerprint", new string('0', 64)], output, error)); + Assert.Equal(1, await ServerCommand.RunAsync(["--health-check", "127.0.0.1:1"], output, error)); + } [PublishedServerFact] public async Task PublishedExecutableProvisionsAdminAndReportsFingerprintsWithoutPasswordOutput() {