Package managed server for Linux production
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s

This commit is contained in:
2026-09-16 17:08:17 +02:00
parent 48c754aed9
commit 7be93e81a1
15 changed files with 384 additions and 119 deletions
+3
View File
@@ -3,6 +3,9 @@
# Previous build outputs # Previous build outputs
build/ build/
dotnet/artifacts/
dotnet/**/bin/
dotnet/**/obj/
# Native GUI client code (Swift/Xcode, C#/WinForms) — server build doesn't need these # Native GUI client code (Swift/Xcode, C#/WinForms) — server build doesn't need these
clients/ clients/
+20 -91
View File
@@ -1,98 +1,27 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1.7
# ───────────────────────────────────────────────────────────────────────────── FROM mcr.microsoft.com/dotnet/sdk:10.0.203-noble AS build
# 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 <sha>: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
WORKDIR /src 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 FROM scratch AS export
COPY --from=builder /src/build/server-release/bin/voicecat-server /voicecat-server COPY --from=build /out/VoiceCat.Server /VoiceCat.Server
COPY --from=builder /src/build/server-release/bin/voicecat-admin /voicecat-admin
# ───────────────────────────────────────────────────────────────────────────── FROM mcr.microsoft.com/dotnet/runtime-deps:10.0.10-noble-chiseled-extra AS runtime
# Stage 3 — Runtime (default stage — must be last) WORKDIR /app
# ───────────────────────────────────────────────────────────────────────────── COPY --from=build --chown=1654:1654 /out/ ./
FROM ubuntu:24.04 AS runtime COPY --from=build --chown=1654:1654 /empty-data/ /data/
USER 1654:1654
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.
VOLUME ["/data"] VOLUME ["/data"]
# Control (TLS 1.3) and media (ChaCha20-Poly1305) share one port number on TCP+UDP.
EXPOSE 8384/tcp EXPOSE 8384/tcp
EXPOSE 8384/udp EXPOSE 8384/udp
ENV VOICECAT_DATA_DIR=/data \
ENTRYPOINT ["/usr/local/bin/voicecat-server"] VOICECAT_BIND_ADDRESS=0.0.0.0 \
CMD ["--data-dir", "/data"] 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"]
+14
View File
@@ -10,6 +10,20 @@ up instantly. Newest status at the top.
## ▶ Where we left off / next action ## ▶ 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` - **Done (2026-09-16): Managed CLI and cross-generation client exit.** Added `VoiceCat.Cli`
with interactive channel text, TOFU, channel selection and deterministic headless 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 text/tone verification. Independent process tests run two managed CLIs in channels 1 and 2
+7
View File
@@ -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
+23
View File
@@ -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."
+29 -13
View File
@@ -1,23 +1,39 @@
[Unit] [Unit]
Description=VoiceCat Voice & Text Server Description=VoiceCat encrypted voice server
Documentation=https://github.com/org/voicecat Documentation=https://github.com/Talon/voice-cat/blob/main/docs/deployment.md
After=network.target Wants=network-online.target
After=network-online.target
[Service] [Service]
Type=simple Type=simple
User=voicecat DynamicUser=yes
Group=voicecat StateDirectory=voicecat
ExecStart=/usr/local/bin/voicecat-server --data-dir /var/lib/voicecat StateDirectoryMode=0700
ExecStart=/usr/local/lib/voicecat/VoiceCat.Server --data-dir /var/lib/voicecat
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s
# Allow binding port 8384 without running as root TimeoutStopSec=15s
AmbientCapabilities=CAP_NET_BIND_SERVICE NoNewPrivileges=yes
# Harden the process PrivateDevices=yes
NoNewPrivileges=true PrivateTmp=yes
ProtectSystem=strict ProtectSystem=strict
ProtectHome=true ProtectHome=yes
ReadWritePaths=/var/lib/voicecat ProtectKernelTunables=yes
PrivateTmp=true 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] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+11 -4
View File
@@ -1,16 +1,23 @@
services: services:
voicecat: voicecat:
build: . build: .
image: ghcr.io/org/voicecat:latest image: voicecat:local
container_name: voicecat
restart: unless-stopped restart: unless-stopped
init: true
ports: ports:
- "8384:8384/tcp" - "8384:8384/tcp"
- "8384:8384/udp" - "8384:8384/udp"
volumes: volumes:
- voicecat-data:/data - voicecat-data:/data
# Override CMD to customise the server name; add --no-guests to require accounts. environment:
command: ["--data-dir", "/data", "--name", "My VoiceCat Server"] 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: volumes:
voicecat-data: voicecat-data:
+36 -9
View File
@@ -2,11 +2,12 @@
## Managed server deployment checkpoint ## Managed server deployment checkpoint
The .NET server preserves protocol v2 and the native schema/credentials. Publish the The .NET server preserves protocol v2 and the native schema/credentials. Publish a
Windows self-contained executable (no installed .NET runtime required): self-contained executable (no installed .NET runtime required):
```powershell ```powershell
./dotnet/publish-server.ps1 ./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 --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 account add Operator --admin --data-dir ./voicecat-data
./dotnet/artifacts/server/win-x64/VoiceCat.Server.exe --data-dir ./voicecat-data --allow-guests false ./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. 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. 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. 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 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, 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. 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. 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 The publish script uses separate per-RID lock files so deployment and development restore
graphs remain reproducible. The checked deployment target is currently Windows x64; graphs remain reproducible. Windows x64 and Linux x64 self-contained outputs are checked.
Linux container publishing, service packaging, TOML/reload support and long-running The Linux smoke starts the published ELF, validates TLS health and the pin, checks mode 0700
operational validation remain before broad production rollout. The native deployment data creation, sends SIGTERM and requires a clean exit:
paths and planned operational features below remain available as the migration oracle.
```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 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 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) ### A. Docker (recommended)
```bash ```bash
docker build -t voicecat:local .
docker run -d --name voicecat \ docker run -d --name voicecat \
-p 8384:8384/tcp \ # control (TLS 1.3) -p 8384:8384/tcp \ # control (TLS 1.3)
-p 8384:8384/udp \ # media (encrypted) -p 8384:8384/udp \ # media (encrypted)
-v voicecat-data:/data \ -v voicecat-data:/data \
ghcr.io/<org>/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 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) ## 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`. - Graceful reload of `server.toml` on `SIGHUP`.
- `voicecat-admin` (see §3a) also handles bans and channel admin, talking to the same SQLite - `voicecat-admin` (see §3a) also handles bans and channel admin, talking to the same SQLite
file or a running server. file or a running server.
+41
View File
@@ -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 } }
@@ -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": {}
}
}
@@ -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": {}
}
}
+38 -2
View File
@@ -1,9 +1,13 @@
using System.Globalization; using System.Globalization;
using System.Net; using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using VoiceCat.Crypto; using VoiceCat.Crypto;
using VoiceCat.Server.Data; using VoiceCat.Server.Data;
using VoiceCat.Transport;
namespace VoiceCat.Server; namespace VoiceCat.Server;
@@ -55,11 +59,20 @@ internal static class ServerCommand
{ {
if (args.Contains("--help")) 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; return 0;
} }
try 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); ServerConfiguration config = Parse(args, Environment.GetEnvironmentVariable);
if (args.Contains("--print-config")) { await output.WriteLineAsync(JsonSerializer.Serialize(config)); return 0; } if (args.Contains("--print-config")) { await output.WriteLineAsync(JsonSerializer.Serialize(config)); return 0; }
CreateDataDirectory(config.Directory); 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); 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."); await error.WriteLineAsync("VoiceCat command failed: " + exception.GetType().Name + ". Check configuration, data files and port availability.");
return 1; return 1;
} }
} }
private static async Task<int> 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) private static void CreateDataDirectory(string directory)
{ {
if (OperatingSystem.IsWindows()) System.IO.Directory.CreateDirectory(directory); if (OperatingSystem.IsWindows()) System.IO.Directory.CreateDirectory(directory);
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="../VoiceCat.Crypto/VoiceCat.Crypto.csproj" /> <ProjectReference Include="../VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
@@ -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=="
}
}
}
}
@@ -12,6 +12,20 @@ namespace VoiceCat.Tests;
public class ProductionServerTests 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] [PublishedServerFact]
public async Task PublishedExecutableProvisionsAdminAndReportsFingerprintsWithoutPasswordOutput() public async Task PublishedExecutableProvisionsAdminAndReportsFingerprintsWithoutPasswordOutput()
{ {