diff --git a/.env.example b/.env.example index 0d90799..3a1220a 100644 --- a/.env.example +++ b/.env.example @@ -17,17 +17,15 @@ ROOM_LEASE_SECONDS=86400 # activation requires a verified writable directory and database recovery procedure. ROOM_DATABASE_PATH= MAX_VIEWERS_PER_ROOM=8 -# Bounded standard-WebRTC relay/SFU controller for every room when true. -# Keep false outside measured tests. -PEER_ASSISTED_MEDIA=false +# Bounded standard-WebRTC relay/SFU controller is active for every room. # One steady outbound media-copy capacity for every non-server endpoint. # A peer child or Host SFU publication consumes one copy. Valid values: 1, 2, 3. ENDPOINT_MEDIA_COPY_CAPACITY=2 # During pre-release route canaries, this enables sanitized room/ordinal events. -# NODE_DEBUG=screener-route +# SCREENER_DEBUG=route -# Optional automatic SFU fallback. Set PEER_ASSISTED_MEDIA=true and supply all -# four values together; leaving them empty keeps LiveKit completely dormant. +# Optional automatic SFU fallback. Supply all four values together; leaving them +# empty keeps LiveKit completely dormant. # Ingress uses the 9000-room code capacity; egress uses that capacity times the # configured per-room Viewer admission. # Production requires wss and an independent API secret of 32+ bytes. @@ -45,12 +43,18 @@ STUN_URLS= # from the first STUN_URLS endpoint on UDP 3478. NAT_PREDICTION_ENABLED=false +# Development example (two processes): Vite serves the Browser on 8787 and +# proxies /api, /healthz and /signal to the Go server on 8788. Keep +# PUBLIC_BASE_URL and ALLOWED_ORIGINS on the Browser origin above. +# SCREENER_ENV=development +# PORT=8788 + # Minimal production example (use independent untracked secrets): +# SCREENER_ENV=production # SITE_ACCESS_PASSWORD=replace-with-an-independent-access-key # STUN_URLS=stun:stun.example.com:3478 # NAT_PREDICTION_ENABLED=true -# Automatic SFU fallback (requires PEER_ASSISTED_MEDIA=true and does not -# replace STUN above): +# Automatic SFU fallback (does not replace STUN above): # LIVEKIT_URL=wss://share.example.com # LIVEKIT_API_URL=http://127.0.0.1:7880 # LIVEKIT_API_KEY=replace-with-a-livekit-api-key diff --git a/.github/workflows/repository-hygiene.yml b/.github/workflows/repository-hygiene.yml index e818033..94b2709 100644 --- a/.github/workflows/repository-hygiene.yml +++ b/.github/workflows/repository-hygiene.yml @@ -58,16 +58,20 @@ jobs: LICENSE \ licenses \ index.html \ - native/client \ + cmd \ + internal \ + native \ + go.mod \ + go.sum \ package.json \ package-lock.json \ public \ src \ tsconfig.json \ tsconfig.app.json \ - tsconfig.server.json \ vite.config.ts \ scripts/assemble-client.mjs \ + scripts/archive-tool.mjs \ scripts/client-icons.mjs \ scripts/package-app-release.mjs \ scripts/package-licenses.mjs \ @@ -81,17 +85,18 @@ jobs: fi fi echo "run=$run_client" >> "$GITHUB_OUTPUT" + - name: Install dependencies + run: npm ci + # The Go build embeds internal/server/webassets/dist, so Vite runs first. + - name: Build the Browser client + run: npm run build:client - name: Set up Go - if: steps.client-scope.outputs.run == 'true' uses: actions/setup-go@v6 with: go-version: 1.26.6 - cache-dependency-path: native/client/go.sum - - name: Check Screener Client - if: steps.client-scope.outputs.run == 'true' + cache-dependency-path: go.sum + - name: Check Go core run: npm run check:client-core - - name: Install dependencies - run: npm ci - name: Type-check, test, and build run: npm run check @@ -110,6 +115,11 @@ jobs: with: node-version-file: .node-version cache: npm + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: 1.26.6 + cache-dependency-path: go.sum - name: Install dependencies run: npm ci - name: Package application release @@ -148,11 +158,17 @@ jobs: uses: actions/setup-node@v6 with: node-version-file: .node-version + cache: npm - name: Set up Go uses: actions/setup-go@v6 with: go-version: 1.26.6 - cache-dependency-path: native/client/go.sum + cache-dependency-path: go.sum + # scripts/assemble-client.mjs runs `npm run build:client` for the Web + # assets the Client binary embeds, so Vite and the license inputs must be + # installed here too. + - name: Install dependencies + run: npm ci - name: Install Linux native capture dependencies if: matrix.target == 'linux-amd64' run: >- diff --git a/.gitignore b/.gitignore index d81af04..5645796 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,10 @@ node_modules/ public/third-party-licenses.txt build/ coverage/ -dist/ +/dist/ +!internal/server/webassets/dist/ +internal/server/webassets/dist/* +!internal/server/webassets/dist/.gitkeep benchmark-results/ .cache/ *.sqlite diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49a6108..06b87e8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,11 +61,12 @@ Clean up worktrees and branches only after semantic review and integration are c ## Verification Entrypoints -- `npm run check` owns deterministic Web/Server type-check, unit, and build - acceptance. +- `npm run check` owns deterministic Web type-check, unit, and build acceptance. - `npm run check:client` owns Go formatting, unit tests, vet, three-platform - builds, and the Windows capture compile/probe when run on Windows. CI invokes - these same package commands rather than rebuilding their steps in YAML. + builds, and the Windows capture compile/probe when run on Windows. The server + core is Go, so its acceptance runs here. It builds the Vite client first when + that output is missing, because both binaries embed it. CI invokes these same + package commands rather than rebuilding their steps in YAML. - `gate:*` commands are explicit physical or network acceptance. They must use isolated profiles, bounded deadlines, shared cleanup helpers, and a structured result. A manual diagnostic may locate a failure, but is not retained as pass diff --git a/README.md b/README.md index c041125..535a6ae 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ this README intentionally does not mirror release details. ## Screener Client -The Client opens the same interface in your system browser. The Windows package -includes its runtime; you do not need to install Node.js separately. +The Client opens the same interface in your system browser. The package is one +self-contained executable. - **Local:** share with devices on the same LAN. - **Public link:** send an ordinary invitation link. Cloudflare Quick Tunnel @@ -33,13 +33,19 @@ site-access password. This does not change access rules on a saved Site. ## Local Development -Node.js 24 and npm 11 are required. +Node.js 24, npm 11, and Go 1.26 are required. ```sh npm ci npm run dev ``` +In a second terminal, run the application server that the dev server proxies: + +```sh +PORT=8788 PUBLIC_BASE_URL=http://localhost:8787 go run ./cmd/screener-server +``` + Open `http://localhost:8787`. The development server listens on `0.0.0.0`, so a phone on the same LAN may use `http://:8787`. Screen capture must remain on `localhost` or HTTPS. Set `PUBLIC_BASE_URL` and @@ -55,6 +61,7 @@ Run complete automated validation with: ```sh npm run check +npm run check:client ``` ## Documentation diff --git a/native/client/README.md b/cmd/screener-client/README.md similarity index 93% rename from native/client/README.md rename to cmd/screener-client/README.md index b864013..87226fc 100644 --- a/native/client/README.md +++ b/cmd/screener-client/README.md @@ -77,7 +77,7 @@ falling back to ASCII without color support or in very narrow windows. Visual mode depicts the selected mode and startup state with small scenes; the television rests with one eye closed and occasionally winks while idle. Press `o` to reopen the Browser, or `q` / Ctrl+C to end Local -rooms and stop the bundled server and temporary public link. Plain-text output +rooms and stop the local server and temporary public link. Plain-text output uses Ctrl+C. A Site-loaded Browser tab does not own the Client process. For one-link Internet sharing, open the Client launcher, choose **Public invite**, @@ -88,13 +88,13 @@ control availability or SFU fallback matters. ## Development +The binary embeds the Browser assets, so build them before running it: + ```sh -cd native/client +npm run build:client go test ./... go vet ./... -go run ./cmd/screener-client \ - --node /path/to/node \ - --app /path/to/Screener +go run ./cmd/screener-client ``` The repository-level entry used locally and by CI is: @@ -127,15 +127,14 @@ available even when this machine has no accepted native capture encoder. ## Packaging Build one application release, then assemble a platform Client from that exact -descriptor and a matching platform Node executable. Native capture and the -public-link sidecar are explicit package inputs: +descriptor. Native capture and the public-link sidecar are explicit package +inputs: ```sh node scripts/package-app-release.mjs /outside/repository/app-release SCREENER_GO=/path/to/go \ node scripts/assemble-client.mjs \ /outside/repository/app-release/screener-.release.json \ - /path/to/node \ /outside/repository/Screener-Client \ --target windows-amd64 \ --capture /outside/repository/screener-client-capture.exe \ @@ -143,8 +142,8 @@ SCREENER_GO=/path/to/go \ ``` Supported targets are `windows-amd64`, `linux-amd64`, and `darwin-arm64`. -`--target` controls the Go cross-build and packaged executable names; the Node, -capture, and tunnel inputs must already match that target. Each target accepts +`--target` controls the Go cross-build and packaged executable names; the +capture and tunnel inputs must already match that target. Each target accepts its matching native-capture input. The result contains: @@ -152,16 +151,15 @@ The result contains: ```text screener-client[.exe] REVISION -runtime/node/node[.exe] +LICENSE +THIRD-PARTY-NOTICES.txt runtime/native/screener-client-capture[.exe] # supported native-media packages runtime/tunnel/cloudflared[.exe] # packages that support --link -app/REVISION -app/dist -app/node_modules ``` -The Client executable and application must contain the same full Git revision. -No compatibility reader accepts a mismatched private build. +The executable embeds the Browser assets of the consumed application release and +carries that same full Git revision as the package `REVISION`. No compatibility +reader accepts a mismatched private build. The Windows Client embeds the shared Screener mark through the `cmd/screener-client/screener_windows_amd64.syso` resource; the platform @@ -189,8 +187,6 @@ is POSIX; use equivalent variables on Windows. SCREENER_CLIENT_LOCAL_GATE=true \ CHROME_PATH=/path/to/chrome \ SCREENER_CLIENT_EXE=/path/to/screener-client \ -SCREENER_CLIENT_NODE=/path/to/node \ -SCREENER_CLIENT_APP=/path/to/app \ SCREENER_CLIENT_GATE_LAN_ADDRESS=192.168.1.10 \ npm run gate:client-local @@ -201,6 +197,7 @@ npm run probe:client-loopback SCREENER_CLIENT_MEDIA_GATE=true \ CHROME_PATH=/path/to/chrome \ +SCREENER_CLIENT_GATE_STUN_URLS=stun::3478 \ npm run gate:client-media SCREENER_CLIENT_NATIVE_HOST_GATE=true \ @@ -216,7 +213,7 @@ SCREENER_REMOTE_HOST= \ SCREENER_REMOTE_USER= \ SCREENER_REMOTE_SSH_KEY=/path/to/key \ SCREENER_CLIENT_GATE_STUN_URLS=stun::3478 \ -npm run gate:client-cross-nat +npm run gate:client-native-host SCREENER_CLIENT_NATIVE_HOST_GATE=true \ SCREENER_CLIENT_LINK_MEDIA_GATE=true \ @@ -226,7 +223,7 @@ SCREENER_CLOUDFLARED=/path/to/cloudflared \ SCREENER_REMOTE_HOST= \ SCREENER_REMOTE_USER= \ SCREENER_REMOTE_SSH_KEY=/path/to/key \ -npm run gate:client-link-media +npm run gate:client-native-host SCREENER_CLIENT_LINK_GATE=true \ SCREENER_GO=/path/to/go \ diff --git a/native/client/cmd/screener-client/main.go b/cmd/screener-client/main.go similarity index 79% rename from native/client/cmd/screener-client/main.go rename to cmd/screener-client/main.go index c176980..f43f051 100644 --- a/native/client/cmd/screener-client/main.go +++ b/cmd/screener-client/main.go @@ -1,3 +1,8 @@ +// Command screener-client is the self-contained Screener Client: it opens a +// configured Screener Site, or runs the Local room authority in-process +// (--local / --link) together with the loopback capability service and the +// native media edge. Flags select the mode; clientapp owns every mode's +// lifecycle. package main import ( @@ -8,7 +13,7 @@ import ( "os/signal" "syscall" - "github.com/TNTcraftHIM/Screener/native/client/internal/clientapp" + "github.com/TNTcraftHIM/Screener/internal/client/clientapp" ) func main() { @@ -20,8 +25,6 @@ func main() { }) flag.BoolVar(&options.Local, "local", false, "use the self-contained Local room authority") flag.BoolVar(&options.Link, "link", false, "create one public Viewer invitation link") - flag.StringVar(&options.NodePath, "node", "", "path to the bundled Node runtime") - flag.StringVar(&options.AppDirectory, "app", "", "path to the bundled Screener application") flag.StringVar(&options.CaptureProcess, "capture-process", "", "path to the platform native capture process") flag.StringVar(&options.TunnelProcess, "tunnel-process", "", "path to the packaged public tunnel process") flag.StringVar(&options.ConfigPath, "config", "", "path to the Client configuration file") diff --git a/native/client/cmd/screener-client/screener.ico b/cmd/screener-client/screener.ico similarity index 100% rename from native/client/cmd/screener-client/screener.ico rename to cmd/screener-client/screener.ico diff --git a/native/client/cmd/screener-client/screener_windows_amd64.syso b/cmd/screener-client/screener_windows_amd64.syso similarity index 100% rename from native/client/cmd/screener-client/screener_windows_amd64.syso rename to cmd/screener-client/screener_windows_amd64.syso diff --git a/native/client/cmd/screener-peer-gate/main.go b/cmd/screener-peer-gate/main.go similarity index 99% rename from native/client/cmd/screener-peer-gate/main.go rename to cmd/screener-peer-gate/main.go index 30d4732..998d8bf 100644 --- a/native/client/cmd/screener-peer-gate/main.go +++ b/cmd/screener-peer-gate/main.go @@ -20,7 +20,7 @@ import ( ) const ( - signalingProtocol = "screener-v20" + signalingProtocol = "screener-v21" maxPendingCandidates = 64 ) diff --git a/native/client/cmd/screener-peer-gate/main_test.go b/cmd/screener-peer-gate/main_test.go similarity index 100% rename from native/client/cmd/screener-peer-gate/main_test.go rename to cmd/screener-peer-gate/main_test.go diff --git a/cmd/screener-server/main.go b/cmd/screener-server/main.go new file mode 100644 index 0000000..0dd4fde --- /dev/null +++ b/cmd/screener-server/main.go @@ -0,0 +1,168 @@ +// Command screener-server is the Hosted Screener entry point: it loads the +// environment, serves the embedded Browser UI together with the signaling and +// room API, and stops on SIGINT or SIGTERM. It replaces src/server/index.ts. +// +// Two maintenance modes exit without serving: --check-config validates the +// environment (the release wrapper runs it as the service user before cutover) +// and --check-release compares the deployed revision with the latest published +// release (see release.go). +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io/fs" + "log/slog" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/TNTcraftHIM/Screener/internal/server/app" + "github.com/TNTcraftHIM/Screener/internal/server/config" + "github.com/TNTcraftHIM/Screener/internal/server/webassets" +) + +// shutdownTimeout bounds the ordered stop (signaling, listener, room store). +// systemd's tracked TimeoutStopSec is 20s, so the process must be gone first. +const shutdownTimeout = 15 * time.Second + +// environmentFile is read from the working directory, which systemd pins to the +// release directory. Hosted keeps its real values in the service secret store. +const environmentFile = ".env" + +// BuildRevision is the full Git revision the release packager links in with +// -ldflags. It matches the REVISION file shipped beside the binary and is +// reported at startup so one journal line identifies the running release. +var BuildRevision = "development" + +func main() { + checkConfig := flag.Bool("check-config", false, "validate the environment and exit") + checkRelease := flag.Bool("check-release", false, + "report whether a newer published release exists and exit") + currentFile := flag.String("current-file", "", + "path to the deployed REVISION file read by --check-release") + apiURL := flag.String("api-url", defaultReleaseAPIURL, + "release metadata endpoint used by --check-release") + flag.Parse() + + if err := loadEnvironmentFile(environmentFile); err != nil { + fail(err) + } + switch { + case *checkRelease: + os.Exit(checkDeployedRelease(context.Background(), *currentFile, *apiURL, os.Stdout)) + case *checkConfig: + if _, err := config.Load(environment()); err != nil { + fail(err) + } + fmt.Println("config=ok") + default: + if err := serve(); err != nil { + fail(err) + } + } +} + +func fail(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} + +// serve runs the application until the first SIGINT or SIGTERM, then stops it +// with Close rather than End: Hosted rooms are leased in the database and must +// survive a restart (DECISIONS D6). +func serve() error { + configuration, err := config.Load(environment()) + if err != nil { + return err + } + server, err := app.New(app.Options{ + Config: configuration, + Assets: webassets.FS(), + Logger: slog.New(slog.NewTextHandler(os.Stderr, nil)), + }) + if err != nil { + return err + } + // Startup does not take the stop signal: index.ts registered its SIGINT and + // SIGTERM handlers only after `await server.listen()` resolved, so a stop + // during the LiveKit reconciliation ended the process by the signal's + // default disposition. Cancelling startup instead would abort it and exit + // non-zero, which systemd records as a failed unit. Each LiveKit call keeps + // its own 5 s bound (D1). + port, err := server.Listen(context.Background()) + if err != nil { + return err + } + fmt.Printf("Screener %s is listening on %s:%d; public URL %s\n", + BuildRevision, configuration.ListenHost, port, config.Origin(configuration.PublicBaseURL)) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + return server.Close(shutdownCtx) +} + +// environment is the process environment in the form config.Load expects. +func environment() map[string]string { + values := make(map[string]string) + for _, entry := range os.Environ() { + if name, value, found := strings.Cut(entry, "="); found { + values[name] = value + } + } + return values +} + +// loadEnvironmentFile ports the src/server/index.ts loadEnvFile() call: an +// absent file is the ordinary case and every other read failure stops startup. +// Like Node, a name the process already carries is never overwritten, so a +// systemd EnvironmentFile still wins over a stale file in the release +// directory. The accepted syntax is DECISIONS D8: KEY=value lines, whole-line +// '#' comments, an optional 'export ' prefix, and a single- or double-quoted +// value. Quoted values keep their content verbatim; nothing is expanded. +func loadEnvironmentFile(name string) error { + content, err := os.ReadFile(name) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + for _, line := range strings.Split(string(content), "\n") { + line = strings.TrimSpace(line) + line = strings.TrimSpace(strings.TrimPrefix(line, "export ")) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, assigned := strings.Cut(line, "=") + key = strings.TrimSpace(key) + if !assigned || key == "" { + continue + } + if _, present := os.LookupEnv(key); present { + continue + } + if err := os.Setenv(key, unquote(strings.TrimSpace(value))); err != nil { + return err + } + } + return nil +} + +// unquote removes one matching pair of surrounding single or double quotes. +func unquote(value string) string { + if len(value) < 2 || value[0] != value[len(value)-1] { + return value + } + if value[0] != '"' && value[0] != '\'' { + return value + } + return value[1 : len(value)-1] +} diff --git a/cmd/screener-server/main_test.go b/cmd/screener-server/main_test.go new file mode 100644 index 0000000..9bef26d --- /dev/null +++ b/cmd/screener-server/main_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadEnvironmentFileFillsOnlyAbsentVariables(t *testing.T) { + t.Setenv("SCREENER_TEST_PRESET", "from-the-service") + path := filepath.Join(t.TempDir(), ".env") + content := "# Application\n" + + "SCREENER_TEST_PRESET=from-the-file\n" + + "export SCREENER_TEST_EXPORTED=exported\n" + + " SCREENER_TEST_QUOTED = \"a b#c\" \n" + + "SCREENER_TEST_SINGLE=' padded '\n" + + "SCREENER_TEST_EMPTY=\n" + + "SCREENER_TEST_MARK=has#hash\n" + + "\n" + + "not an assignment\n" + + "#SCREENER_TEST_COMMENTED=never\r\n" + + "SCREENER_TEST_LAST=tail" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + // Setenv registers the restore, Unsetenv makes the name absent for the run. + for _, name := range []string{ + "SCREENER_TEST_EXPORTED", "SCREENER_TEST_QUOTED", "SCREENER_TEST_SINGLE", + "SCREENER_TEST_EMPTY", "SCREENER_TEST_MARK", "SCREENER_TEST_COMMENTED", + "SCREENER_TEST_LAST", + } { + t.Setenv(name, "absent") + os.Unsetenv(name) + } + + if err := loadEnvironmentFile(path); err != nil { + t.Fatal(err) + } + for name, want := range map[string]string{ + // A variable the service already supplies is never replaced. + "SCREENER_TEST_PRESET": "from-the-service", + "SCREENER_TEST_EXPORTED": "exported", + "SCREENER_TEST_QUOTED": "a b#c", + "SCREENER_TEST_SINGLE": " padded ", + "SCREENER_TEST_EMPTY": "", + "SCREENER_TEST_MARK": "has#hash", + "SCREENER_TEST_LAST": "tail", + } { + if got, present := os.LookupEnv(name); !present || got != want { + t.Errorf("%s = %q, present %t; want %q", name, got, present, want) + } + } + if value, present := os.LookupEnv("SCREENER_TEST_COMMENTED"); present { + t.Errorf("commented assignment was applied as %q", value) + } +} + +func TestLoadEnvironmentFileIgnoresOnlyAMissingFile(t *testing.T) { + directory := t.TempDir() + if err := loadEnvironmentFile(filepath.Join(directory, ".env")); err != nil { + t.Fatalf("a missing environment file must not stop startup: %v", err) + } + // A directory in place of the file is a read failure, not an absent file. + if err := loadEnvironmentFile(directory); err == nil { + t.Fatal("an unreadable environment file was ignored") + } +} diff --git a/cmd/screener-server/release.go b/cmd/screener-server/release.go new file mode 100644 index 0000000..9b1aeec --- /dev/null +++ b/cmd/screener-server/release.go @@ -0,0 +1,242 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "time" +) + +// defaultReleaseAPIURL is DEFAULT_RELEASE_API_URL: the same published release +// contract the Browser update notice uses (src/client/lib/release-update.ts). +const defaultReleaseAPIURL = "https://api.github.com/repos/TNTcraftHIM/Screener/releases/latest" + +// Exit codes are UPDATE_EXIT_CODES. deploy/check-release.sh forwards them, so a +// checker failure stays distinguishable from "an update exists". +const ( + exitUpToDate = 0 + exitUpdateAvailable = 10 + exitUpdateUnavailable = 20 + // exitUsage is the exit code parseArguments set when --current-file was + // missing. Without it a forgotten argument reads as an unavailable check. + exitUsage = 2 +) + +// releaseUsage is the usage string parseArguments threw, with this binary's +// name in place of the script's. +const releaseUsage = "Usage: screener-server --check-release --current-file [--api-url ]" + +const ( + // releaseTimeout bounds the whole check; an operator runs it interactively. + releaseTimeout = 5 * time.Second + // releasePathPrefix is RELEASE_PATH_PREFIX: the only accepted release page. + releasePathPrefix = "/TNTcraftHIM/Screener/releases/tag/" + releaseHost = "github.com" + releaseAPIHost = "api.github.com" +) + +// fullRevisionPattern is FULL_REVISION applied to an already lowercased value, +// which is why the JavaScript case-insensitive flag has no counterpart here. +var fullRevisionPattern = regexp.MustCompile(`^[0-9a-f]{40}$`) + +// Release status values are the "status" field of the printed JSON line, which +// deploy/check-release.sh reads; each maps to one exit code above. +const ( + statusUpToDate = "up-to-date" + statusUpdateAvailable = "update-available" + statusUnavailable = "unavailable" +) + +// releaseResult is the single JSON line the checker prints. Field order is the +// JSON.stringify order of the ported object, and the pointers are the nulls: a +// value the check could not establish is present and null, never omitted. +type releaseResult struct { + Status string `json:"status"` + CurrentRevision *string `json:"currentRevision"` + LatestRevision *string `json:"latestRevision"` + ReleaseURL *string `json:"releaseUrl"` +} + +// checkDeployedRelease is the --check-release entry point. It writes one JSON +// line and returns the process exit code. An unreadable revision file is an +// unavailable check rather than an update, which is what the ported script's +// silently swallowed read failure meant; an absent --current-file is a usage +// error, which is what parseArguments made it. +func checkDeployedRelease(ctx context.Context, currentFile, apiURL string, out io.Writer) int { + if currentFile == "" { + fmt.Fprintln(os.Stderr, releaseUsage) + return exitUsage + } + current := "" + if content, err := os.ReadFile(currentFile); err == nil { + current = string(content) + } + result := checkRelease(ctx, current, apiURL, os.Getenv("GITHUB_TOKEN")) + encoder := json.NewEncoder(out) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(result); err != nil { + return exitUpdateUnavailable + } + switch result.Status { + case statusUpdateAvailable: + return exitUpdateAvailable + case statusUpToDate: + return exitUpToDate + default: + return exitUpdateUnavailable + } +} + +// checkRelease ports checkRelease. Every failure - an unusable current +// revision, an unusable endpoint, a redirect, a timeout, a non-2xx reply or +// metadata that fails validation - collapses to the same unavailable result: +// the checker never guesses that an update exists. +func checkRelease(ctx context.Context, currentRevision, apiURL, token string) releaseResult { + current := normalizeRevision(currentRevision) + if current == "" { + return unavailableResult(currentRevision) + } + endpoint, err := url.Parse(apiURL) + if err != nil || (endpoint.Scheme != "https" && endpoint.Scheme != "http") { + return unavailableResult(current) + } + + ctx, cancel := context.WithTimeout(ctx, releaseTimeout) + defer cancel() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return unavailableResult(current) + } + request.Header.Set("Accept", "application/vnd.github+json") + request.Header.Set("X-GitHub-Api-Version", "2022-11-28") + request.Header.Set("User-Agent", "Screener-release-check") + if authorizesGitHub(endpoint, token) { + request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token)) + } + + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { + // The ported redirect: "error" policy. A moved release endpoint is not + // followed, so the Authorization header cannot travel to another origin. + return errors.New("release endpoint redirected") + }} + response, err := client.Do(request) + if err != nil { + return unavailableResult(current) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode > 299 { + return unavailableResult(current) + } + body, err := io.ReadAll(response.Body) + if err != nil { + return unavailableResult(current) + } + latest, releaseURL, ok := parseReleaseMetadata(body) + if !ok { + return unavailableResult(current) + } + status := statusUpdateAvailable + if latest == current { + status = statusUpToDate + } + return releaseResult{ + Status: status, + CurrentRevision: ¤t, + LatestRevision: &latest, + ReleaseURL: &releaseURL, + } +} + +// authorizesGitHub reports whether the operator token may be sent to endpoint. +// A fixture, mirror or plaintext endpoint must never receive it, which is why +// the scheme and the host are both part of the rule. +func authorizesGitHub(endpoint *url.URL, token string) bool { + return endpoint.Scheme == "https" && + strings.ToLower(endpoint.Hostname()) == releaseAPIHost && + strings.TrimSpace(token) != "" +} + +// unavailableResult is unavailableResult: the current revision is reported when +// it is well formed and null otherwise. +func unavailableResult(currentRevision string) releaseResult { + return releaseResult{Status: statusUnavailable, CurrentRevision: revisionOrNull(currentRevision)} +} + +func revisionOrNull(value string) *string { + revision := normalizeRevision(value) + if revision == "" { + return nil + } + return &revision +} + +// normalizeRevision ports normalizeRevision, returning "" where it returned +// null: only a full 40-hex commit identifies a release. +func normalizeRevision(value string) string { + revision := strings.ToLower(strings.TrimSpace(value)) + if !fullRevisionPattern.MatchString(revision) { + return "" + } + return revision +} + +// releaseMetadata is the subset of the GitHub release payload that is read. +// A tag_name or html_url of another type fails to decode, which is the +// rejection the JavaScript reached through typeof. Deviation: the JavaScript +// tested `draft === true`, so it read a draft of another type as "not a +// draft"; here such a payload fails to decode and the check reports +// unavailable. Only stricter, and a release payload the checker cannot +// understand must never be reported as an update. +type releaseMetadata struct { + TagName *string `json:"tag_name"` + HTMLURL *string `json:"html_url"` + Draft *bool `json:"draft"` + Prerelease *bool `json:"prerelease"` +} + +// parseReleaseMetadata ports parseReleaseMetadata. It accepts one shape only: a +// published, non-draft release whose tag is a full commit revision and whose +// page is exactly https://github.com/TNTcraftHIM/Screener/releases/tag/ with no credentials, port, query or fragment. The returned URL is +// rebuilt from the validated parts so nothing unverified reaches the operator. +func parseReleaseMetadata(payload []byte) (revision string, releaseURL string, ok bool) { + var metadata releaseMetadata + if err := json.Unmarshal(payload, &metadata); err != nil { + return "", "", false + } + if (metadata.Draft != nil && *metadata.Draft) || + (metadata.Prerelease != nil && *metadata.Prerelease) { + return "", "", false + } + if metadata.TagName == nil || metadata.HTMLURL == nil { + return "", "", false + } + revision = normalizeRevision(*metadata.TagName) + if revision == "" { + return "", "", false + } + parsed, err := url.Parse(*metadata.HTMLURL) + if err != nil || parsed.Scheme != "https" || + strings.ToLower(parsed.Hostname()) != releaseHost || parsed.User != nil || + parsed.Port() != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", "", false + } + // The escaped path is compared, so an encoded separator such as %2F cannot + // smuggle another path past the prefix the way the decoded form would. + path := parsed.EscapedPath() + if !strings.HasPrefix(path, releasePathPrefix) { + return "", "", false + } + tag, err := url.PathUnescape(strings.TrimPrefix(path, releasePathPrefix)) + if err != nil || normalizeRevision(tag) != revision { + return "", "", false + } + return revision, "https://" + releaseHost + path, true +} diff --git a/cmd/screener-server/release_test.go b/cmd/screener-server/release_test.go new file mode 100644 index 0000000..9725425 --- /dev/null +++ b/cmd/screener-server/release_test.go @@ -0,0 +1,329 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" +) + +const ( + deployedRevision = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + latestRevision = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + latestReleaseURL = "https://github.com/TNTcraftHIM/Screener/releases/tag/" + latestRevision + latestRelease = `{"tag_name":"` + latestRevision + `","html_url":"` + latestReleaseURL + `"}` +) + +func TestParseReleaseMetadataAcceptsOnlyTheStrictReleaseIdentity(t *testing.T) { + tagged := func(page string) string { + return `{"tag_name":"` + latestRevision + `","html_url":"` + page + `"}` + } + testCases := []struct { + name string + payload string + revision string + url string + }{ + {"published release", latestRelease, latestRevision, latestReleaseURL}, + { + "uppercase tag normalizes", + `{"tag_name":"` + strings.ToUpper(latestRevision) + `","html_url":"` + latestReleaseURL + `"}`, + latestRevision, latestReleaseURL, + }, + {"short tag", `{"tag_name":"short","html_url":"` + latestReleaseURL + `"}`, "", ""}, + {"other host", tagged("https://evil.example"), "", ""}, + { + "userinfo", + tagged("https://user@github.com/TNTcraftHIM/Screener/releases/tag/" + latestRevision), + "", "", + }, + { + "port", + tagged("https://github.com:8443/TNTcraftHIM/Screener/releases/tag/" + latestRevision), + "", "", + }, + { + "plaintext page", + tagged("http://github.com/TNTcraftHIM/Screener/releases/tag/" + latestRevision), + "", "", + }, + {"query", tagged(latestReleaseURL + "?asset=1"), "", ""}, + {"fragment", tagged(latestReleaseURL + "#assets"), "", ""}, + { + "encoded path separator", + tagged("https://github.com/TNTcraftHIM%2FScreener/releases/tag/" + latestRevision), + "", "", + }, + { + "page tag disagrees with the release tag", + tagged("https://github.com/TNTcraftHIM/Screener/releases/tag/" + deployedRevision), + "", "", + }, + { + "draft", + `{"tag_name":"` + latestRevision + `","html_url":"` + latestReleaseURL + `","draft":true}`, + "", "", + }, + { + "prerelease", + `{"tag_name":"` + latestRevision + `","html_url":"` + latestReleaseURL + `","prerelease":true}`, + "", "", + }, + {"missing page", `{"tag_name":"` + latestRevision + `"}`, "", ""}, + {"array payload", `[]`, "", ""}, + {"not an object", `"released"`, "", ""}, + {"not json", `nonsense`, "", ""}, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + revision, releaseURL, ok := parseReleaseMetadata([]byte(testCase.payload)) + if ok != (testCase.revision != "") || revision != testCase.revision || + releaseURL != testCase.url { + t.Fatalf("parseReleaseMetadata(%s) = %q, %q, %t; want %q, %q", + testCase.payload, revision, releaseURL, ok, testCase.revision, testCase.url) + } + }) + } +} + +func TestCheckReleaseReportsTheDeployedComparison(t *testing.T) { + testCases := []struct { + name string + current string + status int + body string + wantStatus string + wantCurrent string + wantLatest string + wantReleaseURL string + wantRequestSent bool + }{ + { + name: "newer release", current: deployedRevision, status: http.StatusOK, + body: latestRelease, wantStatus: "update-available", wantCurrent: deployedRevision, + wantLatest: latestRevision, wantReleaseURL: latestReleaseURL, wantRequestSent: true, + }, + { + name: "same release", current: latestRevision, status: http.StatusOK, + body: latestRelease, wantStatus: "up-to-date", wantCurrent: latestRevision, + wantLatest: latestRevision, wantReleaseURL: latestReleaseURL, wantRequestSent: true, + }, + { + name: "endpoint unavailable", current: deployedRevision, + status: http.StatusServiceUnavailable, body: "offline", + wantStatus: "unavailable", wantCurrent: deployedRevision, wantRequestSent: true, + }, + { + name: "unusable metadata", current: deployedRevision, status: http.StatusOK, + body: `{"tag_name":"v1","html_url":"https://evil.example"}`, + // The published page failed validation, so no update is claimed. + wantStatus: "unavailable", wantCurrent: deployedRevision, wantRequestSent: true, + }, + { + name: "deployed revision is not a release", current: "development", + status: http.StatusOK, body: latestRelease, + // No request at all: the check cannot compare an unknown deployment. + wantStatus: "unavailable", wantCurrent: "", wantRequestSent: false, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + requested := false + server := httptest.NewServer(http.HandlerFunc( + func(writer http.ResponseWriter, request *http.Request) { + requested = true + if got := request.Header.Get("Accept"); got != "application/vnd.github+json" { + t.Errorf("Accept = %q", got) + } + if got := request.Header.Get("X-GitHub-Api-Version"); got != "2022-11-28" { + t.Errorf("X-GitHub-Api-Version = %q", got) + } + if got := request.Header.Get("User-Agent"); got != "Screener-release-check" { + t.Errorf("User-Agent = %q", got) + } + writer.WriteHeader(testCase.status) + _, _ = writer.Write([]byte(testCase.body)) + })) + defer server.Close() + + result := checkRelease(t.Context(), testCase.current, server.URL, "") + if requested != testCase.wantRequestSent { + t.Fatalf("endpoint requested = %t; want %t", requested, testCase.wantRequestSent) + } + assertResult(t, result, testCase.wantStatus, testCase.wantCurrent, + testCase.wantLatest, testCase.wantReleaseURL) + }) + } +} + +func TestCheckReleaseFailsClosedWithoutAUsableEndpoint(t *testing.T) { + redirect := httptest.NewServer(http.HandlerFunc( + func(writer http.ResponseWriter, request *http.Request) { + http.Redirect(writer, request, "https://evil.example/releases", http.StatusFound) + })) + defer redirect.Close() + reachable := httptest.NewServer(http.HandlerFunc( + func(writer http.ResponseWriter, _ *http.Request) { + _, _ = writer.Write([]byte(latestRelease)) + })) + defer reachable.Close() + + cancelled, cancel := context.WithCancel(t.Context()) + cancel() + testCases := []struct { + name string + ctx context.Context + apiURL string + }{ + {"redirected endpoint", t.Context(), redirect.URL}, + {"unsupported scheme", t.Context(), "file:///etc/passwd"}, + {"relative endpoint", t.Context(), "releases/latest"}, + {"cancelled check", cancelled, reachable.URL}, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + result := checkRelease(testCase.ctx, deployedRevision, testCase.apiURL, "") + assertResult(t, result, "unavailable", deployedRevision, "", "") + }) + } +} + +func TestOperatorTokenReachesGitHubOnly(t *testing.T) { + endpoints := []struct { + name string + url string + token string + authorized bool + }{ + {"github api", defaultReleaseAPIURL, "operator-token", true}, + {"github api with padded token", defaultReleaseAPIURL, " operator-token ", true}, + {"github api without a token", defaultReleaseAPIURL, " ", false}, + {"plaintext github api", "http://api.github.com/repos", "operator-token", false}, + {"release host", "https://github.com/repos", "operator-token", false}, + {"local fixture", "http://127.0.0.1:8787/releases/latest", "operator-token", false}, + {"lookalike host", "https://api.github.com.evil.example/x", "operator-token", false}, + } + for _, endpoint := range endpoints { + t.Run(endpoint.name, func(t *testing.T) { + parsed, err := url.Parse(endpoint.url) + if err != nil { + t.Fatal(err) + } + if got := authorizesGitHub(parsed, endpoint.token); got != endpoint.authorized { + t.Fatalf("authorizesGitHub(%q) = %t; want %t", + endpoint.url, got, endpoint.authorized) + } + }) + } + + var received http.Header + fixture := httptest.NewServer(http.HandlerFunc( + func(writer http.ResponseWriter, request *http.Request) { + received = request.Header.Clone() + _, _ = writer.Write([]byte(latestRelease)) + })) + defer fixture.Close() + checkRelease(t.Context(), deployedRevision, fixture.URL, "fixture-secret") + if authorization := received.Get("Authorization"); authorization != "" { + t.Fatalf("fixture endpoint received %q", authorization) + } +} + +func TestCheckDeployedReleasePrintsOneLineAndTheOperatorExitCode(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc( + func(writer http.ResponseWriter, _ *http.Request) { + _, _ = writer.Write([]byte(latestRelease)) + })) + defer server.Close() + + testCases := []struct { + name string + revision string + wantCode int + wantJSON string + }{ + { + "update available", deployedRevision + "\n", exitUpdateAvailable, + `{"status":"update-available","currentRevision":"` + deployedRevision + + `","latestRevision":"` + latestRevision + + `","releaseUrl":"` + latestReleaseURL + `"}`, + }, + { + "up to date", latestRevision, exitUpToDate, + `{"status":"up-to-date","currentRevision":"` + latestRevision + + `","latestRevision":"` + latestRevision + + `","releaseUrl":"` + latestReleaseURL + `"}`, + }, + { + "unreadable deployment", "", exitUpdateUnavailable, + `{"status":"unavailable","currentRevision":null,` + + `"latestRevision":null,"releaseUrl":null}`, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + currentFile := filepath.Join(t.TempDir(), "REVISION") + if testCase.revision != "" { + if err := os.WriteFile(currentFile, []byte(testCase.revision), 0o600); err != nil { + t.Fatal(err) + } + } + var out bytes.Buffer + code := checkDeployedRelease(t.Context(), currentFile, server.URL, &out) + if code != testCase.wantCode || out.String() != testCase.wantJSON+"\n" { + t.Fatalf("checkDeployedRelease = %d, %q; want %d, %q", + code, out.String(), testCase.wantCode, testCase.wantJSON+"\n") + } + }) + } +} + +// assertResult compares the whole result through its JSON form so a value that +// must be null is distinguished from one that is merely empty. +func assertResult(t *testing.T, result releaseResult, status, current, latest, releaseURL string) { + t.Helper() + payload, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + expected, err := json.Marshal(releaseResult{ + Status: status, + CurrentRevision: reported(current), + LatestRevision: reported(latest), + ReleaseURL: reported(releaseURL), + }) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(payload, expected) { + t.Fatalf("result = %s; want %s", payload, expected) + } +} + +// reported turns the empty test expectation into the JSON null the result uses. +func reported(value string) *string { + if value == "" { + return nil + } + return &value +} + +// release-update.mjs parseArguments threw the usage string and set +// process.exitCode = 2 when --current-file was absent, so the operator could +// tell a forgotten argument from a check that could not reach GitHub. +func TestCheckDeployedReleaseWithoutACurrentFileIsAUsageError(t *testing.T) { + var out bytes.Buffer + code := checkDeployedRelease(context.Background(), "", defaultReleaseAPIURL, &out) + if code != exitUsage { + t.Fatalf("exit code = %d, want %d", code, exitUsage) + } + if out.Len() != 0 { + t.Fatalf("a usage error wrote %q, want no result line", out.String()) + } +} diff --git a/deploy/check-release.sh b/deploy/check-release.sh index ca72f06..7a82b94 100644 --- a/deploy/check-release.sh +++ b/deploy/check-release.sh @@ -6,13 +6,6 @@ if [ "$#" -gt 1 ]; then exit 2 fi -repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" -node="${SCREENER_NODE:-/usr/local/bin/node}" current_file="${1:-/opt/screener/current/REVISION}" -if [ ! -x "$node" ]; then - printf 'Node runtime is unavailable: %s\n' "$node" >&2 - exit 2 -fi -arguments=("$repo_root/scripts/release-update.mjs" "--current-file" "$current_file") -exec "$node" "${arguments[@]}" +exec /opt/screener/current/screener-server --check-release --current-file "$current_file" diff --git a/deploy/release-app.sh b/deploy/release-app.sh index 2cf2c91..8a6dd5f 100644 --- a/deploy/release-app.sh +++ b/deploy/release-app.sh @@ -7,8 +7,6 @@ if [ "$#" -ne 1 ]; then exit 2 fi -node='/usr/local/bin/node' -npm='/usr/local/bin/npm' upload_root='/opt/screener/uploads' release_root='/opt/screener/releases' current='/opt/screener/current' @@ -23,26 +21,20 @@ old_release='' test "$(dirname -- "$descriptor")" = "$upload_root" [[ "$public_origin" =~ ^https://[A-Za-z0-9.-]+(:[0-9]+)?$ ]] -IFS=$'\t' read -r revision release_id artifact_name artifact_sha manifest_name manifest_sha file_count main_asset < <( - "$node" --input-type=module --eval ' - import { readFileSync } from "node:fs"; - const value = JSON.parse(readFileSync(process.argv[1], "utf8")); - const fields = [ - value.revision, - value.releaseId, - value.artifact, - value.artifactSha256, - value.manifest, - value.manifestSha256, - value.fileCount, - value.mainAsset, - ]; - if (value.schema !== 1 || fields.some((field) => - !["string", "number"].includes(typeof field) || String(field).includes("\t") || String(field).includes("\n") - )) process.exit(2); - process.stdout.write(fields.join("\t") + "\n"); - ' "$descriptor" -) +# The descriptor is JSON.stringify(descriptor, null, 2): one field per line. +descriptor_text() { + sed -n "s|^ \"$1\": \"\([A-Za-z0-9._/-]*\)\",\{0,1\}\$|\1|p" "$descriptor" +} + +grep -qx ' "schema": 1,' "$descriptor" +revision="$(descriptor_text revision)" +release_id="$(descriptor_text releaseId)" +artifact_name="$(descriptor_text artifact)" +artifact_sha="$(descriptor_text artifactSha256)" +manifest_name="$(descriptor_text manifest)" +manifest_sha="$(descriptor_text manifestSha256)" +file_count="$(sed -n 's|^ "fileCount": \([0-9]\{1,\}\),\{0,1\}$|\1|p' "$descriptor")" +main_asset="$(descriptor_text mainAsset)" [[ "$revision" =~ ^[0-9a-f]{40}$ ]] [[ "$release_id" =~ ^[0-9a-f]{7}$ ]] @@ -126,6 +118,7 @@ recover() { systemctl start screener.service || ok=0 wait_for_health || ok=0 fi + rm -f -- "${current}.${release_id}-$$" "${current}.recover-${release_id}-$$" || ok=0 if [ "$ok" -eq 1 ]; then cleanup_release || ok=0 fi @@ -148,8 +141,6 @@ trap 'code=$?; if [ "$code" -ne 0 ]; then recover "$code"; fi' EXIT exec 9>"$lock" flock -n 9 -test -x "$node" -test -x "$npm" old_release="$(readlink -f -- "$current")" test "$(dirname -- "$old_release")" = "$release_root" test -d "$old_release" @@ -174,7 +165,7 @@ while IFS= read -r raw_entry; do esac case "$entry" in /*|*\\*) printf 'invalid archive entry: %s\n' "$raw_entry" >&2; exit 41 ;; - LICENSE|REVISION|package.json|package-lock.json|dist|dist/client|dist/server|dist/client/*|dist/server/*) ;; + LICENSE|REVISION|THIRD-PARTY-NOTICES.txt|screener-server) ;; *) printf 'unexpected archive entry: %s\n' "$raw_entry" >&2; exit 42 ;; esac done < <(tar -tzf "$artifact") @@ -184,115 +175,50 @@ test "$(dirname -- "$(realpath -m -- "$stage")")" = "$release_root" chmod 0755 "$stage" tar --no-same-owner --no-same-permissions -xzf "$artifact" -C "$stage" -STAGE="$stage" MANIFEST="$manifest" FILE_COUNT="$file_count" REVISION="$revision" "$node" --input-type=module <<'NODE' -import { createHash } from 'node:crypto'; -import { lstat, readFile, readdir } from 'node:fs/promises'; -import { join, relative, sep } from 'node:path'; +tab=$'\t' +if [ -n "$(tail -c 1 "$manifest")" ]; then exit 61; fi +if grep -qEv "^[0-9a-f]{64}${tab}[0-9]+${tab}[^${tab}]*$" "$manifest"; then exit 62; fi +if grep -qEv "^[0-9a-f]{64}${tab}[0-9]+${tab}(LICENSE|REVISION|THIRD-PARTY-NOTICES\.txt|screener-server)$" "$manifest"; then exit 63; fi +if [ -n "$(cut -f3 "$manifest" | sort | uniq -d)" ]; then exit 64; fi +if [ "$(wc -l < "$manifest")" -ne "$file_count" ]; then exit 65; fi +if [ -n "$(find "$stage" -type l)" ]; then exit 66; fi +if [ -n "$(find "$stage" -type f -printf '%D:%i\n' | sort | uniq -d)" ]; then exit 67; fi +if [ -n "$(find "$stage" ! -type f ! -type d ! -type l)" ]; then exit 68; fi +if [ "$(find "$stage" -type f | wc -l)" -ne "$file_count" ]; then exit 69; fi +if ! awk -F'\t' '{ printf "%s %s\n", $1, $3 }' "$manifest" | (cd "$stage" && sha256sum -c --strict --quiet -); then exit 70; fi +while IFS="$tab" read -r _ size path; do + test "$(stat -c '%s' "$stage/$path")" = "$size" || exit 70 +done < "$manifest" +if [ "$(cat "$stage/REVISION")" != "$revision" ]; then exit 71; fi +printf 'artifact_manifest=ok files=%s revision=%s\n' "$file_count" "$revision" -const stage = process.env.STAGE; -const text = await readFile(process.env.MANIFEST, 'utf8'); -if (!text.endsWith('\n')) process.exit(61); -const lines = text.slice(0, -1).split('\n'); -const expected = new Map(); -for (const line of lines) { - const fields = line.split('\t'); - if (fields.length !== 3 || !/^[0-9a-f]{64}$/.test(fields[0]) || !/^\d+$/.test(fields[1])) process.exit(62); - const path = fields[2]; - if (!/^(LICENSE|REVISION|package(-lock)?\.json|dist\/(client|server)\/[A-Za-z0-9._/-]+)$/.test(path)) process.exit(63); - if (path.split('/').some((part) => part === '' || part === '.' || part === '..') || expected.has(path)) process.exit(64); - expected.set(path, { hash: fields[0], size: Number(fields[1]) }); -} -if (expected.size !== Number(process.env.FILE_COUNT)) process.exit(65); - -const actual = new Map(); -const identities = new Set(); -const pending = [stage]; -while (pending.length) { - const directory = pending.pop(); - for (const entry of await readdir(directory, { withFileTypes: true })) { - const absolute = join(directory, entry.name); - const metadata = await lstat(absolute); - if (metadata.isSymbolicLink()) process.exit(66); - if (metadata.isDirectory()) { - pending.push(absolute); - } else if (metadata.isFile()) { - const identity = `${metadata.dev}:${metadata.ino}`; - if (identities.has(identity)) process.exit(67); - identities.add(identity); - const path = relative(stage, absolute).split(sep).join('/'); - const body = await readFile(absolute); - actual.set(path, { hash: createHash('sha256').update(body).digest('hex'), size: body.length }); - } else process.exit(68); - } -} -if (actual.size !== expected.size) process.exit(69); -for (const [path, record] of expected) { - const found = actual.get(path); - if (!found || found.hash !== record.hash || found.size !== record.size) process.exit(70); -} -if ((await readFile(join(stage, 'REVISION'), 'ascii')) !== `${process.env.REVISION}\n`) process.exit(71); -console.log(`artifact_manifest=ok files=${actual.size} revision=${process.env.REVISION}`); -NODE - -test -f "$stage/dist/client/$main_asset" -test -f "$stage/dist/server/server/index.js" -find "$stage/dist" -type d -exec chmod 0755 {} + -find "$stage/dist" -type f -exec chmod 0644 {} + -chmod 0644 "$stage/LICENSE" "$stage/REVISION" "$stage/package.json" "$stage/package-lock.json" +# Set the mode before testing it: an archive packaged on a host without an +# executable bit (Windows) records 0644, and the release owns the bit here. +chmod 0755 "$stage/screener-server" +test -x "$stage/screener-server" +chmod 0644 "$stage/LICENSE" "$stage/REVISION" "$stage/THIRD-PARTY-NOTICES.txt" +# Runs as the service user under the manager (no sudo/polkit round trip) so a +# binary or environment file the service cannot use fails here, before cutover. systemd-run \ - --unit="screener-deps-${release_id}" \ + --uid=screener \ + --gid=screener \ --wait \ --collect \ --quiet \ --service-type=exec \ - --property="WorkingDirectory=$stage" \ - --property='CPUQuota=30%' \ - --property='MemoryHigh=160M' \ - --property='MemoryMax=256M' \ - --property='MemorySwapMax=0' \ - --property='RuntimeMaxSec=120s' \ - "$npm" ci --omit=dev --ignore-scripts --no-audit --no-fund + --property='EnvironmentFile=/etc/screener/screener.env' \ + --property='Environment=SCREENER_ENV=production' \ + --property='RuntimeMaxSec=20s' \ + "$stage/screener-server" --check-config -previous_directory="$PWD" -cd "$stage" -sudo -u screener "$node" --input-type=module --eval "await Promise.all([import('sirv'), import('ws'), import('zod'), import('livekit-server-sdk'), import('./dist/server/server/config.js')]);" -"$node" --env-file=/etc/screener/screener.env --input-type=module --eval "import { loadConfig } from './dist/server/server/config.js'; loadConfig(process.env);" -cd "$previous_directory" +if [ -n "$(comm -12 <(find "$old_release" -type f -printf '%D:%i\n' | sort -u) <(find "$stage" -type f -printf '%D:%i\n' | sort -u))" ]; then + exit 80 +fi +printf 'release_inode_intersection=0 new_regular_files=%s\n' \ + "$(find "$stage" -type f -printf '%D:%i\n' | sort -u | wc -l)" -OLD_RELEASE="$old_release" NEW_RELEASE="$stage" "$node" --input-type=module <<'NODE' -import { lstatSync, readdirSync } from 'node:fs'; -import { join } from 'node:path'; -function collect(root) { - const identities = new Set(); - const pending = [root]; - while (pending.length) { - const directory = pending.pop(); - for (const name of readdirSync(directory)) { - const path = join(directory, name); - const metadata = lstatSync(path); - if (metadata.isSymbolicLink()) continue; - if (metadata.isDirectory()) pending.push(path); - else if (metadata.isFile()) identities.add(`${metadata.dev}:${metadata.ino}`); - } - } - return identities; -} -const oldFiles = collect(process.env.OLD_RELEASE); -const newFiles = collect(process.env.NEW_RELEASE); -let intersection = 0; -for (const identity of newFiles) if (oldFiles.has(identity)) intersection += 1; -if (intersection !== 0) process.exit(80); -console.log(`release_inode_intersection=0 new_regular_files=${newFiles.size}`); -NODE - -old_asset="$(OLD_RELEASE="$old_release" "$node" --input-type=module --eval ' - import { readFileSync } from "node:fs"; - const html = readFileSync(`${process.env.OLD_RELEASE}/dist/client/index.html`, "utf8"); - const match = html.match(/]+src="\/(assets\/index-[A-Za-z0-9_-]+\.js)"/); - if (!match) process.exit(2); - process.stdout.write(match[1]); -')" +old_asset="$(curl -fsS --max-time 5 http://127.0.0.1:8787/ | grep -oE 'assets/index-[A-Za-z0-9_-]+\.js' | sed -n 1p)" release_owned=1 mv -- "$stage" "$release" @@ -337,7 +263,7 @@ if [ "$old_asset" != "$main_asset" ]; then fi test -z "$(journalctl -u screener.service --since "$cutover_since" -p warning --no-pager --output=cat)" -asset_sha="$(sha256sum "$release/dist/client/$main_asset" | awk '{print $1}')" +asset_sha="$(curl -fsS --max-time 8 "$public_origin/$main_asset" | sha256sum | awk '{print $1}')" release_owned=0 cutover_started=0 trap - ERR HUP INT TERM EXIT diff --git a/deploy/systemd/screener.service.example b/deploy/systemd/screener.service.example index 425ab61..4c408d8 100644 --- a/deploy/systemd/screener.service.example +++ b/deploy/systemd/screener.service.example @@ -9,8 +9,9 @@ Type=simple User=screener Group=screener WorkingDirectory=/opt/screener/current +Environment=SCREENER_ENV=production EnvironmentFile=/etc/screener/screener.env -ExecStart=/usr/local/bin/node dist/server/server/index.js +ExecStart=/opt/screener/current/screener-server Restart=on-failure RestartSec=3s TimeoutStopSec=20s diff --git a/docs/README.md b/docs/README.md index 9026ea7..f333a8b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -51,6 +51,8 @@ ordinary implementation and UI detail. Client encoded fanout. - [ADR-0010](./adr/0010-cross-platform-client-runtime.md): cross-platform Client runtime and self-contained package boundary. +- [ADR-0012](./adr/0012-shared-go-backend-core.md): one shared Go backend core + for Hosted and Client, amending part of ADR-0010. ## Evidence @@ -77,6 +79,12 @@ Rooms and repository practice: - [Cross-restart room recovery](./research/cross-restart-room-recovery.md) - [Agent context governance](./research/agent-context-governance.md) +Backend structure: + +- [Server consolidation](./research/server-consolidation.md): Go core ownership + and its acceptance checklist +- [Backend audit and refactor preparation](./research/backend-audit-1b01048.md) + Research records verified facts, measurements, assumptions, licenses, and remaining evidence boundaries. It does not own current product behavior or work priority. diff --git a/docs/adr/0002-memory-resident-protected-rooms.md b/docs/adr/0002-memory-resident-protected-rooms.md index a2bb890..4a837ce 100644 --- a/docs/adr/0002-memory-resident-protected-rooms.md +++ b/docs/adr/0002-memory-resident-protected-rooms.md @@ -48,7 +48,7 @@ Stable mode stores only `roomId`, Host-token digest, Viewer-grant digest, authorization generation, code-entry policy, optional password verifier, and dormant lease deadline. It never stores raw tokens, grants, or passwords. -The schema is one exact current version using built-in `node:sqlite`, one +The schema is one exact current version on an embedded SQLite driver, one connection, one writer, and transactional room mutations. An unknown schema, wrong application identity, corrupt row, inaccessible path, or second owner fails startup before signaling or LiveKit mutation. This private pre-release @@ -139,6 +139,7 @@ returns `ROOM_ACCESS_DENIED`. ## Decision Sources - [Node.js SQLite](https://nodejs.org/docs/latest-v24.x/api/sqlite.html) +- [`modernc.org/sqlite` driver](https://pkg.go.dev/modernc.org/sqlite) - [SQLite transactional guarantees](https://www.sqlite.org/transactional.html) - [SQLite locking mode](https://sqlite.org/pragma.html#pragma_locking_mode) - [LiveKit reconnect behavior](https://docs.livekit.io/intro/basics/connect/#network-changes-and-reconnection) diff --git a/docs/adr/0005-automatic-hybrid-media-routing.md b/docs/adr/0005-automatic-hybrid-media-routing.md index 74a9ea3..2d2a354 100644 --- a/docs/adr/0005-automatic-hybrid-media-routing.md +++ b/docs/adr/0005-automatic-hybrid-media-routing.md @@ -18,6 +18,10 @@ physical evidence is in the routing research documents. ## Decision +Hosted and Local always use this controller. The former rollout switch and +separate Host-star authority are removed; SFU availability is still selected by +configuration and the room's privacy policy, not by another routing mode. + ### One Capacity Rule Every non-server endpoint uses one server-authoritative steady outbound media- diff --git a/docs/adr/0009-optional-nat-prediction.md b/docs/adr/0009-optional-nat-prediction.md index 96f9198..e90c294 100644 --- a/docs/adr/0009-optional-nat-prediction.md +++ b/docs/adr/0009-optional-nat-prediction.md @@ -58,11 +58,12 @@ connection acquisition reuses the route controller's existing opportunity ledger the remaining attempts behind a working SFU route. A new connection can provide another mapping opportunity, not guaranteed independent randomness: in particular, Native connections may retain the same shared UDP socket. - When prediction is enabled, that route-owned Browser P2P edge may also use - the existing bounded automatic-recovery budget (two ICE restarts) before - reporting the edge failed. Each restart creates a fresh ICE gathering - generation without adding a route candidate, SFU fallback, or second retry - state; unsuccessful recovery follows the normal route-failed path. + A committed Browser P2P edge uses its existing two-request automatic-recovery + budget: first ICE restart on the same connection, then connection rebuild. + Recovery does not choose a new parent or add a route candidate; unsuccessful + recovery follows the normal route-failed path. A prepared candidate remains owned by the route + operation and is reported failed to that operation instead of starting a + separate ICE-restart loop. ## Consequences diff --git a/docs/adr/0010-cross-platform-client-runtime.md b/docs/adr/0010-cross-platform-client-runtime.md index 0823d1f..b194ff0 100644 --- a/docs/adr/0010-cross-platform-client-runtime.md +++ b/docs/adr/0010-cross-platform-client-runtime.md @@ -2,6 +2,10 @@ - Status: accepted capability-provider architecture and native endpoint media boundary - Date: 2026-09-05 +- Superseded in part: items 1, 5, 7, 12 (its supervisor sentence), 13, 14, and + the local-package consequence were amended in place by + [ADR-0012](./0012-shared-go-backend-core.md), which replaced the TypeScript + server with one shared Go core. Every other item stands as written. ## Context @@ -22,10 +26,11 @@ topologies and makes a saved Site unavailable while another room source runs. ## Decision -1. The TypeScript/Node core remains the sole owner of HTTP, room authority, - admission, signaling, and routing in every deployment. A future local package - runs that same server and the same built Browser assets with local - configuration; Go does not reimplement the product core. +1. One shared core is the sole owner of HTTP, room authority, admission, + signaling, and routing in every deployment. The local package runs that same + core and the same built Browser assets with local configuration; no + deployment gets a second product core. Its implementation is owned by + [ADR-0012](./0012-shared-go-backend-core.md). 2. The packaged product is Screener Client. Its Go process is the cross-platform entry and native capability provider. It starts `/health` and one `/control` WebSocket on IPv4 loopback within ports `39721` through `39730` before room- @@ -45,11 +50,12 @@ topologies and makes a saved Site unavailable while another room source runs. native action, reuses it across successive media generations, and closes it with the page; a picker, share, or room source does not own the socket. The Browser forwards current signaling and remains the participant. -5. A platform package contains the Go entry, a pinned Node runtime, and the same - server/client build used by Hosted Screener. It may also carry one process- - isolated capture binary and the pinned `cloudflared` sidecar. The Go entry - supervises child processes with bounded lifetime. Platform packaging is - metadata around that entry, not another long-running wrapper or UI. +5. A platform package contains one Go executable carrying the same core and + embedded Browser assets used by Hosted Screener. It may also carry one + process-isolated capture binary and the pinned `cloudflared` sidecar. Those + sidecars are the only supervised children and keep their bounded lifetime. + Platform packaging is metadata around that entry, not another long-running + wrapper or UI. 6. The system Browser remains the UI. Browser extensions, userscripts, Electron, Tauri, and resident services need new evidence before they can replace this smaller boundary. A Client-opened Site stores a non-secret opt-in at that @@ -59,8 +65,9 @@ topologies and makes a saved Site unavailable while another room source runs. Client again. 7. A self-contained Local deployment serves reachable LAN peers without a central Screener service. Its explicit `--link` mode starts one accountless - Cloudflare Quick Tunnel for the same Node HTTP/WebSocket surface, injects the - resulting HTTPS origin before Node starts, and otherwise retains the same + Cloudflare Quick Tunnel for the same HTTP/WebSocket surface, applies the + resulting HTTPS origin to the local server configuration before that server + starts listening, and otherwise retains the same memory RoomStore, Browser UI, Viewer grant, signaling, and route controller. The Host sends the ordinary invitation link and the Viewer needs only a Browser. Cloudflare terminates this temporary control path; WebRTC media stays @@ -133,19 +140,21 @@ topologies and makes a saved Site unavailable while another room source runs. origin. Friends use the existing room invitation grant. No Client-specific room authorization system is added. 12. Local authority shutdown first ends every in-memory room through the current - `room-closed` path, then closes signaling and HTTP. The Go supervisor closes - Node stdin, waits, and applies one bounded process timeout. It does not - restart a vanished authority. -13. Client assembly consumes the immutable Web/Server application release from - the same full Git revision and an explicit supported target. The Go binary, - matching target Node runtime, and `app/REVISION` form one package; revision - mismatch fails rather than loading a stale private contract. Client-scoped - pull requests build every target and must start the assembled Local authority, - pass `/healthz`, and stop it cleanly before the candidate is accepted. -14. The root package manifest is the single build/runtime dependency contract: - Browser-only libraries stay in `devDependencies`, while release and Client - assembly install the same manifest with `--omit=dev` for the server runtime. - No second Client dependency list or post-install package surgery is used. + `room-closed` path, then closes signaling and HTTP. The Client cancels the + server context, calls that end-then-close sequence under one bounded + timeout, and only then closes the public tunnel and the loopback service. It + does not restart a vanished authority. +13. Client assembly consumes the immutable application release from the same full + Git revision and an explicit supported target. The Go binary embeds that + release's built Browser assets and the package `REVISION` records the same + revision; a revision mismatch fails rather than loading a stale private + contract. Client-scoped pull requests build every + target and must start the assembled Local authority, pass `/healthz`, and + stop it cleanly before the candidate is accepted. +14. The root package manifest is the single dependency contract for the Browser + bundle and repository tooling. No host installs packages to run Screener, so + the manifest has no runtime half, and no second Client dependency list or + post-install package surgery is used. ## Consequences @@ -153,9 +162,10 @@ Hosted and Local operation share one product contract and one route model. The Go runtime acts as an optional capability provider when an activated Site or Local page discovers it, but it is not a second room product. A mixed room does not expose endpoint implementation to routing policy. The runtime stays small until a -proven native capability needs a protocol field. The local package may contain -two internal processes while presenting one user entry; the supervisor, not a -compatibility protocol, owns their lifetime. +proven native capability needs a protocol field. The local package presents one +user entry and runs its room authority inside that same process; only the +optional capture and tunnel sidecars remain supervised children, and that entry, +not a compatibility protocol, owns their lifetime. Windows gates prove loopback discovery, Local static startup, automatic Host access, LAN invitation construction, a three-Viewer Browser relay tree, two diff --git a/docs/adr/0012-shared-go-backend-core.md b/docs/adr/0012-shared-go-backend-core.md new file mode 100644 index 0000000..d17c50c --- /dev/null +++ b/docs/adr/0012-shared-go-backend-core.md @@ -0,0 +1,111 @@ +# ADR-0012: Shared Go Backend Core + +- Status: accepted +- Date: 2026-09-06 + +## Context + +One TypeScript/Node server owned HTTP, room authority, admission, signaling, and +routing. Hosted and Local composed the same application from it, and the packaged +Client supervised it as a Node child beside a pinned runtime and an extracted +application tree. [ADR-0010](./0010-cross-platform-client-runtime.md) accepted +that shape because a second implementation of rooms, signaling, or routing would +make the two deployments diverge without improving the media path. + +That boundary is paid for in every deployment. The Client package carries a +language runtime and an installed dependency tree beside its Go entry, a +production host needs Node and npm to install and validate a release, and one +user entry runs two processes whose lifetime a supervisor owns. The Client +already owns a Go process, Pion media, and its capture sidecars, so the second +runtime buys only the existing server code. + +Replacing that server is acceptable only if the result stays one implementation: +the same wire contract, the same room semantics, the same persistent schema, and +the same Browser build. + +## Decision + +1. One Go module at the repository root owns the product core. + `cmd/screener-server` (Hosted) and `cmd/screener-client` (Client) are entry + points over the same `internal/server` packages and own only their defaults, + reachability, and lifecycle policy. Neither reimplements rooms, admission, + signaling, or routing. The TypeScript server, its bundled runtime, the process + supervisor, and the Client's runtime/application selection flags are deleted in + the same boundary. +2. The Browser UI stays React/TypeScript. Node, npm, Vite, vitest, and `tsx` + remain build and gate tooling and stop being a runtime requirement on any + deployment or end-user host. `src/shared/protocol.ts` remains the Browser + schema owner; one checked-in wire fixture is replayed by both a TypeScript and + a Go test instead of hand-maintained parallel validation rules. +3. Both binaries serve the same built Browser assets embedded through + `internal/server/webassets`. A binary built without those assets serves the + API only rather than reading a second asset path, so every packaging and + acceptance entry point runs the Vite build before the Go build. +4. One mutex in the signaling server guards every room, session, share, route, + and SFU-ledger mutation, reproducing Node's single thread instead of + per-object locks. Room-touching HTTP handlers, WebSocket handlers, and timer + callbacks all acquire it, and the route controller decides beneath it without + locking or performing I/O. This is a recorded ceiling whose upgrade path is + per-room locks, not a throughput claim. +5. Every place the TypeScript code awaited I/O - password derivation, LiveKit + calls, token issue - releases that lock, performs the work, reacquires the + lock, and revalidates exactly the session, share, connection, publication, + operation, and revision identities the TypeScript code checked after that + await. Insertion-ordered structures preserve the JavaScript `Map` iteration + order that participant, candidate, and timer decisions depend on. Room writes + stay synchronous inside the lock and keep the current fail-fast storage + policy: an unrecoverable authority write ends the process instead of + continuing on divergent state. +6. LiveKit access uses the standard library: HS256 participant and service tokens + plus Twirp JSON RoomService calls over `net/http`, with the current grants, + bounded per-request timeout, per-managed-room serialization, and not-found + rule. No LiveKit SDK is added. +7. Room persistence uses the pure-Go `modernc.org/sqlite` driver over the schema, + pragmas, single exclusive connection, and recovery accepted in + [ADR-0002](./0002-memory-resident-protected-rooms.md). Both storage modes, + their validation, and their failure policy are unchanged, and no build + requires a C toolchain. +8. The port changes no contract. Signaling stays `screener-v21`, the Client + loopback stays v8, capture framing stays v4, and the HTTP API, cookies, error + codes, and close codes keep their exact shapes. No compatibility alias, dual + reader, or migration is added for a change of implementation language. + +## Consequences + +- The Client is one process. Room authority, the loopback capability service, and + native media share it, so a panic in the server ends the Local session exactly + as a crashed Node child did, and the capability service no longer survives a + failed room authority. Hosted exits non-zero and systemd restarts it; nothing + restarts a vanished authority in place. +- Package and runtime cost falls in three places: the Client package loses its + bundled runtime and dependency tree, the application release becomes the server + binary with its notices and revision, and a production host installs no + packages during a release. One Windows 11 before/after run measured cold start + to healthy 160 ms to 81 ms, idle resident memory 73.7 MiB to 10.4 MiB, and + shipped Hosted bytes ~106.7 MB to ~14.0 MB; the + [consolidation research](../research/server-consolidation.md) owns the method + and the full table. Throughput and latency were not measured, and the port + changes no media path. +- Release-time configuration validation and the operator update check are + maintenance modes of the deployed binary, so the release wrapper and the + production host need no Node; [deployment](../deployment.md) owns their exact + commands. +- Development runs two processes: the Vite dev server for the Browser bundle and + the Go application server behind it. The [README](../../README.md) owns those + commands. +- ADR-0010 items 1, 5, 7, 12 (its supervisor sentence), 13, 14, and its + two-internal-process consequence are superseded and amended in place. The rest + of ADR-0010 - loopback control contract, capability boundary, native media + adapters, launcher, and Local composition - is unchanged. +- Go is the only server implementation. A second room, signaling, persistence, or + route-controller implementation stays out of the repository. + +## Primary Sources + +- [Go module layout](https://go.dev/doc/modules/layout) +- [Go `embed`](https://pkg.go.dev/embed) +- [`coder/websocket`](https://pkg.go.dev/github.com/coder/websocket) +- [`modernc.org/sqlite`](https://pkg.go.dev/modernc.org/sqlite) +- [LiveKit tokens and grants](https://docs.livekit.io/frontends/reference/tokens-grants/) +- [LiveKit RoomService API](https://docs.livekit.io/reference/other/roomservice-api/) +- [Server consolidation research](../research/server-consolidation.md) diff --git a/docs/deployment.md b/docs/deployment.md index 72eec72..7e1e92a 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -15,8 +15,8 @@ do not call this wrapper generic. ## Release Boundary -A routine application release changes only the built Screener client/server and -its independent runtime dependency tree. It does not change infrastructure, +A routine application release changes only the built Screener server binary and +the Browser assets it embeds. It does not change infrastructure, service units, proxy/firewall rules, secrets, LiveKit/coturn configuration, or persistent room state. @@ -30,7 +30,7 @@ backup; Git and immutable artifacts own history. ## Build Host -Start from a clean exact revision with Node.js 24 and npm 11: +Start from a clean exact revision with Node.js 24, npm 11, and Go 1.26: ```sh npm ci @@ -38,36 +38,37 @@ npm run check node scripts/package-app-release.mjs ``` -The packager refuses a dirty tree, builds from that exact revision, records the +The packager refuses a dirty tree, builds the Browser assets and the +linux/amd64 server binary from that exact revision without cgo, records the full revision, emits a runtime archive plus path/size/SHA-256 manifest and release descriptor, and -extracts its own artifact to verify it. Upload the archive, manifest, and -descriptor together to `/opt/screener/uploads`. +extracts its own artifact to verify it. The archive contains exactly +`screener-server`, `LICENSE`, `THIRD-PARTY-NOTICES.txt`, and `REVISION`. Upload +the archive, manifest, and descriptor together to `/opt/screener/uploads`. The same application descriptor is also the Client assembly input. On each -target platform, provide that platform's Node executable and Go toolchain: +target platform, provide that platform's Go toolchain: ```sh SCREENER_GO=/path/to/go node scripts/assemble-client.mjs \ /outside/repository/app-release/screener-.release.json \ - /path/to/node \ /outside/repository/Screener-Client \ --target windows-amd64 \ --capture /path/to/platform-capture \ --tunnel /path/to/cloudflared ``` -Assembly refuses a dirty or different revision and emits one directory with -the Client executable, pinned Node runtime, application release, selected -sidecars, production dependencies, and matching `REVISION`. Its required target +Assembly refuses a dirty or different revision and emits one directory with the +Client executable, the Browser assets taken from that release and embedded in +it, the selected sidecars, notices, and matching `REVISION`. Its required target is one of `windows-amd64`, `linux-amd64`, or `darwin-arm64`; every supplied -runtime must match it. It does not create an installer, auto-updater, release -tag, or compatibility bundle. +binary input must match it. It does not create an installer, auto-updater, +release tag, or compatibility bundle. The platform package also carries its native presentation metadata: Windows embeds the icon in the Go executable, Linux emits a freedesktop desktop entry under `share/`, and macOS emits a thin `.app` launcher with an ICNS resource. -The latter two are packaging metadata only and do not duplicate the Client or -Node runtime. +The latter two are packaging metadata only and do not duplicate the Client +executable. CI and local release-candidate builds use the same wrapper on the target's native operating system: @@ -94,8 +95,8 @@ archive and its SHA-256 file; Actions retains candidates for 14 days. This is automatic build output, not a tag, public GitHub Release, or deployment. Do not build or run the full repository check on a constrained production host. -The release wrapper installs only production dependencies in a transient, -CPU/memory/time-bounded unit. +That host runs only the packaged binary and needs no Node, npm, or dependency +install. ## Update Check @@ -115,10 +116,11 @@ as no notice. It never downloads, replaces, or interrupts a running share. An operator can perform the corresponding read-only Server check: ```sh -SCREENER_NODE=/usr/local/bin/node bash deploy/check-release.sh +bash deploy/check-release.sh ``` -The command reads `/opt/screener/current/REVISION` and prints one JSON result. +The script runs the deployed binary's release check, which reads +`/opt/screener/current/REVISION` and prints one JSON result. Exit status `0` means the deployed revision is current, `10` means a newer release is available, and `20` means the check could not establish a valid release identity. For a private repository, inject a short-lived `GITHUB_TOKEN` @@ -128,6 +130,14 @@ state. A different current-revision file may be supplied as its only argument. ## Atomic Cutover +### First Go cutover prerequisite + +The release wrapper is an application updater, not a systemd installer. Before +the first Go release, install and reload the tracked unit as described in the +[self-hosting cutover procedure](./operations/self-hosting.md#first-go-service-cutover). +Do not mix the new Go binary with the old Node unit or an environment file that +still sets `NODE_ENV`. + Run the tracked server entry with the uploaded descriptor: ```sh @@ -142,14 +152,14 @@ The wrapper: manifest, current release, required services, and public origin; 2. rejects unexpected archive paths, links, file types, or duplicate inodes; 3. extracts to a new release directory and verifies every file hash and size; -4. installs an independent `node_modules` tree under resource limits and checks - runtime imports plus production configuration; +4. validates production configuration by running the packaged binary's + configuration check as the service user under a bounded runtime; 5. proves the old and new releases share no regular-file inode; 6. atomically switches `/opt/screener/current`, starts the service, and polls bounded local/public health; and 7. restores the exact prior symlink and service when cutover or health fails. -Never hard-link dependency trees or recursively mutate permissions before the +Never hard-link release files or recursively mutate permissions before the inode audit: metadata changes would violate both releases. Expected service states should be read as data (`systemctl show`), not used as bare commands under strict-shell error traps. diff --git a/docs/operations/self-hosting.md b/docs/operations/self-hosting.md index 7811398..8f2d003 100644 --- a/docs/operations/self-hosting.md +++ b/docs/operations/self-hosting.md @@ -15,8 +15,8 @@ Browser <--------- DTLS-SRTP/UDP ----------> LiveKit :7882 Screener -------- private RoomService -----> LiveKit :7880 ``` -The tracked public baseline uses Node.js 24, nginx, a valid Web TLS -certificate, time synchronization, coturn 4.17.2 or a newer patched release, +The tracked public baseline uses nginx, a valid Web TLS certificate, time +synchronization, coturn 4.17.2 or a newer patched release, and LiveKit Server 1.13.6. LiveKit is required for the accepted SFU fallback and tracked release wrapper. Keep the accepted patch pinned until its replacement passes the same Browser/SFU acceptance boundary. @@ -44,7 +44,8 @@ domains, certificates, users, and resource limits required by the host. ## Initial Setup 1. Configure DNS and TLS for the Web origin and STUN name. -2. Install Node.js, nginx, coturn, and the pinned LiveKit Server patch. +2. Install nginx, coturn, and the pinned LiveKit Server patch. The Screener + release is one static binary and needs no language runtime. 3. Create an unprivileged `screener` service account, `/opt/screener/releases`, `/opt/screener/uploads`, and an access-restricted environment file. 4. Provision one verified initial immutable release and atomically point @@ -62,6 +63,33 @@ domains, certificates, users, and resource limits required by the host. 8. Start coturn and LiveKit before Screener, then nginx. The LiveKit readiness drop-in waits for its private control listener before Screener. +### First Go service cutover + +The tracked unit starts the Go binary. A Node-to-Go deployment is a one-time +infrastructure transaction, not a routine application update: its failure path +must restore the old unit and environment before starting the prior release. +Prepare and verify that recovery before installing the unit: + +```sh +sudo install -o root -g root -m 0644 \ + deploy/systemd/screener.service.example \ + /etc/systemd/system/screener.service +sudo systemctl daemon-reload +sudo systemctl show screener.service -p ExecStart -p Environment --no-pager +``` + +The output must show `/opt/screener/current/screener-server` and +`SCREENER_ENV=production`. The staged environment removes `NODE_ENV` and +`PEER_ASSISTED_MEDIA`, and replaces route `NODE_DEBUG` with `SCREENER_DEBUG=route`. +Keep credentials, ports, capacity and media configuration unchanged. Do not +restart the old release using the Go unit. The routine release wrapper owns +only application-symlink recovery; it does not install or restore systemd units. + +The first-cutover transaction retains the previous unit, environment and closed +SQLite files, applies unit/environment plus release together, and restores those +exact values on failure before reloading systemd and restarting Node. After a +successful Go cutover, later application releases use the ordinary wrapper. + LiveKit must be dedicated to this Screener application, set `room.auto_create: false`, and expose its control listener only to the proxy and application host. Screener binds its own listener before touching LiveKit, diff --git a/docs/product/presentation-lifecycle.md b/docs/product/presentation-lifecycle.md index a9f293a..4e1ccae 100644 --- a/docs/product/presentation-lifecycle.md +++ b/docs/product/presentation-lifecycle.md @@ -79,7 +79,9 @@ by one presentation reducer. Older route or media generations cannot overwrite the current result, and raw server errors are never rendered directly. The room route revision orders control messages; it is not a Viewer media -identity. A Viewer frame belongs to its local media generation and remains +identity. Reauthentication replaces the route snapshot authoritatively, even if +a replacement controller restarted revision numbering; ordinary updates remain +monotonic within that authority. A Viewer frame belongs to its local media generation and remains current across unrelated graph revisions until that exact binding is replaced, invalidated, or terminally failed. Pending candidates never own the primary badge, status line, or overlay while committed media remains proved. Those diff --git a/docs/product/routing-transport.md b/docs/product/routing-transport.md index bc74b52..96adaf1 100644 --- a/docs/product/routing-transport.md +++ b/docs/product/routing-transport.md @@ -16,9 +16,8 @@ owns ports and [self-hosting](../operations/self-hosting.md) owns service setup. - SFU-fed and peer-fed Viewers may both serve as ordinary peer parents. - A route ends in usable media, explicit bounded waiting, or clear failure. -The flagship deployment enables the all-room controller. A lightweight -deployment may disable peer assistance and keep only bounded direct Host edges; -it does not gain another transport or compatibility protocol. +Every deployment uses this controller. Omitting LiveKit keeps the same bounded +P2P graph without its SFU fallback; there is no separate Host-star mode. ## Per-Share Route Policy @@ -98,10 +97,11 @@ begins only after that exact candidate commits as the active route. When NAT traversal is enabled, another operation may use the remaining connection-attempt budget after rollback. The waiting display uses the actual server-issued attempt ordinal; it never counts time as an attempted connection. -When NAT prediction is enabled, a route-owned P2P candidate may also use its -existing bounded automatic-recovery budget (two ICE restarts) before it reports -that failure; each restart creates a fresh mapping generation without reopening -the route candidate or introducing a second reconciliation loop. +Committed Browser P2P recovery first restarts ICE on the same connection, then +rebuilds that connection if necessary, within the existing two-request budget. +It does not require NAT prediction or reopen the route candidate. A prepared +candidate instead follows its current route operation's failure path, without +an independent restart loop. When a newly committed Host-root Viewer exposes unused downstream capacity while another Host root has at least two direct children, the same background operation may diff --git a/docs/project-memory.md b/docs/project-memory.md index 0c16cd3..a33ed4b 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -1,16 +1,19 @@ # Project Memory -Last updated: 2026-09-05 +Last updated: 2026-09-06 Screener is private, low-latency game screen sharing for one Host and up to 20 authenticated friends. The current product surface is Web Host, Web Viewer, and Browser relay; desktop and mobile Browsers are Viewer targets. It is not a -public broadcast service. Source includes a cross-platform Client that reuses -the TypeScript product core in a self-contained Local deployment or opens one -configured Site through the system Browser. Its Go entry presents Local, -temporary public-link, and saved-Site choices on every launch, starts one process- -level loopback capability service, supervises Local Node when selected, and owns -the explicit native media path chosen in the same Web UI. Room source and media +public broadcast service. Hosted and Client run one shared Go core +([ADR-0012](./adr/0012-shared-go-backend-core.md)); Node and Vite build the +Browser UI and are not a runtime. Source includes a cross-platform Client that +runs that same core in a self-contained Local deployment or opens one configured +Site through the system Browser. Its single Go process presents Local, temporary +public-link, and saved-Site choices on every launch, starts one process-level +loopback capability service, owns the Local room authority in-process when +selected, and owns the explicit native media path chosen in the same Web UI. +Room source and media implementation are independent: a topology may mix Browser and Native peers without changing participant, signaling, capacity, or route identity. Native Host and H.264/VP8 Viewer receive/relay adapters share the same Pion media edge; @@ -84,7 +87,7 @@ implementation and routine UI detail. ## Current Snapshot -Canonical source uses the strict `screener-v20` Browser/server contract with +The audit candidate uses the strict `screener-v21` Browser/server contract with committed media readiness, bounded candidate-relative progress and one-shot Host-root convergence. It retains optional SQLite room authority and the H.264/VP8 sender gate. [Status](./status.md) owns the compact current execution diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 8e83b2d..9448871 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1,14 +1,14 @@ # Configuration And Ports The tracked [`.env.example`](../../.env.example) is the executable schema -companion; `src/server/config.ts` is validation truth. Keep real values in the +companion; `internal/server/config` is validation truth. Keep real values in the service secret store or an untracked access-restricted environment file. ## Application Environment | Variable | Contract | | --- | --- | -| `NODE_ENV` | `production` enables production-only validation. | +| `SCREENER_ENV` | `development` or `production`, default `development`; required for every server deployment: `production` enables production-only validation and the `Secure` site-access cookie. | | `LISTEN_HOST` | Defaults to `0.0.0.0`; bare-metal production normally uses `127.0.0.1`. | | `PORT` | Positive TCP port, default `8787`; the tracked release wrapper supports only that default. | | `PUBLIC_BASE_URL` | Exact public HTTP(S) origin; production requires HTTPS. | @@ -17,13 +17,11 @@ service secret store or an untracked access-restricted environment file. | `ROOM_LEASE_SECONDS` | Positive dormant lease, default `86400`; active Host prevents expiry. | | `ROOM_DATABASE_PATH` | Optional absolute SQLite file path; unset selects memory mode. | | `MAX_VIEWERS_PER_ROOM` | `1..20`, default `8`. | -| `PEER_ASSISTED_MEDIA` | Enables the all-room bounded Peer/SFU controller. | | `ENDPOINT_MEDIA_COPY_CAPACITY` | Shared endpoint steady-copy cap `1..3`, default `2`. | | `STUN_URLS` | Comma-separated `stun:` URLs; at least one is required in production. | | `NAT_PREDICTION_ENABLED` | Optional bounded NAT prediction capability, default `false`; requires an ordinary `STUN_URLS` endpoint on UDP 3478 plus reachable same-host UDP 3479/3480 listeners. | -Automatic SFU fallback is enabled only when `PEER_ASSISTED_MEDIA=true` and all -four values below are present: +Automatic SFU fallback is enabled when all four values below are present: | Variable | Contract | | --- | --- | @@ -37,13 +35,15 @@ another. LiveKit tokens are short-lived media credentials and do not provide application E2EE. Removed access, room TTL, endpoint-tier, room-rollout, and TURN variables fail -startup even when blank. The private deployment is upgraded atomically; there -are no compatibility aliases or dual configuration readers. +startup even when blank. A present `NODE_ENV` fails the same way, so a stale +environment file cannot silently drop a deployment out of production. The +private deployment is upgraded atomically; there are no compatibility aliases or +dual configuration readers. -During pre-release route canaries, standard Node `NODE_DEBUG=screener-route` -enables sanitized room and participant-ordinal events. It records route reasons, -candidates, revisions, quality states and commit/failure outcomes, but not raw -Peer IDs, SDP, ICE candidates, tokens or media credentials. +During pre-release route canaries, `SCREENER_DEBUG=route` enables sanitized room +and participant-ordinal events. It records route reasons, candidates, revisions, +quality states and commit/failure outcomes, but not raw Peer IDs, SDP, ICE +candidates, tokens or media credentials. ## Public And Private Ports diff --git a/docs/research/backend-audit-1b01048.md b/docs/research/backend-audit-1b01048.md new file mode 100644 index 0000000..5cbf629 --- /dev/null +++ b/docs/research/backend-audit-1b01048.md @@ -0,0 +1,106 @@ +# Backend Audit Reconciliation + +- Reviewed: 2026-09-06 +- External evidence baseline: `1b01048e558cfa69b2c8216e658374d06037bfe4`. +- Integration baseline: `d3232cfd2929eafc94a0e3f003cd2bcdcd47089c`. +- Scope: verified lifecycle fixes and removal of the optional Host-star route. + This is not the Go server rewrite or a new physical-media acceptance claim. + +The external package contains 244 raw claims, not 244 confirmed defects. +Its handoff, source patch, and machine-readable findings remain external +evidence. This document records the independent reconciliation; the +[TODO ledger](../todo.md) alone owns remaining work. + +## Accepted Corrections + +- A quality candidate rejected before preparation consumes its existing + opportunity, preventing an identical prepare/deny loop. +- Disposing a Native media bridge settles its pending start. Native relay + callbacks bind the actual peer, including prepared children. +- A failed Native pause remains paused locally; a failed resume returns to the + paused intent. No new pause protocol or parallel state owner is introduced. +- Prepared Native receive deadlines follow the server operation. Viewer teardown + retires pending candidates and their recovery timers; terminal signaling + teardown uses its existing stop owner. +- Authentication is an authoritative route snapshot, even when a replacement + controller restarts revision numbering. Ordinary route updates retain their + monotonic fence; session and media identities still reject stale callbacks. +- Only explicit `committed: true` publishes a newly committed connection + identity. Stop-sharing presence is emitted after graph teardown. +- Room expiry and explicit abandonment share termination cleanup. Shutdown + closes Vite before its shared listener and attempts every SFU drain even when + one fails. Hosted shutdown reports failure explicitly. +- Native Receiver creation releases its unused GCC observer-map entry. Local + startup rejects an already occupied health port; this is a duplicate-instance + guard, not atomic socket handoff or cryptographic process identity. +- Native control keeps one reader, a bounded queue, and serial handling so socket + close can cancel an outstanding system-picker operation. Explicit share close + is a clean end; natural capture-process EOF still needs cause attribution. +- Gates use current loopback constants and explicit test STUN configuration. + Packagers reject in-repository output. Deployment templates specify production + mode and failed cutover cleanup removes its temporary links. +- Hygiene skips only GitHub's all-zero base SHA; a missing nonzero commit remains + an error. Uncalled exports and obsolete gate aliases are removed. + +## Resolved Decisions + +The owner removed `PEER_ASSISTED_MEDIA`. Hosted and Local now use the same +bounded graph controller; absent LiveKit configuration still means P2P-only +service. Room privacy and topology-optimization controls remain independent. +The old environment variable must be removed before the next deployment. +There is no Host-star compatibility path to port. Removing the unused +authentication fields requires the coordinated private `screener-v21` release; +rollback to the prior server must also restore its environment configuration. + +[ADR-0009](../adr/0009-optional-nat-prediction.md) distinguishes server-owned +three-attempt NAT acquisition from active Browser edge recovery. Active recovery +first restarts ICE, then rebuilds the connection if needed. Uncommitted candidates +return failure to their route operation; the unreachable extra local restart +branch was removed. Neither kind of restart guarantees a new NAT mapping. + +The Client launch marker remains origin activation, not proof that the process +is alive. One failed discovery can mean contention or a permission delay, so +automatically deleting the marker would break the accepted saved-Site workflow. +Browser capture remains available in the existing picker. + +## Claims Not Adopted Wholesale + +- Catching every SQLite exception and continuing is not a complete recovery + strategy: failed disconnect persistence can leave an active room indefinitely. + Keep failure observable; design storage failure handling with its authority, + rather than adding scattered catch-and-continue guards. +- A Local public invitation URL does not identify the transport used by a LAN + visitor. Cookie issuance must support both HTTP LAN and HTTPS public access; + globally dropping Secure or trusting arbitrary forwarded headers is not an + acceptable shortcut. +- Audio capture can end silently while video continues. Ending healthy video is + not accepted as the fix; explicit audio availability/recovery needs a coherent + media contract. Native prepared-bridge fallback likewise needs a shared + session-level failure owner, not a per-candidate flag that is immediately lost. +- Registering Receivers with Engine, deduplicating media ledgers, splitting page + owners, and replacing full graph snapshots are refactor candidates, not proven + present-day leaks or measured performance wins. +- No replacement media pipeline, custom scoring, periodic retries, TURN, IDL + framework, compatibility readers, or new revision namespace was accepted. + +## Refactor Direction + +The [consolidation research](./server-consolidation.md) owns the shared Go module +layout that replaced the single TypeScript backend. Hosted and Local preserve +one graph/operation owner, with the Node runtime and superseded server packaging +removed in that same boundary. +Browser UI and native capture adapters are reuse boundaries, not rewrite targets. + +Use the current behavioral tests as an oracle, not a requirement to preserve +private class structure. Extract cohesive packages without duplicating mutable +state; neither a file split nor one interface per old file is inherently an +improvement. Measure package size, startup, memory, and contract parity. + +## Sources + +- [WebRTC restartICE](https://www.w3.org/TR/webrtc/#dom-rtcpeerconnection-restartice) + defines ICE restart, not a promise of different network mappings. +- [coder/websocket](https://pkg.go.dev/github.com/coder/websocket) + documents the connection read and cancellation contract. +- [Go module layout](https://go.dev/doc/modules/layout) + supports multiple commands and shared internal packages. diff --git a/docs/research/cross-platform-client-runtime.md b/docs/research/cross-platform-client-runtime.md index cdcb763..dbe2692 100644 --- a/docs/research/cross-platform-client-runtime.md +++ b/docs/research/cross-platform-client-runtime.md @@ -25,7 +25,7 @@ Chromium UI separate from a native voice/media module and updater. Oopz ships Flutter, a second WebView, FFmpeg, Agora RTC, and Tencent LiteAV together; that stack is substantially larger and duplicates media ownership. Screener follows the first two products' process separation but not their UI runtimes, and avoids -Oopz's parallel RTC stacks: one Go supervisor, one Pion media core, and one thin +Oopz's parallel RTC stacks: one Go process, one Pion media core, and one thin system capture sidecar per platform remain sufficient. No binary code or private application data was copied or inspected. @@ -49,25 +49,20 @@ credentials or route policy; those stay in the Browser/server protocol. ## Client Composition -The existing TypeScript server owns the room and route contract. A local package -therefore composes three artifacts behind one user entry: - -```text -screener-client -runtime/node -app/dist -``` - -The Client's Go entry starts and supervises the pinned Node runtime in Local -mode; Node serves the same Screener application with explicit local -configuration. In Site mode the system Browser opens the saved Site while the -same Go process remains its loopback native-media owner. The system Browser -remains the UI. +The Client and Hosted Screener run the same server core, so the local package is +one executable carrying the built Browser assets plus the optional capture and +tunnel sidecars; [the Client README](../../cmd/screener-client/README.md) owns +that layout. In Local mode the Client runs the server in its own process with +explicit local configuration. In Site mode it starts no server, and the system +Browser opens the saved Site while the same process remains its loopback +native-media owner. The system Browser remains the UI. Official Go process APIs require every started child to be waited and provide a -bounded `WaitDelay` for cancellation and stuck I/O. The current supervisor uses -stdin EOF for graceful Node shutdown, waits for the child, and does not restart -it. Windows is physically exercised; macOS and Linux remain package gates. +bounded `WaitDelay` for cancellation and stuck I/O. That guidance now covers the +capture and cloudflared sidecars only: the room authority is no longer a child +process, and Local shutdown is one bounded in-process sequence that ends rooms +before closing signaling and HTTP. Windows is physically exercised; macOS and +Linux remain package gates. ## Local And Hosted Reachability @@ -77,12 +72,13 @@ its `loopback-network` permission; without that permission the cross-origin request was blocked. The gate checks both expected outcomes. A self-contained LAN room uses a localhost Host URL and a selected LAN IPv4 -Viewer URL backed by the same local TypeScript process. The Client persists an +Viewer URL backed by the same local Client process. The Client persists an optional user-chosen access password and passes it only to its Host page in a consumed fragment; a blank value leaves the Local site open. Explicit `--link` mode launches the packaged Cloudflare Quick Tunnel against that same local HTTP server and supplies its random HTTPS origin -to Node before startup. The public path therefore reuses the exact frontend, +to the local server configuration before it starts listening. The public path +therefore reuses the exact frontend, WebSocket signaling, RoomStore, and Viewer grant instead of adding a rendezvous or second client protocol. Cloudflare carries control traffic; WebRTC media uses public STUN and remains P2P-only. @@ -111,6 +107,12 @@ result selects one UI runtime; it does not create parallel products. ## Current Evidence +The runs below are pre-port evidence: they exercised the package that bundled a +Node runtime and an extracted application tree. Their capture, discovery, media, +and network results still apply; every package composition, startup, and +shutdown claim must be re-verified on the single-binary Client before that build +is accepted. + On Windows with Chrome for Testing 151.0.7922.138: - the Local Client started its built static application and reached `/healthz`; diff --git a/docs/research/cross-restart-room-recovery.md b/docs/research/cross-restart-room-recovery.md index 031e0bd..f5752b2 100644 --- a/docs/research/cross-restart-room-recovery.md +++ b/docs/research/cross-restart-room-recovery.md @@ -68,7 +68,7 @@ truth path. ## Storage And Deployment Boundary -The accepted implementation uses built-in Node `node:sqlite`, one exact schema, +The accepted implementation uses one embedded SQLite driver, one exact schema, one process connection and transactional low-frequency room writes. No ORM, event table, stats writer, compatibility reader or migration chain is added. Unknown schema, wrong application identity, corrupt row, inaccessible file or a @@ -96,6 +96,7 @@ restoring one later requires an explicit credential-invalidating decision. ## Primary Sources - [Node.js SQLite](https://nodejs.org/docs/latest-v24.x/api/sqlite.html) +- [`modernc.org/sqlite` driver](https://pkg.go.dev/modernc.org/sqlite) - [SQLite transactional guarantees](https://www.sqlite.org/transactional.html) - [SQLite locking mode](https://sqlite.org/pragma.html#pragma_locking_mode) - [LiveKit reconnect behavior](https://docs.livekit.io/intro/basics/connect/#network-changes-and-reconnection) diff --git a/docs/research/server-consolidation.md b/docs/research/server-consolidation.md index 0dc234c..eee7ede 100644 --- a/docs/research/server-consolidation.md +++ b/docs/research/server-consolidation.md @@ -1,50 +1,46 @@ # Server Consolidation -- Reviewed: 2026-09-05 -- Status: design candidate for the phase after Browser/Client acceptance +- Reviewed: 2026-09-06 +- Status: implemented in the candidate; the acceptance list below is the + remaining boundary - Scope: one shared Go backend for Hosted and self-contained Client operation ## Current Ownership -Hosted [`index.ts`](../../src/server/index.ts) and Local -[`local-index.ts`](../../src/server/local-index.ts) already compose the same -[`createScreenerServer`](../../src/server/app.ts). The Go -[`clientapp`](../../native/client/internal/clientapp/app.go) supervises that Node -process and supplies native media; it does not duplicate room or route authority. -Consolidation replaces this sole backend rather than merging two implementations. +`cmd/screener-server` (Hosted) and `cmd/screener-client` (Client) compose the +same application from `internal/server` and own only their defaults, +reachability, and lifecycle policy. Within that scope `protocol` owns wire types, +strict decoding, and shared scalars; `config` owns environment validation plus +the Hosted and Local compositions; `room` owns room authority, participants, and +the SQLite database; `route` decides graph transitions without I/O; `signal` +executes signaling and SFU effects under one lock; `sfu` owns admission, LiveKit +room control, and token issue; `app` owns HTTP composition, site access, static +assets, and lifecycle; and `webassets` carries the embedded Browser bundle. +`internal/client` keeps the native, launcher, and platform boundaries and +supervises no server process. These are useful boundaries, not duplication to +remove. -[`RoomStore`](../../src/server/room-store.ts) owns room authority and participants; -[`RoomDatabase`](../../src/server/room-database.ts) persists only access and lease -material. [`RoomRouteController`](../../src/server/room-route-controller.ts) -decides graph transitions without network I/O; -[`HybridMediaRouter`](../../src/server/hybrid-media-router.ts) executes signaling -and SFU effects. These are useful boundaries, not duplication to remove. +## Structural Simplification -## Preparatory Simplification - -1. Unify room termination cleanup. `SignalingServer.closeRoom` and `expireRooms` - in [`signaling.ts`](../../src/server/signaling.ts) delete the same timers, - connection evidence, share state and router resources before closing sockets. - Use one cleanup owner with the existing termination reason as input. -2. Remove the Hybrid connection-ID mirror. `connectionIdsByViewer` is populated - through router callbacks, then `handleViewerQualityEvidence` checks that map - and `resolveActiveViewerMediaEdge` again. Derive the Hybrid identity from the - committed edge; retain the evidence gate's own last-observation identity. - Cover reauthentication and in-place connection adoption before removing the - callbacks. The ordinary routing mode remains a separate consumer until its - product decision is made. +1. Room expiry and abandonment share one `terminateRoom` path in `signal`. + Preserve the distinct semantics of stopping a share, ending a room, and + restarting a persistent server. +2. The Hybrid connection-ID mirror is gone. The viewer-quality evidence path + and the active media-edge lookup read the connection identity from the + committed edge, and the evidence gate keeps its own last-observation + identity; router tests cover in-place connection adoption during a rebuild + and exact direct/peer-relayed evidence sources. These are structural simplifications, not reproduced functional failures. +The [audit reconciliation](./backend-audit-1b01048.md) separates verified fixes +from disputed recommendations. Its external staged plan is not an instruction +to perform extractions for their own sake. ## Product Choice -`PEER_ASSISTED_MEDIA=false` remains a documented, default configuration in -[`.env.example`](../../.env.example). It owns a separate Host-star path in -`SignalingServer`, including child admission and SDP-answer readiness; Local and -production use the graph controller. Decide whether this mode still belongs to -the product before translating it. If automatic Peer topology is universal, -remove the switch and ordinary path together; otherwise retain its explicit -contract; this reachable mode is not dead code. +The graph controller is now universal. The former rollout switch and ordinary +Host-star signaling path were removed together, so Hosted and Local +share one route authority and LiveKit remains an optional fallback of that graph. ## Target Composition @@ -54,14 +50,13 @@ cmd/screener-server -> shared server application cmd/screener-client -> shared server application + launcher + native media internal/server -> rooms, routing, HTTP/signaling, persistence, SFU adapters internal/client -> current native and platform boundaries -one React/Vite static artifact +one React/Vite static artifact -> embedded in both binaries ``` Use packages for cohesive owners and files for local concerns. Do not introduce a framework layer per file, a second product API, or a multi-module build. -Move the existing `native/client` Go module into the common scope during the -migration. Hosted and Local construct the same validated application options; -entry points own their defaults, reachability and lifecycle policy. +Hosted and Local construct the same validated application options; entry points +own their defaults, reachability and lifecycle policy. Each room keeps one serialized state owner. Go HTTP/WebSocket handlers must not mutate room, share and route maps independently. Run password derivation and @@ -72,16 +67,19 @@ unrelated locks around each map. ## Reusable Components -- Use standard [`net/http`](https://pkg.go.dev/net/http) and - [`embed`](https://pkg.go.dev/embed) for HTTP and built assets. -- Reuse the Client's [`coder/websocket`](https://pkg.go.dev/github.com/coder/websocket) - for signaling and the official [LiveKit Go SDK](https://github.com/livekit/server-sdk-go) - for room control and tokens. -- Keep the current SQLite schema and transactions. Compare a small - [`modernc.org/sqlite`](https://pkg.go.dev/modernc.org/sqlite) build/runtime gate - against package and locking needs before selecting a driver; no ORM is needed. -- Follow [Go's shared `internal` and multiple `cmd` guidance](https://go.dev/doc/modules/layout). - Retain current Pion, capture sidecars and control-tunnel ownership. +- Standard [`net/http`](https://pkg.go.dev/net/http) and + [`embed`](https://pkg.go.dev/embed) serve HTTP and the built assets. +- Signaling reuses the Client's + [`coder/websocket`](https://pkg.go.dev/github.com/coder/websocket). +- LiveKit room control and tokens use the standard library only: HS256 JWTs and + Twirp JSON requests. The official + [LiveKit Go SDK](https://github.com/livekit/server-sdk-go) adds a large module + and license surface for a handful of call shapes and is not used. +- Room persistence keeps the current schema and transactions on the pure-Go + [`modernc.org/sqlite`](https://pkg.go.dev/modernc.org/sqlite) driver, which + builds without cgo and holds one exclusive connection; no ORM is needed. +- The layout follows [Go's shared `internal` and multiple `cmd` guidance](https://go.dev/doc/modules/layout). + Pion, capture sidecars and control-tunnel ownership are unchanged. ## Minimum Acceptance @@ -92,9 +90,40 @@ rollback, including stale asynchronous completions. Preserve short-lived SFU authority and release resources only after physical drain confirmation. Use the same Browser contract scenarios against Hosted and Local, plus one -physical mixed Browser/Client media flow. Measure package size, startup and idle -memory before and after. Keep one strict wire contract and one schema owner; -avoid hand-maintained parallel TypeScript/Go validation rules. Retire the Node -server, bundled runtime, supervisor and old packaging paths in the same accepted +physical mixed Browser/Client media flow. Package size, startup and idle memory +are measured below. Keep one strict wire contract and one schema owner; +avoid hand-maintained parallel TypeScript/Go validation rules. The Node server, +bundled runtime, supervisor and old packaging paths retire in this same accepted integration boundary, preserving [ADR-0005](../adr/0005-automatic-hybrid-media-routing.md) -and [ADR-0010](../adr/0010-cross-platform-client-runtime.md). +and [ADR-0010](../adr/0010-cross-platform-client-runtime.md) as amended by +[ADR-0012](../adr/0012-shared-go-backend-core.md). + +## Measured Before And After + +One machine, 2026-09-06: Windows 11, Node 24.15.0, Go 1.26.6. A script spawned +each server with the same production configuration (`PORT=18787`, HTTPS public +origin, site-access password, one STUN URL, loopback listen host), polled +`/healthz` every 10 ms until `{"status":"ok"}`, waited 5 s idle, sampled +resident memory through PowerShell `WorkingSet64`, then killed the process. +Five runs per side; the median is reported. Before is the Node baseline at tag +`baseline-b20fd88`; after is `screener-server` under the same script. + +| metric | before | after | +| --- | ---: | ---: | +| cold start to healthy | 160 ms | 81 ms | +| idle resident memory | 77,242,368 B | 10,928,128 B | +| shipped Hosted bytes | ~106.7 MB | ~14.0 MB | + +The before total is the Node runtime (91,694,408 B), the production +`node_modules` tree (12,640,469 B in 1,513 files), `dist/client` (1,492,721 B) +and `dist/server` (856,898 B). The after total is the linux/amd64 +`screener-server` built with `-s -w` and embedded assets (13,983,906 B) plus its +notices and `REVISION`. For reference, the windows/amd64 binaries are +14,276,608 B (`screener-server`) and 22,703,104 B (`screener-client`, which also +carries Pion and the terminal UI); the Client package additionally drops the +Node runtime and dependency tree it used to ship beside its Go executable. + +The first two starts of a freshly built binary took 469 ms and 336 ms, which is +consistent with on-access antivirus scanning of a new executable; the median +above is the steady state. Not measured: throughput or latency under load, which +this port does not change, and the Linux deployment host. diff --git a/docs/status.md b/docs/status.md index 8f8e3b3..45d05ae 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,14 +1,15 @@ # Current Status -Last updated: 2026-09-05 +Last updated: 2026-09-06 This is the current execution index. Git history owns completed timelines; [verification status](./verification-status.md) owns evidence boundaries. -## Production +## Last Production Postflight -- `https://share.bonfire.icu` runs the strict `screener-v19` Browser/server - contract. Exact revision, release, artifact, manifest, and asset identity are +- The pre-Go postflight at `https://share.bonfire.icu` verified the strict + `screener-v19` Browser/server contract. Current revision, release, artifact, + manifest, and asset identity are retained by the immutable release descriptor, runtime `REVISION`, and deployment record rather than copied into this source snapshot. - The latest scoped postflight found public health and the immutable Browser @@ -26,7 +27,7 @@ This is the current execution index. Git history owns completed timelines; - Public media listeners remain STUN-only UDP 3478 and LiveKit UDP 7882. Production enables optional NAT prediction with self-hosted STUN-only UDP 3479 and 3480; ordinary `STUN_URLS` and media routes remain unchanged. Web - ingress is TCP 80/443; Node 8787 and LiveKit control/signaling 7880 are + ingress is TCP 80/443; Screener 8787 and LiveKit control/signaling 7880 are private. TURN, ICE/TCP, media TCP, TLS relay, port 5349, and relay ranges are disabled. - Browser video uses the content-independent H.264 sender gate with VP8 @@ -50,11 +51,14 @@ This is the current execution index. Git history owns completed timelines; ## Current Source -- Canonical source uses the strict `screener-v20` Browser/server contract. - Optional SQLite stable authority, non-expiring local preferred code, atomic - room replacement and the Host codec selector remain implemented; production - selects stable storage. Graceful restart, crash, timeout and network loss use - one reconnect state. LiveKit room teardown cannot stop Host-owned capture. +- Source uses the strict `screener-v21` Browser/server contract and one + shared Go server core for Hosted and Client; the graph controller is universal + and the Host-star configuration and wire path are removed. Optional SQLite + stable authority, non-expiring + local preferred code, atomic room replacement and the Host codec selector + remain implemented; production selects stable storage. Graceful restart, crash, + timeout and network loss use one reconnect state. LiveKit room teardown cannot + stop Host-owned capture. - One event-driven controller owns the committed graph and one room-serial child operation. Initial direct acquisition uses a five-second foreground window; exact transport-connected progress may retain that candidate through the @@ -82,9 +86,8 @@ This is the current execution index. Git history owns completed timelines; Enabled acquisition now shares a three-attempt budget in the route controller, across foreground P2P and background direct continuation. Viewer progress reflects actual candidate creation; quality trials retain their prior budget. - candidate and selected-path logs retain anonymous - `ordinary | predicted | unknown` provenance. Ordinary candidates and SFU - fallback remain unchanged. + Candidate and selected-path logs retain anonymous `ordinary | predicted | + unknown` provenance. Ordinary candidates and SFU fallback remain unchanged. - Every direct, Browser-relay, and Host SFU video sender owns one clone of its source track; the original remains presentation and source authority only. Replacement, rollback, unpublish, physical LiveKit sender recreation, and @@ -145,14 +148,11 @@ This is the current execution index. Git history owns completed timelines; capture path remain unrun on a physical Mac. The Linux package now includes a thin Portal/PipeWire/GStreamer hardware-H.264 capture adapter and passes its compile, probe, package, startup, and shutdown checks; real desktop capture, - system audio, and recovery remain unproved. Client-scoped pull requests build - and run the scripted Local smoke on all three candidates before merge; a - validated `main` push emits them as short-lived Actions artifacts. One-link - mode preserves the Host Local authority while exposing its ordinary invitation - and HTTP/WebSocket control path through a session-scoped Quick Tunnel. An - independent Linux Pion Viewer - has received that native media over a direct ICE pair; remote Browser media - remains unproved. Site and one-link native shares also make one bounded + system audio, and recovery remain unproved. One-link mode preserves the Host + Local authority while exposing its ordinary invitation and HTTP/WebSocket + control path through a session-scoped Quick Tunnel. An independent Linux Pion + Viewer has received that native media over a direct ICE pair; remote Browser + media remains unproved. Site and one-link native shares also make one bounded best-effort PCP/UPnP/NAT-PMP mapping and Pion Universal-UDP-mux STUN discovery on their media socket. A public-link run delivered 35 H.264 packets to an independent Linux Viewer; no predicted-path win is yet claimed. @@ -186,13 +186,16 @@ This is the current execution index. Git history owns completed timelines; the deployment tree provides a read-only operator check against the same full- SHA release identity. Neither installs or interrupts a running share. -## Current Hold +## Release Acceptance Client acceptance remains open for the reported Windows 10 display startup and game-specific source behavior. Windows 11 display, minimized-source recovery, live presets and source-switch checks pass, but do not establish those reports as resolved. NAT acquisition has controller and signaling coverage, not a new -public-network success-rate claim. Its added prepare progress field requires a -coordinated private-wire release: old strict pages cannot consume it safely. -Native codec and capability fields likewise require the matching bundled UI. -Production is unchanged; [TODO](./todo.md) owns acceptance and release work. +public-network success-rate claim. The candidate removes the Host-star +configuration and wire shape under `screener-v21` and moves the server to Go. +The authorized cutover needs matching Web/Client builds, the updated unit file +that replaces `NODE_ENV` with `SCREENER_ENV` before the first Go release, +an active-session check, and configuration restoration on rollback. The actual +deployment result belongs to its release record; [TODO](./todo.md) owns +remaining device/network acceptance. diff --git a/docs/todo.md b/docs/todo.md index 6f09563..737138e 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,12 +1,18 @@ # Current TODO Ledger -Last reviewed: 2026-09-05 +Last reviewed: 2026-09-06 Only **Now** is executable. Observations, old branches, experiments, and parked topics are not implementation authority. ## Now +The returned Go core and frontend fixes are reconciled on the integration +candidate. The owner authorized hygiene cleanup, matched Server/Client builds, +one squash PR and a controlled deployment. Complete the existing checks and +first-Go unit/environment rollback before cutover. Device/network checks below +remain evidence gaps, not claims established by compilation or local loopback. + 1. **Client physical acceptance.** Physically validate macOS and Linux capture/audio/recovery, a decoded public-link Browser Viewer, and a NAT case rescued beyond ordinary STUN, plus one mixed Browser/Native Viewer relay. @@ -25,8 +31,9 @@ topics are not implementation authority. 4. **NAT acquisition and release acceptance.** Validate the implemented bounded three-attempt owner on restricted Browser/Native pairs; a new connection does not guarantee a different NAT mapping. The added prepare progress field is - incompatible with old strict pages. Obtain owner approval for the coordinated - Web/Server/Client update and its active-share impact before merging/deploying; + incompatible with old strict pages. The owner authorized the coordinated + Web/Server/Client update; check active sessions before the cutover and + retain the previous unit/environment alongside the application rollback; do not mix the new field into an ongoing old-version share. 5. **Native codec acceptance.** Windows Native VP8 and Auto-selected H264 now pass real Browser playback, live presets, paused changes and source-switch @@ -103,8 +110,8 @@ topics are not implementation authority. recommending either as a default for other deployments. Measure benefit of the implemented three-attempt budget before increasing it or claiming that independent connection attempts are independent NAT mappings. -14. **Unresolved route-state ownership claims.** Reopen lower-revision - reauthentication, active SFU failure during an unrelated prepare, and +14. **Unresolved route-state ownership claims.** Reopen active SFU failure + during an unrelated prepare and multi-child relay-evidence ownership only from an exact current-wire reproduction. Do not add a second revision namespace, parallel failure state, or generalized evidence map from static possibility alone. @@ -119,17 +126,27 @@ topics are not implementation authority. explicit distribution work; do not add automatic install, container self-update, Watchtower, compatibility ranges, or active-share interruption before distribution and rollback evidence requires them. -16. **Go server consolidation.** Revisit replacing the sole TypeScript/Node - server owner with Go only after the Client feature and physical acceptance - boundaries are complete. First measure package, startup, and maintenance - gains. If accepted, migrate Hosted and Local together and delete the Node - server in the same boundary; do not create or retain two room, signaling, - persistence, or route-controller implementations. The - [consolidation draft](./research/server-consolidation.md) identifies the two - preparatory simplifications, the routing-mode product choice, and the shared - module and concurrency model; implementation follows Client acceptance. +16. **Go server consolidation.** Done in the candidate under + [ADR-0012](./adr/0012-shared-go-backend-core.md): Hosted and Client share one + Go core and the Node server, bundled runtime, and supervisor are deleted. Do + not create or retain two room, signaling, persistence, or route-controller + implementations. Package size, startup, and idle memory are measured in the + [consolidation research](./research/server-consolidation.md). Remaining + acceptance: rerun the physical Client and Browser-contract gates on this + build, and sequence the deployment cutover, installing the updated unit, + which replaces `NODE_ENV=production` with `SCREENER_ENV=production`, before + the first Go release, then proving the release wrapper on a host without + Node. 17. **Opt-in diagnostics.** Replace ad hoc console logging with an explicit Client/Server debug option and bounded diagnostic export. Include revision, runtime state and sanitized capture/connection events; exclude credentials, media and raw process memory by default. A compressed feedback bundle needs deliberate collection and retention, not automatic uploads. +18. **Remaining audit behavior.** During the owning refactor, reproduce and + resolve optional Local-password cookies across HTTP LAN/HTTPS public origins; + audio-process failure without stopping healthy video; natural capture EOF + attribution; and Native prepared-bridge failure that must disable Native + for the current Viewer session before retrying. Storage I/O failure needs + an authority-consistent recovery policy, not catch-and-continue guards. + The Local duplicate-launch guard is the in-process listener bind itself; + it refuses a second Client on the same port and hands nothing over. diff --git a/docs/verification-status.md b/docs/verification-status.md index c04bd9f..d549674 100644 --- a/docs/verification-status.md +++ b/docs/verification-status.md @@ -73,9 +73,15 @@ page retention, or capture keepalive. See ## Screener Client The Go Client has unit coverage for persistent Local/Site configuration, LAN -address selection, Browser launch commands, bounded Node supervision, its -loopback port/Origin/Host/session contract, and exact package revision. The same -source cross-builds for Windows amd64, macOS arm64, and Linux amd64. +address selection, Browser launch commands, and its loopback +port/Origin/Host/session contract. The same source cross-builds for Windows +amd64, macOS arm64, and Linux amd64. + +Every packaged-Client result in this section is pre-port evidence from the +package that bundled a Node runtime and an application tree. Capture, media, +discovery, and network results remain applicable; package assembly, startup, and +shutdown claims must be re-verified on the single-binary Client before it is +accepted. On Windows, Chrome 151.0.7922.175 completed the built Local page's fragment access, Host surface, LAN invitation, and clean Client/Node/Browser @@ -83,9 +89,9 @@ shutdown. A separate synthetic run formed a P2P-only Host-plus-three-Viewer tree with one Browser relay and advancing frames at every Viewer. The Hosted Site completed v8 loopback `hello`/`ping` after CDP granted `loopback-network`; without that permission Chrome blocked it as expected. -The current clean-revision Windows package assembled with Node 24.19.0 passed the -package checks recorded for that revision; changing `app/REVISION` made it fail -before opening a listener. Its fresh Site loopback gate closed the Client, +The pre-port clean-revision Windows package assembled with Node 24.19.0 passed +the package checks recorded for that revision; changing `app/REVISION` made it +fail before opening a listener. Its fresh Site loopback gate closed the Client, Browser, port, and temporary profile. The same explicit-target assembly produced a Linux amd64 package that ran its bundled Node application and loopback runtime on an independent Ubuntu host. @@ -158,10 +164,11 @@ retained image, and exited cleanly. The Native Host crash gate also terminated t Client process and observed the Host return to its start-share control within the bounded check. These results cover the current Windows build only. -The same clean revision assembled into a self-contained Windows package with a -matching Node runtime, application tree, and native capture process. Chrome 151 -passed the package's Local startup gate and both Site loopback-permission arms; -revision mismatch remains fail-closed before a listener starts. +That same pre-port clean revision assembled into a self-contained Windows package +with a matching Node runtime, application tree, and native capture process. +Chrome 151 passed the package's Local startup gate and both Site +loopback-permission arms; revision mismatch remains fail-closed before a listener +starts. ## Interpretation Rules diff --git a/native/client/go.mod b/go.mod similarity index 81% rename from native/client/go.mod rename to go.mod index 3c5bcad..afbed38 100644 --- a/native/client/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/TNTcraftHIM/Screener/native/client +module github.com/TNTcraftHIM/Screener go 1.25.0 @@ -19,6 +19,9 @@ require ( github.com/pion/stun/v3 v3.1.6 github.com/pion/webrtc/v4 v4.2.18 github.com/thesyncim/gopus v0.1.1 + golang.org/x/crypto v0.48.0 + golang.org/x/text v0.41.0 + modernc.org/sqlite v1.58.0 ) require ( @@ -27,6 +30,7 @@ require ( github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/gopacket v1.1.19 // indirect github.com/google/uuid v1.6.0 // indirect github.com/huin/goupnp v1.2.0 // indirect @@ -34,8 +38,10 @@ require ( github.com/koron/go-ssdp v0.0.4 // indirect github.com/libp2p/go-netroute v0.2.1 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pion/datachannel v1.6.2 // indirect github.com/pion/dtls/v3 v3.1.5 // indirect github.com/pion/mdns/v2 v2.1.0 // indirect @@ -44,12 +50,15 @@ require ( github.com/pion/srtp/v3 v3.0.12 // indirect github.com/pion/transport/v4 v4.0.2 // indirect github.com/pion/turn/v5 v5.0.12 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.50.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/time v0.14.0 // indirect + modernc.org/libc v1.75.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect ) diff --git a/native/client/go.sum b/go.sum similarity index 74% rename from native/client/go.sum rename to go.sum index f0cffcd..6a7419a 100644 --- a/native/client/go.sum +++ b/go.sum @@ -26,10 +26,16 @@ github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNU github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/huin/goupnp v1.2.0 h1:uOKW26NG1hsSSbXIZ1IR7XP9Gjd1U8pnLaCMgntmkmY= github.com/huin/goupnp v1.2.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= @@ -40,10 +46,14 @@ github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9t github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ= github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 h1:pBxXEsxcsO3qVUND//5j1kelYlO57x5IrRviNF0+0iA= github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8/go.mod h1:mFViabv4PpnoDw9w7W21a7xux6APA4q7KQZRsv4BCl8= github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc= @@ -82,6 +92,8 @@ github.com/pion/webrtc/v4 v4.2.18 h1:smA/3g6Gy4RohM0VIZ5KKY/12TQbxv3XFgpUMyb2EUI github.com/pion/webrtc/v4 v4.2.18/go.mod h1:vmzi6s+rvhoIuT94DPqivB+0xJXs9rG4QRD+4MgBtlY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -100,6 +112,8 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= @@ -113,9 +127,41 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus= +modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0= +modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/native/client/internal/browser/open.go b/internal/client/browser/open.go similarity index 100% rename from native/client/internal/browser/open.go rename to internal/client/browser/open.go diff --git a/native/client/internal/browser/open_test.go b/internal/client/browser/open_test.go similarity index 100% rename from native/client/internal/browser/open_test.go rename to internal/client/browser/open_test.go diff --git a/native/client/internal/clientapp/app.go b/internal/client/clientapp/app.go similarity index 71% rename from native/client/internal/clientapp/app.go rename to internal/client/clientapp/app.go index 9d60d25..4b17505 100644 --- a/native/client/internal/clientapp/app.go +++ b/internal/client/clientapp/app.go @@ -5,22 +5,25 @@ import ( "encoding/json" "errors" "fmt" - "io" + "log/slog" + "net" + "net/http" "net/url" - "os" "path/filepath" - "runtime" "strings" + "time" - "github.com/TNTcraftHIM/Screener/native/client/internal/browser" - "github.com/TNTcraftHIM/Screener/native/client/internal/clientconfig" - "github.com/TNTcraftHIM/Screener/native/client/internal/lan" - "github.com/TNTcraftHIM/Screener/native/client/internal/launcher" - "github.com/TNTcraftHIM/Screener/native/client/internal/loopback" - "github.com/TNTcraftHIM/Screener/native/client/internal/nativecapture" - "github.com/TNTcraftHIM/Screener/native/client/internal/nativecontrol" - "github.com/TNTcraftHIM/Screener/native/client/internal/publictunnel" - "github.com/TNTcraftHIM/Screener/native/client/internal/supervisor" + "github.com/TNTcraftHIM/Screener/internal/client/browser" + "github.com/TNTcraftHIM/Screener/internal/client/clientconfig" + "github.com/TNTcraftHIM/Screener/internal/client/lan" + "github.com/TNTcraftHIM/Screener/internal/client/launcher" + "github.com/TNTcraftHIM/Screener/internal/client/loopback" + "github.com/TNTcraftHIM/Screener/internal/client/nativecapture" + "github.com/TNTcraftHIM/Screener/internal/client/nativecontrol" + "github.com/TNTcraftHIM/Screener/internal/client/publictunnel" + serverapp "github.com/TNTcraftHIM/Screener/internal/server/app" + serverconfig "github.com/TNTcraftHIM/Screener/internal/server/config" + "github.com/TNTcraftHIM/Screener/internal/server/webassets" ) const ( @@ -28,6 +31,10 @@ const ( publicSTUNURL = "stun:stun.cloudflare.com:3478" publicNATPredictionSTUNURLA = "stun:stun.miwifi.com:3478" publicNATPredictionSTUNURLB = "stun:stun.chat.bilibili.com:3478" + // localShutdownTimeout bounds the ordered Local stop. The packaged smoke + // allows 10 s between the stop request and the exit, so it stays well + // inside that budget. + localShutdownTimeout = 5 * time.Second ) var BuildRevision = "development" @@ -37,8 +44,6 @@ type Options struct { SiteSet bool Local bool Link bool - NodePath string - AppDirectory string ConfigPath string LANAddress string Port int @@ -114,7 +119,7 @@ func runConfigured( client *loopback.Server, ) error { if config.Site != "" && !options.Local && !options.Link { - return runSite(ctx, config.Site, options, client) + return runSite(config.Site, options, client) } return runLocal(ctx, options, config, client) } @@ -126,13 +131,9 @@ func runLauncher( config clientconfig.Config, client *loopback.Server, ) error { - _, appDirectory, err := packagePaths(options.NodePath, options.AppDirectory) - if err != nil { - return err - } launch, err := launcher.Start( ctx, - filepath.Join(appDirectory, "dist", "client"), + webassets.FS(), config.Site, BuildRevision, config.LocalAccessPassword, @@ -246,9 +247,10 @@ func validateMode(options Options) error { return nil } -func runSite(ctx context.Context, site string, options Options, - client *loopback.Server, -) error { +// runSite opens the configured Screener Site in the Browser and waits for the +// loopback server, which is the only thing this mode owns. It takes no context: +// cancellation reaches it through client.Done(). +func runSite(site string, options Options, client *loopback.Server) error { var err error launchURL := clientLaunchURL(site) view := consoleView{mode: "site", state: "starting", entry: launchURL} @@ -287,10 +289,6 @@ func runLocal(ctx context.Context, options Options, config clientconfig.Config, if err != nil { return err } - nodePath, appDirectory, err := packagePaths(options.NodePath, options.AppDirectory) - if err != nil { - return err - } var tunnel *publictunnel.Process publicOrigin := "" if options.Link { @@ -306,42 +304,43 @@ func runLocal(ctx context.Context, options Options, config clientconfig.Config, publicOrigin = tunnel.Origin() } - entry := filepath.Join(appDirectory, "dist", "server", "server", "local-index.js") - stunURLs := []string(nil) - natPredictionStunURLs := []string(nil) - if options.Link { - stunURLs = []string{publicSTUNURL} - natPredictionStunURLs = []string{ - publicNATPredictionSTUNURLA, - publicNATPredictionSTUNURLB, - } - } - var stdout, stderr io.Writer = os.Stdout, os.Stderr - if !options.console.machine { - stdout, stderr = io.Discard, options.console.logWriter() - } - localServer, err := supervisor.Start(ctx, supervisor.Command{ - Path: nodePath, - Args: []string{entry}, - Directory: appDirectory, - Environment: localEnvironment( - options.Port, - selectedAddress, - addresses, - config.LocalAccessPassword, - stunURLs, - natPredictionStunURLs, - publicOrigin, - ), - Stdout: stdout, - Stderr: stderr, - HealthURL: fmt.Sprintf("http://127.0.0.1:%d/healthz", options.Port), + stunURLs, natPredictionStunURLs := localSTUNURLs(options.Link) + localConfig, err := serverconfig.Local(serverconfig.LocalOptions{ + Port: options.Port, + PublicAddress: selectedAddress, + PublicOrigin: publicOrigin, + AllowedAddresses: addresses, + SiteAccessPassword: config.LocalAccessPassword, + STUNURLs: stunURLs, + NATPredictionSTUNURLs: natPredictionStunURLs, }) if err != nil { return err } - defer localServer.Close() + localServer, err := serverapp.New(serverapp.Options{ + Config: localConfig, + Assets: webassets.FS(), + // Machine mode inherits stderr; the interactive console shows server + // diagnostics in its log pane, as the Node child's stderr did. + Logger: slog.New(slog.NewTextHandler(options.console.logWriter(), nil)), + }) + if err != nil { + return err + } + if _, err = localServer.Listen(ctx); err != nil { + // Binding the Local port is the duplicate-launch guard, and now an + // atomic one: a second Client on the same port loses the bind instead + // of racing the first for the room authority. + var bindErr *net.OpError + if errors.As(err, &bindErr) && bindErr.Op == "listen" { + return fmt.Errorf("local server port is unavailable: %w", err) + } + return err + } + defer func() { _ = endLocalServer(localServer) }() + // The readiness lines follow the listener, which the packaged smoke and the + // public-link gate both read from stdout before they probe the port. if options.console.machine { if config.LocalAccessPassword == "" { fmt.Println("Local access: open") @@ -377,12 +376,7 @@ func runLocal(ctx context.Context, options Options, config clientconfig.Config, select { case <-ctx.Done(): - return localServer.Close() - case <-localServer.Done(): - if err = localServer.Err(); err != nil { - return errors.New("Screener Client local server stopped unexpectedly") - } - return nil + return endLocalServer(localServer) case err = <-client.Done(): if err != nil { return errors.New("Screener Client runtime stopped unexpectedly") @@ -471,40 +465,34 @@ func discoverNativeMedia(ctx context.Context, configuredPath string) nativeRunti } } -func packagePaths(nodePath, appDirectory string) (string, string, error) { - root := "" - if strings.TrimSpace(nodePath) == "" || strings.TrimSpace(appDirectory) == "" { - executable, err := os.Executable() - if err != nil { - return "", "", errors.New("Screener Client package location is unavailable") - } - root = filepath.Dir(executable) +// localSTUNURLs is the Local room authority's ICE configuration. Only --link +// has a public path, so only --link configures public STUN and the bounded NAT +// prediction survey. The lists are literals on purpose: a gate that sets +// STUN_URLS is configuring the Client's own Pion edge, and that value must not +// reach the room server (which is why serverconfig.Local reads no environment). +func localSTUNURLs(link bool) (stunURLs []string, natPredictionSTUNURLs []string) { + if !link { + return nil, nil } - if strings.TrimSpace(nodePath) == "" { - nodeName := "node" - if runtime.GOOS == "windows" { - nodeName += ".exe" - } - nodePath = filepath.Join(root, "runtime", "node", nodeName) + return []string{publicSTUNURL}, + []string{publicNATPredictionSTUNURLA, publicNATPredictionSTUNURLB} +} + +// endLocalServer is step 1 of the ADR-0010 item 12 unwind: every room ends, +// signaling closes, the listener drains and the room store closes, all before +// the tunnel and the loopback server go away. serverapp.End is memoised, so the +// deferred call after an explicit stop is a no-op. A serve loop or a drain that +// was already stopping reports http.ErrServerClosed or context.Canceled; +// neither is a Client failure, and Run turns any error into exit status 1, +// which is exactly what the packaged smoke fails on. +func endLocalServer(server *serverapp.Server) error { + ctx, cancel := context.WithTimeout(context.Background(), localShutdownTimeout) + defer cancel() + err := server.End(ctx) + if errors.Is(err, http.ErrServerClosed) || errors.Is(err, context.Canceled) { + return nil } - if strings.TrimSpace(appDirectory) == "" { - appDirectory = filepath.Join(root, "app") - } - nodePath = filepath.Clean(nodePath) - appDirectory = filepath.Clean(appDirectory) - if _, err := os.Stat(nodePath); err != nil { - return "", "", errors.New("bundled Node runtime is unavailable") - } - if _, err := os.Stat(filepath.Join(appDirectory, "dist", "server", "server", "local-index.js")); err != nil { - return "", "", errors.New("bundled Screener application is unavailable") - } - if BuildRevision != "development" { - revision, err := os.ReadFile(filepath.Join(appDirectory, "REVISION")) - if err != nil || strings.TrimSpace(string(revision)) != BuildRevision { - return "", "", errors.New("Client and application revisions do not match") - } - } - return nodePath, appDirectory, nil + return err } func tunnelExecutable(configured string) string { @@ -514,38 +502,6 @@ func tunnelExecutable(configured string) string { return publictunnel.PackagedExecutable() } -func localEnvironment( - port int, - publicAddress string, - addresses []string, - password string, - stunURLs []string, - natPredictionStunURLs []string, - publicOrigin string, -) []string { - overrides := map[string]string{ - "SCREENER_CLIENT_PORT": fmt.Sprint(port), - "SCREENER_CLIENT_LAN_ADDRESS": publicAddress, - "SCREENER_CLIENT_ALLOWED_LAN_ADDRESSES": strings.Join(addresses, ","), - "SCREENER_CLIENT_LOCAL_PASSWORD": password, - "SCREENER_CLIENT_PUBLIC_ORIGIN": publicOrigin, - "STUN_URLS": strings.Join(stunURLs, ","), - "SCREENER_CLIENT_NAT_PREDICTION_STUN_URLS": strings.Join(natPredictionStunURLs, ","), - } - result := make([]string, 0, len(os.Environ())+len(overrides)) - for _, entry := range os.Environ() { - name, _, found := strings.Cut(entry, "=") - if _, replaced := overrides[name]; found && replaced { - continue - } - result = append(result, entry) - } - for name, value := range overrides { - result = append(result, name+"="+value) - } - return result -} - func printEndpoint(endpoint loopback.Endpoint) error { payload, err := json.Marshal(endpoint) if err != nil { diff --git a/internal/client/clientapp/app_test.go b/internal/client/clientapp/app_test.go new file mode 100644 index 0000000..56ebb18 --- /dev/null +++ b/internal/client/clientapp/app_test.go @@ -0,0 +1,137 @@ +package clientapp + +import ( + "net/url" + "slices" + "testing" + + "github.com/TNTcraftHIM/Screener/internal/client/clientconfig" + serverconfig "github.com/TNTcraftHIM/Screener/internal/server/config" +) + +func TestClientLaunchURLMarksThePageWithoutChangingOrigin(t *testing.T) { + value := clientLaunchURL("https://share.example/") + parsed, err := url.Parse(value) + if err != nil || parsed.Scheme != "https" || parsed.Host != "share.example" || + parsed.RawQuery != "" { + t.Fatalf("launch URL = %q, %v", value, err) + } + fragment, err := url.ParseQuery(parsed.Fragment) + if err != nil || fragment.Get("screener-client") != "1" { + t.Fatalf("Client launch fragment = %q, %v", parsed.Fragment, err) + } +} + +func TestMissingCaptureKeepsViewerControlAvailable(t *testing.T) { + native := discoverNativeMedia(t.Context(), "missing-capture-process") + if native.capabilities.Video || native.capabilities.HardwareH264 { + t.Fatalf("missing native capture = %+v", native) + } + control := native.controlFactory()() + if control == nil { + t.Fatal("missing capture disabled the native Viewer control") + } + _ = control.Close() +} + +func TestLaunchURLPreservesLocalAccessInsideThePrivateFragment(t *testing.T) { + value := clientLaunchURL("http://localhost:8787/#client-access=secret") + parsed, err := url.Parse(value) + if err != nil || parsed.RawQuery != "" { + t.Fatalf("local native launch URL = %q, %v", value, err) + } + fragment, err := url.ParseQuery(parsed.Fragment) + if err != nil || fragment.Get("client-access") != "secret" || + fragment.Get("screener-client") != "1" { + t.Fatalf("local native launch fragment = %q, %v", parsed.Fragment, err) + } +} + +func TestLaunchURLEncodesAndClearsOptionalLocalAccess(t *testing.T) { + value := clientLaunchURLWithLocalAccess( + "http://localhost:8787/#retained=yes&client-access=old", + "a+b&c?d=e", + ) + parsed, err := url.Parse(value) + if err != nil { + t.Fatal(err) + } + fragment, err := url.ParseQuery(parsed.Fragment) + if err != nil || fragment.Get("client-access") != "a+b&c?d=e" || + fragment.Get("screener-client") != "1" || fragment.Get("retained") != "yes" { + t.Fatalf("encoded local launch fragment = %q, %v", parsed.Fragment, err) + } + + open := clientLaunchURLWithLocalAccess("http://localhost:8787/#client-access=old", "") + parsed, err = url.Parse(open) + if err != nil { + t.Fatal(err) + } + fragment, err = url.ParseQuery(parsed.Fragment) + if err != nil || fragment.Get("client-access") != "" || fragment.Get("screener-client") != "1" { + t.Fatalf("open local launch fragment = %q, %v", parsed.Fragment, err) + } +} + +func TestLinkModeKeepsOneLocalAuthority(t *testing.T) { + config := clientconfig.Config{ + Version: 1, + LocalAccessPassword: "abcdefghijklmnopqrstuvwxyzABCDEF", + Site: "https://example.test", + } + selected, err := applyMode(config, Options{Link: true}) + if err != nil || selected.Site != config.Site { + t.Fatalf("link mode = %+v, %v", selected, err) + } + if err = validateMode(Options{Link: true, SiteSet: true}); err == nil { + t.Fatal("link mode accepted a separate Site") + } + if err = validateMode(Options{Link: true, Local: true}); err == nil { + t.Fatal("link mode accepted a second Local selector") + } +} + +// The Local room authority owns its ICE configuration: a STUN_URLS meant for +// the Client's own Pion edge must not reach it, and only --link is public. +func TestLocalServerOwnsItsSTUNConfiguration(t *testing.T) { + t.Setenv("STUN_URLS", "stun:inherited.example:3478") + for _, testCase := range []struct { + name string + link bool + stunURLs []string + natPredictionSTUNURLs []string + }{ + {name: "local"}, + { + name: "link", link: true, + stunURLs: []string{publicSTUNURL}, + natPredictionSTUNURLs: []string{ + publicNATPredictionSTUNURLA, publicNATPredictionSTUNURLB, + }, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + stunURLs, natPredictionSTUNURLs := localSTUNURLs(testCase.link) + if !slices.Equal(stunURLs, testCase.stunURLs) || + !slices.Equal(natPredictionSTUNURLs, testCase.natPredictionSTUNURLs) { + t.Fatalf("Local STUN selection = %v, %v", stunURLs, natPredictionSTUNURLs) + } + config, err := serverconfig.Local(serverconfig.LocalOptions{ + Port: 8787, + PublicAddress: "192.168.1.2", + AllowedAddresses: []string{"192.168.1.2"}, + SiteAccessPassword: "abcdefghijklmnopqrstuvwxyzABCDEF", + STUNURLs: stunURLs, + NATPredictionSTUNURLs: natPredictionSTUNURLs, + }) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(config.STUNURLs, testCase.stunURLs) || + config.NATPredictionEnabled != testCase.link { + t.Fatalf("Local server config = %v, NAT prediction %t", + config.STUNURLs, config.NATPredictionEnabled) + } + }) + } +} diff --git a/native/client/internal/clientapp/console.go b/internal/client/clientapp/console.go similarity index 99% rename from native/client/internal/clientapp/console.go rename to internal/client/clientapp/console.go index f7a2fb8..d6915cd 100644 --- a/native/client/internal/clientapp/console.go +++ b/internal/client/clientapp/console.go @@ -13,7 +13,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/TNTcraftHIM/Screener/native/client/internal/browser" + "github.com/TNTcraftHIM/Screener/internal/client/browser" "github.com/charmbracelet/colorprofile" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/term" diff --git a/native/client/internal/clientapp/console_test.go b/internal/client/clientapp/console_test.go similarity index 100% rename from native/client/internal/clientapp/console_test.go rename to internal/client/clientapp/console_test.go diff --git a/native/client/internal/clientconfig/config.go b/internal/client/clientconfig/config.go similarity index 100% rename from native/client/internal/clientconfig/config.go rename to internal/client/clientconfig/config.go diff --git a/native/client/internal/clientconfig/config_test.go b/internal/client/clientconfig/config_test.go similarity index 100% rename from native/client/internal/clientconfig/config_test.go rename to internal/client/clientconfig/config_test.go diff --git a/native/client/internal/lan/addresses.go b/internal/client/lan/addresses.go similarity index 100% rename from native/client/internal/lan/addresses.go rename to internal/client/lan/addresses.go diff --git a/native/client/internal/lan/addresses_test.go b/internal/client/lan/addresses_test.go similarity index 100% rename from native/client/internal/lan/addresses_test.go rename to internal/client/lan/addresses_test.go diff --git a/native/client/internal/launcher/server.go b/internal/client/launcher/server.go similarity index 90% rename from native/client/internal/launcher/server.go rename to internal/client/launcher/server.go index 695ed2e..7a5a82d 100644 --- a/native/client/internal/launcher/server.go +++ b/internal/client/launcher/server.go @@ -5,21 +5,23 @@ import ( "encoding/json" "errors" "io" + "io/fs" "net" "net/http" "net/url" - "os" - "path/filepath" "strings" "sync" "time" - "github.com/TNTcraftHIM/Screener/native/client/internal/clientconfig" + "github.com/TNTcraftHIM/Screener/internal/client/clientconfig" ) const ( maxRequestBytes = 2 << 10 shutdownTimeout = 2 * time.Second + // launcherIndex is the Browser entry document. /client serves it directly + // because that route belongs to the client-side router, not to the assets. + launcherIndex = "index.html" ) type Mode string @@ -42,8 +44,8 @@ type Server struct { cancel context.CancelFunc listener net.Listener httpServer *http.Server + assets fs.FS static http.Handler - indexPath string site string localAccessPassword string revision string @@ -63,7 +65,10 @@ type Server struct { handledOnce sync.Once } -func Start(parent context.Context, staticDirectory, site, revision, localAccessPassword string) (*Server, error) { +// Start serves the launcher page and its API on a loopback port. assets is the +// built Browser UI, which the Client embeds; a nil file system means the binary +// carries no build and the launcher cannot run. +func Start(parent context.Context, assets fs.FS, site, revision, localAccessPassword string) (*Server, error) { if parent == nil { parent = context.Background() } @@ -76,8 +81,10 @@ func Start(parent context.Context, staticDirectory, site, revision, localAccessP if err != nil { return nil, errors.New("Client launcher local access password is invalid") } - indexPath := filepath.Join(staticDirectory, "index.html") - if metadata, err := os.Stat(indexPath); err != nil || !metadata.Mode().IsRegular() { + if assets == nil { + return nil, errors.New("Client launcher assets are unavailable") + } + if metadata, err := fs.Stat(assets, launcherIndex); err != nil || !metadata.Mode().IsRegular() { return nil, errors.New("Client launcher assets are unavailable") } listener, err := net.Listen("tcp4", "127.0.0.1:0") @@ -89,8 +96,8 @@ func Start(parent context.Context, staticDirectory, site, revision, localAccessP ctx: ctx, cancel: cancel, listener: listener, - static: http.FileServer(http.Dir(staticDirectory)), - indexPath: indexPath, + assets: assets, + static: http.FileServer(http.FS(assets)), site: site, localAccessPassword: normalizedPassword, revision: strings.TrimSpace(revision), @@ -179,7 +186,7 @@ func (server *Server) ServeHTTP(response http.ResponseWriter, request *http.Requ return } response.Header().Set("Cache-Control", "no-store") - http.ServeFile(response, request, server.indexPath) + http.ServeFileFS(response, request, server.assets, launcherIndex) default: server.static.ServeHTTP(response, request) } diff --git a/native/client/internal/launcher/server_test.go b/internal/client/launcher/server_test.go similarity index 90% rename from native/client/internal/launcher/server_test.go rename to internal/client/launcher/server_test.go index b586444..a2b52e5 100644 --- a/native/client/internal/launcher/server_test.go +++ b/internal/client/launcher/server_test.go @@ -3,14 +3,21 @@ package launcher import ( "bytes" "encoding/json" + "io/fs" "net/http" - "os" - "path/filepath" "strings" "testing" + "testing/fstest" "time" ) +// clientAssets stands in for the embedded Vite build the Client ships. +func clientAssets() fs.FS { + return fstest.MapFS{ + "index.html": {Data: []byte("client")}, + } +} + func TestLauncherServesStateAndCompletesOneSelection(t *testing.T) { server := startFixture(t, "https://share.example") origin := strings.TrimSuffix(server.URL(), "/client") @@ -140,19 +147,21 @@ func TestLauncherCarriesAndNormalizesAnOptionalLocalPassword(t *testing.T) { } func TestLauncherRejectsAnInvalidSavedSite(t *testing.T) { - directory := t.TempDir() - if err := os.WriteFile( - filepath.Join(directory, "index.html"), - []byte("client"), - 0o600, - ); err != nil { - t.Fatal(err) - } - if _, err := Start(t.Context(), directory, "https://example.test/path", "", ""); err == nil { + if _, err := Start(t.Context(), clientAssets(), "https://example.test/path", "", ""); err == nil { t.Fatal("launcher accepted a Site path") } } +// A binary built without the Browser build has nothing to launch. +func TestLauncherRequiresTheEmbeddedBrowserBuild(t *testing.T) { + if _, err := Start(t.Context(), nil, "", "", ""); err == nil { + t.Fatal("launcher started without embedded assets") + } + if _, err := Start(t.Context(), fstest.MapFS{}, "", "", ""); err == nil { + t.Fatal("launcher started without an index document") + } +} + func TestLauncherRejectsInvalidSelections(t *testing.T) { for name, payload := range map[string]string{ "unknown": `{"mode":"other","language":"en"}`, @@ -245,15 +254,7 @@ func startFixtureWithRevisionAndPassword( site, revision, password string, ) *Server { t.Helper() - directory := t.TempDir() - if err := os.WriteFile( - filepath.Join(directory, "index.html"), - []byte("client"), - 0o600, - ); err != nil { - t.Fatal(err) - } - server, err := Start(t.Context(), directory, site, revision, password) + server, err := Start(t.Context(), clientAssets(), site, revision, password) if err != nil { t.Fatal(err) } diff --git a/native/client/internal/loopback/protocol.go b/internal/client/loopback/protocol.go similarity index 100% rename from native/client/internal/loopback/protocol.go rename to internal/client/loopback/protocol.go diff --git a/native/client/internal/loopback/protocol_test.go b/internal/client/loopback/protocol_test.go similarity index 100% rename from native/client/internal/loopback/protocol_test.go rename to internal/client/loopback/protocol_test.go diff --git a/native/client/internal/loopback/server.go b/internal/client/loopback/server.go similarity index 95% rename from native/client/internal/loopback/server.go rename to internal/client/loopback/server.go index df1649f..02ecb91 100644 --- a/native/client/internal/loopback/server.go +++ b/internal/client/loopback/server.go @@ -24,6 +24,7 @@ const ( instanceTokenBytes = 32 helloTimeout = 5 * time.Second shutdownTimeout = 2 * time.Second + controlQueueSize = 64 // MaxControlMessageBytes * 64 = 16 MiB worst-case. ) type Options struct { @@ -262,6 +263,7 @@ func (server *Server) handleControl(response http.ResponseWriter, request *http. } server.setConnection(connection) defer server.releaseConnection(connection) + defer connection.CloseNow() connection.SetReadLimit(MaxControlMessageBytes) helloContext, cancelHello := context.WithTimeout(server.ctx, helloTimeout) @@ -320,11 +322,30 @@ func (server *Server) handleControl(response http.ResponseWriter, request *http. } }() } + reads := make(chan []byte, controlQueueSize) + go func() { + for { + messageType, payload, readErr := connection.Read(controlContext) + if readErr != nil || messageType != websocket.MessageText { + cancelControl() + return + } + select { + case reads <- payload: + case <-controlContext.Done(): + return + default: + cancelControl() + return + } + } + }() for { - messageType, payload, err = connection.Read(controlContext) - if err != nil || messageType != websocket.MessageText { - closeControl(connection, "control frame required") + select { + case <-controlContext.Done(): return + case payload = <-reads: + messageType = websocket.MessageText } message, err = decodeEnvelope(payload) if err != nil { diff --git a/native/client/internal/loopback/server_test.go b/internal/client/loopback/server_test.go similarity index 75% rename from native/client/internal/loopback/server_test.go rename to internal/client/loopback/server_test.go index ea14c01..eba0503 100644 --- a/native/client/internal/loopback/server_test.go +++ b/internal/client/loopback/server_test.go @@ -4,10 +4,12 @@ import ( "context" "encoding/json" "errors" + "fmt" "net" "net/http" "strconv" "strings" + "sync" "testing" "time" @@ -213,6 +215,107 @@ func TestControlSessionSharesOneBoundedSocketForResponsesAndEvents(t *testing.T) } } +func TestControlClosesTheSocketWhenItsReaderRejectsAFrame(t *testing.T) { + server := startTestServer(t, testOrigin) + connection := dialControl(t, server.Endpoint(), server.Endpoint().InstanceToken, testOrigin) + defer connection.CloseNow() + writeControl(t, connection, requestJSON("request_hello", "hello")) + var ready controlMessage + readControl(t, connection, &ready) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := connection.Write(ctx, websocket.MessageBinary, []byte("invalid")); err != nil { + t.Fatal(err) + } + if _, _, err := connection.Read(ctx); err == nil || ctx.Err() != nil { + t.Fatalf("server did not close its rejected control socket: %v", err) + } +} + +func TestControlCloseCancelsAHandlerWaitingForPicker(t *testing.T) { + extension := &blockingControlSession{ + startedSignal: make(chan struct{}), + closedSignal: make(chan struct{}), + } + server := startTestServerWithOptions(t, Options{ + AllowedOrigins: []string{testOrigin}, + NewControl: func() ControlSession { + return extension + }, + }) + connection := dialControl(t, server.Endpoint(), server.Endpoint().InstanceToken, testOrigin) + writeControl(t, connection, requestJSON("request_hello", "hello")) + var ready controlMessage + readControl(t, connection, &ready) + writeControl(t, connection, requestJSON("request_extension", "extension")) + select { + case <-extension.startedSignal: + case <-time.After(time.Second): + t.Fatal("control handler did not start") + } + connection.CloseNow() + select { + case <-extension.closedSignal: + case <-time.After(time.Second): + t.Fatal("closing the control socket did not release the picker handler") + } + + deadline := time.Now().Add(time.Second) + for { + second, response, err := websocket.Dial(context.Background(), + websocketURL(server.Endpoint())+"/control", &websocket.DialOptions{ + HTTPHeader: http.Header{"Origin": []string{testOrigin}}, + Subprotocols: []string{ControlSubprotocol + "." + server.Endpoint().InstanceToken}, + }) + if err == nil { + second.CloseNow() + return + } + if response != nil && response.Body != nil { + response.Body.Close() + } + if time.Now().After(deadline) { + t.Fatalf("control session was not released: %v", err) + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestControlSessionProcessesARequestBurstInOrder(t *testing.T) { + extension := &testControlSession{ + events: make(chan any, 1), + closed: make(chan struct{}), + } + server := startTestServerWithOptions(t, Options{ + AllowedOrigins: []string{testOrigin}, + NewControl: func() ControlSession { + return extension + }, + }) + connection := dialControl(t, server.Endpoint(), server.Endpoint().InstanceToken, testOrigin) + defer connection.CloseNow() + writeControl(t, connection, requestJSON("request_hello", "hello")) + var ready controlMessage + readControl(t, connection, &ready) + const burst = 32 + for index := 0; index < burst; index++ { + writeControl(t, connection, requestJSON( + fmt.Sprintf("request_extension_%d", index), "extension", + )) + } + seen := make(map[string]bool, burst) + for len(seen) < burst { + var response controlMessage + readControl(t, connection, &response) + seen[response.ID] = true + } + for index := 0; index < burst; index++ { + if !seen[fmt.Sprintf("request_extension_%d", index)] { + t.Fatalf("request %d was not processed", index) + } + } +} + func TestStartAlwaysBindsToIPv4Loopback(t *testing.T) { server := startTestServer(t, testOrigin) host, _, err := net.SplitHostPort(server.Endpoint().Host) @@ -341,6 +444,28 @@ type testControlSession struct { closed chan struct{} } +type blockingControlSession struct { + started sync.Once + closed sync.Once + startedSignal chan struct{} + closedSignal chan struct{} +} + +func (session *blockingControlSession) Handle(ctx context.Context, _ []byte) (any, error) { + session.started.Do(func() { close(session.startedSignal) }) + <-ctx.Done() + return nil, ctx.Err() +} + +func (session *blockingControlSession) Events() <-chan any { + return nil +} + +func (session *blockingControlSession) Close() error { + session.closed.Do(func() { close(session.closedSignal) }) + return nil +} + func (session *testControlSession) Handle(_ context.Context, payload []byte) (any, error) { message, err := decodeEnvelope(payload) if err != nil || message.Type != "extension" { diff --git a/native/client/internal/mediaedge/audio.go b/internal/client/mediaedge/audio.go similarity index 98% rename from native/client/internal/mediaedge/audio.go rename to internal/client/mediaedge/audio.go index 9ac7da6..2b8dec3 100644 --- a/native/client/internal/mediaedge/audio.go +++ b/internal/client/mediaedge/audio.go @@ -6,7 +6,7 @@ import ( "sync/atomic" "time" - "github.com/TNTcraftHIM/Screener/native/client/internal/nativeaudio" + "github.com/TNTcraftHIM/Screener/internal/client/nativeaudio" "github.com/pion/rtp" "github.com/pion/webrtc/v4" "github.com/pion/webrtc/v4/pkg/media" diff --git a/native/client/internal/mediaedge/candidate_gathering.go b/internal/client/mediaedge/candidate_gathering.go similarity index 100% rename from native/client/internal/mediaedge/candidate_gathering.go rename to internal/client/mediaedge/candidate_gathering.go diff --git a/native/client/internal/mediaedge/edge.go b/internal/client/mediaedge/edge.go similarity index 100% rename from native/client/internal/mediaedge/edge.go rename to internal/client/mediaedge/edge.go diff --git a/native/client/internal/mediaedge/edge_test.go b/internal/client/mediaedge/edge_test.go similarity index 99% rename from native/client/internal/mediaedge/edge_test.go rename to internal/client/mediaedge/edge_test.go index 3287d40..fb8dba9 100644 --- a/native/client/internal/mediaedge/edge_test.go +++ b/internal/client/mediaedge/edge_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/TNTcraftHIM/Screener/native/client/internal/nativeaudio" + "github.com/TNTcraftHIM/Screener/internal/client/nativeaudio" "github.com/pion/interceptor" "github.com/pion/logging" "github.com/pion/rtcp" diff --git a/native/client/internal/mediaedge/engine.go b/internal/client/mediaedge/engine.go similarity index 99% rename from native/client/internal/mediaedge/engine.go rename to internal/client/mediaedge/engine.go index 240a72c..bb44362 100644 --- a/native/client/internal/mediaedge/engine.go +++ b/internal/client/mediaedge/engine.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "github.com/TNTcraftHIM/Screener/native/client/internal/portmapping" + "github.com/TNTcraftHIM/Screener/internal/client/portmapping" "github.com/pion/ice/v4" "github.com/pion/interceptor" "github.com/pion/logging" diff --git a/native/client/internal/mediaedge/quality.go b/internal/client/mediaedge/quality.go similarity index 100% rename from native/client/internal/mediaedge/quality.go rename to internal/client/mediaedge/quality.go diff --git a/native/client/internal/mediaedge/quality_test.go b/internal/client/mediaedge/quality_test.go similarity index 100% rename from native/client/internal/mediaedge/quality_test.go rename to internal/client/mediaedge/quality_test.go diff --git a/native/client/internal/mediaedge/receiver.go b/internal/client/mediaedge/receiver.go similarity index 99% rename from native/client/internal/mediaedge/receiver.go rename to internal/client/mediaedge/receiver.go index 000237c..9d94534 100644 --- a/native/client/internal/mediaedge/receiver.go +++ b/internal/client/mediaedge/receiver.go @@ -54,6 +54,7 @@ func (engine *Engine) NewReceiver(options ReceiverOptions) (*Receiver, webrtc.Se if err != nil { return nil, webrtc.SessionDescription{}, err } + _ = engine.bandwidth.take(connection.ID()) if err = connection.SetRemoteDescription(options.Offer); err != nil { _ = connection.Close() return nil, webrtc.SessionDescription{}, err diff --git a/native/client/internal/mediaedge/receiver_test.go b/internal/client/mediaedge/receiver_test.go similarity index 95% rename from native/client/internal/mediaedge/receiver_test.go rename to internal/client/mediaedge/receiver_test.go index 9241b84..c5c4c18 100644 --- a/native/client/internal/mediaedge/receiver_test.go +++ b/internal/client/mediaedge/receiver_test.go @@ -39,10 +39,19 @@ func TestReceiverCodecMatchesTheSingleNegotiatedAnswer(t *testing.T) { if err != nil { t.Fatal(err) } + engine.bandwidth.mu.Lock() + pendingBefore := len(engine.bandwidth.pending) + engine.bandwidth.mu.Unlock() receiver, answer, err := engine.NewReceiver(ReceiverOptions{Offer: offer, EdgeCapacity: 1}) if err != nil { t.Fatal(err) } + engine.bandwidth.mu.Lock() + pendingObservers := len(engine.bandwidth.pending) + engine.bandwidth.mu.Unlock() + if pendingObservers != pendingBefore { + t.Fatalf("receiver changed pending bandwidth observers from %d to %d", pendingBefore, pendingObservers) + } t.Cleanup(func() { _ = receiver.Close() }) if receiver.Codec() != want || !strings.Contains(strings.ToLower(answer.SDP), want+"/90000") || (strings.Contains(strings.ToLower(answer.SDP), "vp8/90000") && strings.Contains(strings.ToLower(answer.SDP), "h264/90000")) { diff --git a/native/client/internal/mediaedge/source.go b/internal/client/mediaedge/source.go similarity index 100% rename from native/client/internal/mediaedge/source.go rename to internal/client/mediaedge/source.go diff --git a/native/client/internal/nativeaudio/encoder.go b/internal/client/nativeaudio/encoder.go similarity index 100% rename from native/client/internal/nativeaudio/encoder.go rename to internal/client/nativeaudio/encoder.go diff --git a/native/client/internal/nativeaudio/encoder_test.go b/internal/client/nativeaudio/encoder_test.go similarity index 100% rename from native/client/internal/nativeaudio/encoder_test.go rename to internal/client/nativeaudio/encoder_test.go diff --git a/native/client/internal/nativecapture/json.go b/internal/client/nativecapture/json.go similarity index 100% rename from native/client/internal/nativecapture/json.go rename to internal/client/nativecapture/json.go diff --git a/native/client/internal/nativecapture/probe.go b/internal/client/nativecapture/probe.go similarity index 100% rename from native/client/internal/nativecapture/probe.go rename to internal/client/nativecapture/probe.go diff --git a/native/client/internal/nativecapture/probe_test.go b/internal/client/nativecapture/probe_test.go similarity index 100% rename from native/client/internal/nativecapture/probe_test.go rename to internal/client/nativecapture/probe_test.go diff --git a/native/client/internal/nativecapture/process_other.go b/internal/client/nativecapture/process_other.go similarity index 100% rename from native/client/internal/nativecapture/process_other.go rename to internal/client/nativecapture/process_other.go diff --git a/native/client/internal/nativecapture/process_windows.go b/internal/client/nativecapture/process_windows.go similarity index 100% rename from native/client/internal/nativecapture/process_windows.go rename to internal/client/nativecapture/process_windows.go diff --git a/native/client/internal/nativecapture/protocol.go b/internal/client/nativecapture/protocol.go similarity index 100% rename from native/client/internal/nativecapture/protocol.go rename to internal/client/nativecapture/protocol.go diff --git a/native/client/internal/nativecapture/protocol_test.go b/internal/client/nativecapture/protocol_test.go similarity index 100% rename from native/client/internal/nativecapture/protocol_test.go rename to internal/client/nativecapture/protocol_test.go diff --git a/native/client/internal/nativecapture/stream.go b/internal/client/nativecapture/stream.go similarity index 100% rename from native/client/internal/nativecapture/stream.go rename to internal/client/nativecapture/stream.go diff --git a/native/client/internal/nativecontrol/session.go b/internal/client/nativecontrol/session.go similarity index 97% rename from native/client/internal/nativecontrol/session.go rename to internal/client/nativecontrol/session.go index d6a3831..8841fcd 100644 --- a/native/client/internal/nativecontrol/session.go +++ b/internal/client/nativecontrol/session.go @@ -14,10 +14,10 @@ import ( "sync" "unicode" - "github.com/TNTcraftHIM/Screener/native/client/internal/loopback" - "github.com/TNTcraftHIM/Screener/native/client/internal/nativecapture" - "github.com/TNTcraftHIM/Screener/native/client/internal/nativehost" - "github.com/TNTcraftHIM/Screener/native/client/internal/nativeviewer" + "github.com/TNTcraftHIM/Screener/internal/client/loopback" + "github.com/TNTcraftHIM/Screener/internal/client/nativecapture" + "github.com/TNTcraftHIM/Screener/internal/client/nativehost" + "github.com/TNTcraftHIM/Screener/internal/client/nativeviewer" "github.com/pion/webrtc/v4" ) @@ -78,7 +78,7 @@ func (session *Session) Events() <-chan any { return session.events } -func (session *Session) Handle(_ context.Context, payload []byte) (any, error) { +func (session *Session) Handle(ctx context.Context, payload []byte) (any, error) { var envelope requestEnvelope if err := decodeEnvelope(payload, &envelope); err != nil { return nil, err @@ -137,7 +137,7 @@ func (session *Session) Handle(_ context.Context, payload []byte) (any, error) { !validQualitySettings(request.Profile) { return nil, errors.New("native start-share request is invalid") } - return session.startShare(envelope, request) + return session.startShare(ctx, envelope, request) case "update-share": var request updateShareRequest if err := decodeStrict(payload, &request); err != nil || @@ -171,7 +171,7 @@ func (session *Session) Handle(_ context.Context, payload []byte) (any, error) { audio := request.Audio && session.capabilities.Summary().AudioFor( request.Source.Kind, ) - if err := host.ReplaceSource(nativecapture.VideoOptions{ + if err := host.ReplaceSource(ctx, nativecapture.VideoOptions{ Target: request.Source, AdapterIndex: request.AdapterIndex, EncoderIndex: request.EncoderIndex, @@ -341,6 +341,7 @@ func (session *Session) Close() error { } func (session *Session) startShare( + ctx context.Context, envelope requestEnvelope, request startShareRequest, ) (any, error) { @@ -351,7 +352,7 @@ func (session *Session) startShare( } session.mu.Unlock() profile := nativeQualityProfile(request.Profile) - host, err := nativehost.Start(session.ctx, nativehost.Options{ + host, err := nativehost.Start(ctx, nativehost.Options{ ShareID: request.ShareID, CaptureProcess: session.captureProcess, Video: nativecapture.VideoOptions{ diff --git a/native/client/internal/nativecontrol/session_test.go b/internal/client/nativecontrol/session_test.go similarity index 97% rename from native/client/internal/nativecontrol/session_test.go rename to internal/client/nativecontrol/session_test.go index a31d058..71cce83 100644 --- a/native/client/internal/nativecontrol/session_test.go +++ b/internal/client/nativecontrol/session_test.go @@ -3,8 +3,8 @@ package nativecontrol import ( "testing" - "github.com/TNTcraftHIM/Screener/native/client/internal/loopback" - "github.com/TNTcraftHIM/Screener/native/client/internal/nativecapture" + "github.com/TNTcraftHIM/Screener/internal/client/loopback" + "github.com/TNTcraftHIM/Screener/internal/client/nativecapture" ) func TestSTUNURLsUseTheCurrentBoundedWire(t *testing.T) { diff --git a/native/client/internal/nativecontrol/wire.go b/internal/client/nativecontrol/wire.go similarity index 99% rename from native/client/internal/nativecontrol/wire.go rename to internal/client/nativecontrol/wire.go index 442726e..922e0da 100644 --- a/native/client/internal/nativecontrol/wire.go +++ b/internal/client/nativecontrol/wire.go @@ -1,7 +1,7 @@ package nativecontrol import ( - "github.com/TNTcraftHIM/Screener/native/client/internal/nativecapture" + "github.com/TNTcraftHIM/Screener/internal/client/nativecapture" "github.com/pion/webrtc/v4" ) diff --git a/native/client/internal/nativehost/profile.go b/internal/client/nativehost/profile.go similarity index 79% rename from native/client/internal/nativehost/profile.go rename to internal/client/nativehost/profile.go index 5b85e0e..7c79037 100644 --- a/native/client/internal/nativehost/profile.go +++ b/internal/client/nativehost/profile.go @@ -1,6 +1,6 @@ package nativehost -import "github.com/TNTcraftHIM/Screener/native/client/internal/nativecapture" +import "github.com/TNTcraftHIM/Screener/internal/client/nativecapture" type QualityProfile struct { Video nativecapture.VideoProfile diff --git a/native/client/internal/nativehost/session.go b/internal/client/nativehost/session.go similarity index 97% rename from native/client/internal/nativehost/session.go rename to internal/client/nativehost/session.go index 6b89f7b..621fa9c 100644 --- a/native/client/internal/nativehost/session.go +++ b/internal/client/nativehost/session.go @@ -11,8 +11,8 @@ import ( "time" "unicode/utf8" - "github.com/TNTcraftHIM/Screener/native/client/internal/mediaedge" - "github.com/TNTcraftHIM/Screener/native/client/internal/nativecapture" + "github.com/TNTcraftHIM/Screener/internal/client/mediaedge" + "github.com/TNTcraftHIM/Screener/internal/client/nativecapture" "github.com/pion/webrtc/v4" ) @@ -333,7 +333,7 @@ func (session *Session) UpdateProfile(profile QualityProfile) error { return nil } - replacement, state, err := session.prepareVideo(options) + replacement, state, err := session.prepareVideo(session.ctx, options) if err != nil { return err } @@ -355,6 +355,7 @@ func (session *Session) UpdateProfile(profile QualityProfile) error { } func (session *Session) ReplaceSource( + ctx context.Context, options nativecapture.VideoOptions, audioEnabled bool, ) error { @@ -375,14 +376,14 @@ func (session *Session) ReplaceSource( } options.Profile = profile.Video options.RestoreToken = "" - replacement, state, err := session.prepareVideo(options) + replacement, state, err := session.prepareVideo(ctx, options) if err != nil { return err } var replacementAudio *nativecapture.Stream if hasAudio { replacementAudio, err = startAudioCapture( - session.ctx, + ctx, session.captureProcess, options.Target, ) @@ -413,10 +414,11 @@ func startAudioCapture( } func (session *Session) prepareVideo( + ctx context.Context, options nativecapture.VideoOptions, ) (*nativecapture.Stream, CaptureState, error) { replacement, err := nativecapture.StartVideo( - session.ctx, + ctx, session.captureProcess, options, ) @@ -424,7 +426,7 @@ func (session *Session) prepareVideo( return nil, CaptureState{}, errors.New("native capture could not start") } state, err := waitForCaptureProfile( - session.ctx, + ctx, replacement, options.Profile, options.Codec, @@ -559,6 +561,14 @@ func (session *Session) run() { }() } result := <-videoDone + session.mu.Lock() + closed := session.closed + session.mu.Unlock() + if closed { + // An explicit Close is a clean end, even if closing the capture process + // made the video reader return an error. + result = nil + } session.cancel() session.mu.Lock() audioStream := session.audioStream diff --git a/native/client/internal/nativehost/session_test.go b/internal/client/nativehost/session_test.go similarity index 100% rename from native/client/internal/nativehost/session_test.go rename to internal/client/nativehost/session_test.go diff --git a/native/client/internal/nativeviewer/session.go b/internal/client/nativeviewer/session.go similarity index 99% rename from native/client/internal/nativeviewer/session.go rename to internal/client/nativeviewer/session.go index aaab69e..84ecd43 100644 --- a/native/client/internal/nativeviewer/session.go +++ b/internal/client/nativeviewer/session.go @@ -6,7 +6,7 @@ import ( "sync" "time" - "github.com/TNTcraftHIM/Screener/native/client/internal/mediaedge" + "github.com/TNTcraftHIM/Screener/internal/client/mediaedge" "github.com/pion/webrtc/v4" ) diff --git a/native/client/internal/portmapping/mapping.go b/internal/client/portmapping/mapping.go similarity index 100% rename from native/client/internal/portmapping/mapping.go rename to internal/client/portmapping/mapping.go diff --git a/native/client/internal/portmapping/mapping_test.go b/internal/client/portmapping/mapping_test.go similarity index 100% rename from native/client/internal/portmapping/mapping_test.go rename to internal/client/portmapping/mapping_test.go diff --git a/native/client/internal/publictunnel/process.go b/internal/client/publictunnel/process.go similarity index 100% rename from native/client/internal/publictunnel/process.go rename to internal/client/publictunnel/process.go diff --git a/native/client/internal/publictunnel/process_other.go b/internal/client/publictunnel/process_other.go similarity index 100% rename from native/client/internal/publictunnel/process_other.go rename to internal/client/publictunnel/process_other.go diff --git a/native/client/internal/publictunnel/process_test.go b/internal/client/publictunnel/process_test.go similarity index 100% rename from native/client/internal/publictunnel/process_test.go rename to internal/client/publictunnel/process_test.go diff --git a/native/client/internal/publictunnel/process_windows.go b/internal/client/publictunnel/process_windows.go similarity index 100% rename from native/client/internal/publictunnel/process_windows.go rename to internal/client/publictunnel/process_windows.go diff --git a/internal/server/app/access.go b/internal/server/app/access.go new file mode 100644 index 0000000..2a4b30a --- /dev/null +++ b/internal/server/app/access.go @@ -0,0 +1,179 @@ +package app + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "regexp" + "strconv" + "strings" + "time" + + "github.com/TNTcraftHIM/Screener/internal/server/protocol" +) + +// Ported from src/server/access-session.ts. +const ( + defaultSessionTTLSeconds = 24 * 60 * 60 + cookieVersion = "v1" +) + +var ( + // TS: /^\d+$/ over the expiry segment. + accessExpiresPattern = regexp.MustCompile(`^[0-9]+$`) + // TS: /^[A-Za-z0-9_-]{43}$/ — base64url of a 32-byte HMAC, unpadded. + accessSignaturePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`) +) + +// siteAccessOptions mirrors SiteAccessOptions in access-session.ts. +type siteAccessOptions struct { + // Password empty means site access is not required. + Password string + // Secure selects the __Host- cookie name and appends the Secure attribute. + Secure bool + // Now returns Unix milliseconds; nil uses the wall clock. + Now func() int64 + // TTLSeconds of 0 stands for TS's absent `ttlSeconds`, i.e. 24 hours. + TTLSeconds int +} + +// siteAccessGate issues and verifies the stateless site-access cookie. +type siteAccessGate struct { + password string + secure bool + now func() int64 + ttlSeconds int +} + +// newSiteAccess mirrors the SiteAccess constructor, including its TTL check. +func newSiteAccess(options siteAccessOptions) (*siteAccessGate, error) { + ttlSeconds := options.TTLSeconds + if ttlSeconds == 0 { + ttlSeconds = defaultSessionTTLSeconds + } + // TS: !Number.isSafeInteger(ttlSeconds) || ttlSeconds <= 0. + if ttlSeconds < 0 || int64(ttlSeconds) > protocol.MaxSafeInteger { + return nil, errors.New("Access session TTL must be a positive integer") + } + now := options.Now + if now == nil { + now = func() int64 { return time.Now().UnixMilli() } + } + return &siteAccessGate{ + password: options.Password, + secure: options.Secure, + now: now, + ttlSeconds: ttlSeconds, + }, nil +} + +// required reports whether a password is configured. +func (a *siteAccessGate) required() bool { + return a.password != "" +} + +// passwordMatches mirrors passwordMatches: an unset password accepts anything, +// a set password needs a non-empty exact match. +func (a *siteAccessGate) passwordMatches(value string) bool { + return !a.required() || (value != "" && secretMatches(value, a.password)) +} + +// isAuthenticated mirrors isAuthenticated over a raw Cookie request header. +func (a *siteAccessGate) isAuthenticated(cookieHeader string) bool { + if !a.required() { + return true + } + value := readCookie(cookieHeader, a.cookieName()) + if value == "" { + return false + } + // TS: const [version, expiresText, signature, ...extra] = value.split(".") + // Fewer than three segments fail the two regexes below (an absent segment + // is tested as ""), more than three fill `extra`. + segments := strings.Split(value, ".") + if len(segments) != 3 || + segments[0] != cookieVersion || + !accessExpiresPattern.MatchString(segments[1]) || + !accessSignaturePattern.MatchString(segments[2]) { + return false + } + // TS: Number(expiresText) then Number.isSafeInteger. The segment is all + // digits, so only an out-of-range value can fail here. + expiresAt, err := strconv.ParseInt(segments[1], 10, 64) + if err != nil || expiresAt > protocol.MaxSafeInteger { + return false + } + if expiresAt <= floorSeconds(a.now()) { + return false + } + // Sign the received text, not a re-rendered payload. + return secretMatches(segments[2], a.sign(segments[0]+"."+segments[1])) +} + +// createCookie mirrors createCookie and returns the Set-Cookie header value, +// or "" where TS returned undefined (site access is not required). +func (a *siteAccessGate) createCookie() string { + if !a.required() { + return "" + } + expiresAt := floorSeconds(a.now()) + int64(a.ttlSeconds) + payload := cookieVersion + "." + strconv.FormatInt(expiresAt, 10) + value := payload + "." + a.sign(payload) + // Attribute order is the TS array order; the app writes it verbatim. + attributes := []string{ + a.cookieName() + "=" + value, + "Path=/", + "Max-Age=" + strconv.Itoa(a.ttlSeconds), + "HttpOnly", + "SameSite=Strict", + } + if a.secure { + attributes = append(attributes, "Secure") + } + return strings.Join(attributes, "; ") +} + +func (a *siteAccessGate) cookieName() string { + if a.secure { + return "__Host-screener-site-access" + } + return "screener-site-access" +} + +func (a *siteAccessGate) sign(payload string) string { + mac := hmac.New(sha256.New, []byte(a.password)) + mac.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +// floorSeconds is Math.floor(milliseconds / 1000); Go's integer division +// truncates toward zero, so negative clocks need the adjustment. +func floorSeconds(milliseconds int64) int64 { + seconds := milliseconds / 1_000 + if milliseconds < 0 && milliseconds%1_000 != 0 { + seconds-- + } + return seconds +} + +// readCookie mirrors the TS reader: the first part whose trimmed name matches +// wins, and an empty value counts as absent. +func readCookie(header string, name string) string { + for _, part := range strings.Split(header, ";") { + separator := strings.IndexByte(part, '=') + if separator != -1 && strings.TrimSpace(part[:separator]) == name { + return strings.TrimSpace(part[separator+1:]) + } + } + return "" +} + +// secretMatches compares through SHA-256 digests so the comparison is constant +// time regardless of length, exactly as the TS helper does. +func secretMatches(actual string, expected string) bool { + actualDigest := sha256.Sum256([]byte(actual)) + expectedDigest := sha256.Sum256([]byte(expected)) + return subtle.ConstantTimeCompare(actualDigest[:], expectedDigest[:]) == 1 +} diff --git a/internal/server/app/access_test.go b/internal/server/app/access_test.go new file mode 100644 index 0000000..5f6cc88 --- /dev/null +++ b/internal/server/app/access_test.go @@ -0,0 +1,284 @@ +package app + +import ( + "strings" + "testing" +) + +const testAccessPassword = "instance-access-password" + +// newTestAccess builds a siteAccessGate over a mutable clock, mirroring the +// `now: () => now` injection used by tests/server-http.test.ts. +func newTestAccess(t *testing.T, options siteAccessOptions) *siteAccessGate { + t.Helper() + access, err := newSiteAccess(options) + if err != nil { + t.Fatalf("newSiteAccess: %v", err) + } + return access +} + +// cookiePair is the test helper of the same name: the `name=value` pair of a +// Set-Cookie header, which is what a browser sends back. +func cookiePair(t *testing.T, setCookie string) string { + t.Helper() + if setCookie == "" { + t.Fatal("expected a Set-Cookie value") + } + return strings.SplitN(setCookie, ";", 2)[0] +} + +// Ports "reports status and issues a stateless 24-hour cookie". +func TestSiteAccessIssuesStatelessDayLongCookie(t *testing.T) { + now := int64(1_000) + access := newTestAccess(t, siteAccessOptions{ + Password: testAccessPassword, + Now: func() int64 { return now }, + }) + + if !access.required() { + t.Fatal("a configured password must make site access required") + } + if access.isAuthenticated("") { + t.Fatal("a request without a cookie must not be authenticated") + } + if access.passwordMatches("wrong-password") { + t.Fatal("a wrong password must be rejected") + } + if !access.passwordMatches(testAccessPassword) { + t.Fatal("the configured password must be accepted") + } + + setCookie := access.createCookie() + if want := "screener-site-access=v1."; !strings.Contains(setCookie, want) { + t.Fatalf("Set-Cookie %q does not contain %q", setCookie, want) + } + for _, attribute := range []string{"Path=/", "Max-Age=86400", "HttpOnly", "SameSite=Strict"} { + if !strings.Contains(setCookie, attribute) { + t.Fatalf("Set-Cookie %q is missing %q", setCookie, attribute) + } + } + if strings.Contains(setCookie, "Secure") { + t.Fatalf("Set-Cookie %q must not be Secure without HTTPS", setCookie) + } + if strings.Contains(setCookie, testAccessPassword) { + t.Fatal("Set-Cookie must never carry the access password") + } + + if !access.isAuthenticated(cookiePair(t, setCookie)) { + t.Fatal("the freshly issued cookie must authenticate") + } +} + +// Ports the exact attribute order of createCookie, which app.ts writes verbatim +// into the Set-Cookie header. +func TestSiteAccessCookieAttributeOrder(t *testing.T) { + insecure := newTestAccess(t, siteAccessOptions{ + Password: testAccessPassword, + Now: func() int64 { return 1_000 }, + TTLSeconds: 12, + }) + secure := newTestAccess(t, siteAccessOptions{ + Password: testAccessPassword, + Secure: true, + Now: func() int64 { return 1_000 }, + TTLSeconds: 12, + }) + + signature := strings.Split(cookiePair(t, insecure.createCookie()), ".")[2] + if len(signature) != 43 { + t.Fatalf("signature %q is %d characters, want 43 (base64url, no padding)", signature, len(signature)) + } + + // now = 1000 ms -> floor(1) + 12 = 13 seconds. + wantValue := "v1.13." + signature + want := "screener-site-access=" + wantValue + + "; Path=/; Max-Age=12; HttpOnly; SameSite=Strict" + if got := insecure.createCookie(); got != want { + t.Fatalf("CreateCookie() = %q, want %q", got, want) + } + wantSecure := "__Host-screener-site-access=" + wantValue + + "; Path=/; Max-Age=12; HttpOnly; SameSite=Strict; Secure" + if got := secure.createCookie(); got != wantSecure { + t.Fatalf("secure CreateCookie() = %q, want %q", got, wantSecure) + } +} + +// Ports "uses a secure __Host- cookie for production HTTPS". +func TestSiteAccessUsesHostPrefixedCookieWhenSecure(t *testing.T) { + access := newTestAccess(t, siteAccessOptions{ + Password: testAccessPassword, + Secure: true, + Now: func() int64 { return 1_000 }, + }) + setCookie := access.createCookie() + + for _, attribute := range []string{ + "__Host-screener-site-access=", "Secure", "HttpOnly", "SameSite=Strict", "Path=/", + } { + if !strings.Contains(setCookie, attribute) { + t.Fatalf("Set-Cookie %q is missing %q", setCookie, attribute) + } + } + if strings.Contains(setCookie, "Domain=") { + t.Fatalf("Set-Cookie %q must not carry a Domain for a __Host- cookie", setCookie) + } + if !access.isAuthenticated(cookiePair(t, setCookie)) { + t.Fatal("the secure cookie must authenticate under its own name") + } + // The cookie name is part of the signed identity only through the lookup; + // an insecure instance never finds a __Host- cookie. + insecure := newTestAccess(t, siteAccessOptions{ + Password: testAccessPassword, + Now: func() int64 { return 1_000 }, + }) + if insecure.isAuthenticated(cookiePair(t, setCookie)) { + t.Fatal("the insecure instance must not read the __Host- cookie") + } +} + +// Ports "renews an authenticated cookie across successive idle deadlines". +func TestSiteAccessRenewsAcrossSuccessiveIdleDeadlines(t *testing.T) { + now := int64(1_000) + access := newTestAccess(t, siteAccessOptions{ + Password: testAccessPassword, + Now: func() int64 { return now }, + TTLSeconds: 12, + }) + + originalCookie := cookiePair(t, access.createCookie()) + + now = 7_000 + if !access.isAuthenticated(originalCookie) { + t.Fatal("the original cookie must still be valid at 7s") + } + firstRenewedCookie := cookiePair(t, access.createCookie()) + if firstRenewedCookie == originalCookie { + t.Fatal("a renewal at a later second must produce a different cookie") + } + + now = 13_000 + if access.isAuthenticated(originalCookie) { + t.Fatal("the original cookie must expire 12s after it was issued") + } + if !access.isAuthenticated(firstRenewedCookie) { + t.Fatal("the first renewal must still be valid at 13s") + } + secondRenewedCookie := cookiePair(t, access.createCookie()) + if secondRenewedCookie == firstRenewedCookie { + t.Fatal("the second renewal must differ from the first") + } + + now = 20_000 + if access.isAuthenticated(firstRenewedCookie) { + t.Fatal("the first renewal must expire 12s after it was issued") + } + if !access.isAuthenticated(secondRenewedCookie) { + t.Fatal("the second renewal must still be valid at 20s") + } +} + +// Ports "rejects an expired or modified cookie" plus the parsing rules the HTTP +// scenarios only reach indirectly. +func TestSiteAccessRejectsExpiredOrTamperedCookies(t *testing.T) { + now := int64(1_000) + access := newTestAccess(t, siteAccessOptions{ + Password: testAccessPassword, + Now: func() int64 { return now }, + TTLSeconds: 1, + }) + cookie := cookiePair(t, access.createCookie()) + value := strings.SplitN(cookie, "=", 2)[1] + segments := strings.Split(value, ".") + + if access.isAuthenticated(cookie + "x") { + t.Fatal("an appended character must invalidate the signature") + } + + other := newTestAccess(t, siteAccessOptions{ + Password: "another-access-password", + Now: func() int64 { return now }, + TTLSeconds: 1, + }) + if other.isAuthenticated(cookie) { + t.Fatal("a cookie signed with a different password must be rejected") + } + + // Expiry is compared against floor(now / 1000): 2000 ms is second 2 and the + // cookie expires at second 2, so `expiresAt <= now` rejects it. + now = 2_000 + if access.isAuthenticated(cookie) { + t.Fatal("an expired cookie must be rejected") + } + now = 1_000 + + name := strings.SplitN(cookie, "=", 2)[0] + for _, testCase := range []struct { + name string + cookie string + }{ + {"no cookie header", ""}, + {"different cookie name", "other=" + value}, + {"empty value", name + "="}, + {"no equals sign", name}, + {"wrong version", name + "=v2." + segments[1] + "." + segments[2]}, + {"missing version", name + "=" + segments[1] + "." + segments[2]}, + {"extra segment", name + "=" + value + ".extra"}, + {"non-digit expiry", name + "=v1.1e3." + segments[2]}, + {"signed expiry", name + "=v1.+42." + segments[2]}, + {"empty expiry", name + "=v1.." + segments[2]}, + {"short signature", name + "=v1." + segments[1] + "." + segments[2][:42]}, + {"long signature", name + "=v1." + segments[1] + "." + segments[2] + "A"}, + {"padded signature", name + "=v1." + segments[1] + "." + segments[2][:42] + "="}, + {"unsafe expiry", name + "=v1.9007199254740992." + segments[2]}, + } { + if access.isAuthenticated(testCase.cookie) { + t.Errorf("%s: %q must not authenticate", testCase.name, testCase.cookie) + } + } + + // Surrounding cookies and whitespace are tolerated, as the TS reader is. + for _, header := range []string{ + "a=1; " + cookie + "; b=2", + "a=1;" + cookie, + " " + name + " = " + value + " ", + } { + if !access.isAuthenticated(header) { + t.Errorf("Cookie header %q must authenticate", header) + } + } +} + +// Ports "is immediately authenticated when the access password is empty". +func TestSiteAccessWithoutPassword(t *testing.T) { + access := newTestAccess(t, siteAccessOptions{Now: func() int64 { return 1_000 }}) + + if access.required() { + t.Fatal("an empty password must not require site access") + } + if !access.isAuthenticated("") { + t.Fatal("every request must be authenticated without a password") + } + if !access.isAuthenticated("screener-site-access=nonsense") { + t.Fatal("a bogus cookie must not matter without a password") + } + if !access.passwordMatches("") || !access.passwordMatches("anything") { + t.Fatal("passwordMatches must be true without a password") + } + if got := access.createCookie(); got != "" { + t.Fatalf("CreateCookie() = %q, want \"\" (TS returned undefined)", got) + } +} + +func TestNewSiteAccessTTLValidation(t *testing.T) { + if _, err := newSiteAccess(siteAccessOptions{Password: testAccessPassword, TTLSeconds: -1}); err == nil { + t.Fatal("a negative TTL must be rejected") + } + access := newTestAccess(t, siteAccessOptions{ + Password: testAccessPassword, + Now: func() int64 { return 0 }, + }) + if !strings.Contains(access.createCookie(), "Max-Age=86400") { + t.Fatal("an unset TTL must default to 24 hours") + } +} diff --git a/internal/server/app/app.go b/internal/server/app/app.go new file mode 100644 index 0000000..88b639b --- /dev/null +++ b/internal/server/app/app.go @@ -0,0 +1,429 @@ +// Package app ports src/server/app.ts: the HTTP composition, the startup and +// shutdown lifecycle, and the request router. access.go ports +// src/server/access-session.ts and static.go the sirv frontend handler. +// +// # Locking +// +// signal.Server owns the one mutex that guards the room store, the media router +// and every session. This package never takes it: everything a request can +// mutate goes through the signaling server. Server.mu below guards only this +// struct's own lifecycle bookkeeping. +// +// The lock order is one-directional: Server.mu may be held while the signaling +// mutex is taken (reconcile calls signal.New under it, and signal.New locks the +// server it is still building), never the reverse. Nothing reachable under the +// signaling mutex may take Server.mu, which is why the one callback this +// package hands to signal, SiteAccessAtUpgrade, only reads the lock-free +// site-access gate. Keep it that way or the two locks can deadlock. +package app + +import ( + "context" + "errors" + "fmt" + "io/fs" + "log/slog" + "net" + "net/http" + "strconv" + "sync" + "sync/atomic" + "time" + + "github.com/TNTcraftHIM/Screener/internal/server/config" + "github.com/TNTcraftHIM/Screener/internal/server/room" + "github.com/TNTcraftHIM/Screener/internal/server/sfu" + "github.com/TNTcraftHIM/Screener/internal/server/signal" +) + +// Node HTTP server settings of app.ts, mapped in map section 3.3 / D11. +const ( + requestTimeout = 10 * time.Second // httpServer.requestTimeout + headersTimeout = 15 * time.Second // httpServer.headersTimeout + keepAliveTimeout = 5 * time.Second // httpServer.keepAliveTimeout +) + +// Options is CreateServerOptions. Every zero value means the TS `undefined`. +type Options struct { + // Config is the loaded server configuration; cmd owns config.Load / Local. + Config config.Config + // Assets is the built Browser UI. A nil FS serves the API only, which is + // the TS frontend mode "none"; webassets.FS() returns nil the same way + // when no build was embedded (D3). + Assets fs.FS + // Now returns Unix milliseconds; nil uses the wall clock. + Now func() int64 + // Logger receives the request-failure record; nil uses slog.Default(). + Logger *slog.Logger + + AuthenticationTimeoutMs int + ViewerDisconnectGraceMs int + HeartbeatIntervalMs int + CleanupIntervalMs int + MaxSignalConnections int + MaxUnauthenticatedSignalConnections int + SiteAccessTTLSeconds int + + // SFUTokenIssuer and SFURoomControl replace the LiveKit implementations + // this package would otherwise construct from Config.LiveKit. + SFUTokenIssuer sfu.TokenIssuer + SFURoomControl sfu.RoomControl + // AfterFunc is the signaling timer factory (D5); nil uses time.AfterFunc. + AfterFunc func(time.Duration, func()) func() bool + // RoomStore replaces the store this package would build from Config. + RoomStore *room.Store +} + +// Server is ScreenerServer. +type Server struct { + config config.Config + store *room.Store + siteAccess *siteAccessGate + frontend http.Handler + logger *slog.Logger + // roomControl is nil without a LiveKit fallback; it is the only startup + // reconciliation step (A12). + roomControl sfu.RoomControl + signalOptions signal.Options + httpServer *http.Server + // listener is nil until Listen binds. It is written before startupDone is + // closed and read only after that channel settles, so it needs no lock. + listener net.Listener + + // acceptingTraffic and signaling are the two closure variables app.ts + // flipped together; both are read by request goroutines without a lock and + // written under mu (hazard 13). + acceptingTraffic atomic.Bool + signaling atomic.Pointer[signal.Server] + + mu sync.Mutex + closing bool + startupRequested bool + // startupDone is closed when listen settles, which is what shutdown awaits + // so the listener stays owned until an in-flight startup finishes (A6). + startupDone chan struct{} + + shutdownOnce sync.Once + shutdownErr error +} + +// New is createScreenerServer minus the listening: it builds the SFU fallback, +// the room store, the site-access issuer, the frontend handler and the HTTP +// server, none of which performs I/O. +func New(options Options) (*Server, error) { + configuration := options.Config + now := options.Now + if now == nil { + now = func() int64 { return time.Now().UnixMilli() } + } + logger := options.Logger + if logger == nil { + logger = slog.Default() + } + + roomControl, fallback, err := newSfuFallback(options) + if err != nil { + return nil, err + } + store, err := newRoomStore(options, now) + if err != nil { + return nil, err + } + + siteAccess, err := newSiteAccess(siteAccessOptions{ + Password: configuration.SiteAccessPassword, + Secure: configuration.Env == config.EnvironmentProduction && + configuration.PublicBaseURL.Scheme == "https", + Now: now, + TTLSeconds: options.SiteAccessTTLSeconds, + }) + if err != nil { + return nil, err + } + + server := &Server{ + config: configuration, + store: store, + siteAccess: siteAccess, + logger: logger, + roomControl: roomControl, + startupDone: make(chan struct{}), + } + if options.Assets != nil { + server.frontend = staticHandler(options.Assets, http.HandlerFunc(notFoundJSON)) + } + server.signalOptions = signal.Options{ + Store: store, + EndpointMediaCopyCapacity: configuration.EndpointMediaCopyCapacity, + SfuFallback: fallback, + Ice: config.IceConfig(configuration), + // The TS signaling server read natPredictionEnabled off the same + // `ice` options object it forwarded; the Go IceConfig is already the + // wire shape, so the capability travels beside it. + NATPredictionEnabled: configuration.NATPredictionEnabled, + AllowedOrigins: configuration.AllowedOrigins, + SiteAccessAtUpgrade: func(request *http.Request) bool { + return siteAccess.isAuthenticated(cookieHeader(request)) + }, + PublicBaseURL: configuration.PublicBaseURL, + Now: now, + AuthenticationTimeoutMs: options.AuthenticationTimeoutMs, + ViewerDisconnectGraceMs: options.ViewerDisconnectGraceMs, + HeartbeatIntervalMs: options.HeartbeatIntervalMs, + CleanupIntervalMs: options.CleanupIntervalMs, + MaxConnections: options.MaxSignalConnections, + MaxUnauthenticatedConnections: options.MaxUnauthenticatedSignalConnections, + AfterFunc: options.AfterFunc, + Logger: logger, + } + server.httpServer = &http.Server{ + Handler: server, + ReadHeaderTimeout: headersTimeout, + ReadTimeout: requestTimeout, + IdleTimeout: keepAliveTimeout, + // No WriteTimeout: Node had none, and it would cut long downloads. + } + return server, nil +} + +// newSfuFallback builds the LiveKit half of createScreenerServer. Without a +// LiveKit fallback in the configuration both results are nil and the signaling +// server runs peer-only. The returned RoomControl is also kept by the caller, +// because it is the one startup reconciliation step (A12). +func newSfuFallback(options Options) (sfu.RoomControl, *signal.SfuFallback, error) { + livekit := options.Config.LiveKit + if livekit == nil { + return nil, nil, nil + } + roomControl := options.SFURoomControl + if roomControl == nil { + control, err := sfu.NewLiveKitRoomControl(sfu.LiveKitRoomControlOptions{ + APIURL: livekit.APIURL, + APIKey: livekit.APIKey, + APISecret: livekit.APISecret, + MaxViewersPerRoom: options.Config.MaxViewersPerRoom, + }) + if err != nil { + return nil, nil, err + } + roomControl = control + } + tokenIssuer := options.SFUTokenIssuer + if tokenIssuer == nil { + issuer, err := sfu.NewLiveKitTokenIssuer(sfu.LiveKitTokenIssuerOptions{ + APIKey: livekit.APIKey, + APISecret: livekit.APISecret, + MaxViewersPerRoom: options.Config.MaxViewersPerRoom, + }) + if err != nil { + return nil, nil, err + } + tokenIssuer = issuer + } + return roomControl, &signal.SfuFallback{ + URL: livekit.URL, + TokenIssuer: tokenIssuer, + Admission: sfu.NewAdmission(sfu.AdmissionOptions{ + IngressCapacity: room.Capacity, + EgressCapacity: room.Capacity * options.Config.MaxViewersPerRoom, + }), + RoomControl: roomControl, + }, nil +} + +// newRoomStore builds the room store createScreenerServer would otherwise take +// from Options.RoomStore. An empty RoomDatabasePath is the memory-only mode. +func newRoomStore(options Options, now func() int64) (*room.Store, error) { + if options.RoomStore != nil { + return options.RoomStore, nil + } + var database *room.Database + if options.Config.RoomDatabasePath != "" { + // The file is opened by store.Initialize, not here: a failed bind must + // leave no database behind. + opened, err := room.NewDatabase(options.Config.RoomDatabasePath) + if err != nil { + return nil, err + } + database = opened + } + return room.New(room.Options{ + LeaseMs: options.Config.RoomLeaseMs, + MaxRooms: room.Capacity, + MaxViewersPerRoom: options.Config.MaxViewersPerRoom, + Database: database, + Now: now, + }) +} + +// Handler is the composed router, exposed so a host process can mount it. +func (s *Server) Handler() http.Handler { return s } + +// Store is ScreenerServer.roomStore. The signaling server's mutex guards it, so +// callers outside that lock may only read it while no request is in flight. +func (s *Server) Store() *room.Store { return s.store } + +// Listen binds the configured address, then reconciles the stable authority and +// LiveKit, and only then starts accepting traffic. It returns the bound port, +// which is the actual one when Config.Port is 0. +func (s *Server) Listen(ctx context.Context) (int, error) { + s.mu.Lock() + if s.startupRequested { + s.mu.Unlock() + return 0, errors.New("Screener server startup was already requested") + } + if s.closing { + s.mu.Unlock() + return 0, errors.New("Screener server is closing") + } + s.startupRequested = true + s.mu.Unlock() + + // Deferred: a rejected startupOperation still settled, so shutdown's wait + // on it ended (app.ts:219). A panic escaping start() must likewise release + // the latch, or every later Close blocks on it for good. + defer close(s.startupDone) + return s.start(ctx) +} + +func (s *Server) start(ctx context.Context) (int, error) { + // A11: bind first. A listener owned by another process must fail before + // the room database is opened and before LiveKit is called at all. + address := net.JoinHostPort(s.config.ListenHost, strconv.Itoa(s.config.Port)) + listener, err := net.Listen("tcp", address) + if err != nil { + return 0, err + } + s.listener = listener + go func() { + // bindHttpServer removed its "error" listener once the server was + // listening (app.ts:315-333), so a later listener failure ended the + // Node process. Serve only returns something other than + // ErrServerClosed on a permanent accept failure, which nothing here + // causes; report it rather than serving nothing in silence. + if err := s.httpServer.Serve(listener); !errors.Is(err, http.ErrServerClosed) { + s.logger.Error("Screener HTTP server stopped unexpectedly", "error", err) + } + }() + port := listener.Addr().(*net.TCPAddr).Port + + if err := s.reconcile(ctx); err != nil { + // A13: give the database and the listener back before reporting. + var cleanup []error + if closeErr := s.store.Close(); closeErr != nil { + cleanup = append(cleanup, closeErr) + } + if closeErr := s.stopServing(ctx); closeErr != nil { + cleanup = append(cleanup, closeErr) + } + if len(cleanup) > 0 { + return 0, fmt.Errorf("Screener startup reconciliation failed: %w", + errors.Join(append([]error{err}, cleanup...)...)) + } + return 0, err + } + return port, nil +} + +// stopServing is closeHttpServer. Shutdown stops accepting and drains, but it +// can lose the race against the Serve goroutine registering the listener, in +// which case it returns without having closed the bound socket; closing the +// listener afterwards makes the port release synchronous. A second close of an +// already closed listener is a no-op error nobody reported in TS either, so +// only Shutdown's error is returned. +func (s *Server) stopServing(ctx context.Context) error { + err := s.httpServer.Shutdown(ctx) + if s.listener != nil { + _ = s.listener.Close() + } + return err +} + +// reconcile is the body of the TS startup try block: the store recovers its +// stable authority synchronously, LiveKit reconciliation is the one await +// (A12), and traffic starts only if no shutdown began while it ran. +func (s *Server) reconcile(ctx context.Context) error { + if err := s.store.Initialize(); err != nil { + return err + } + if s.roomControl != nil { + if err := s.roomControl.Initialize(ctx); err != nil { + return err + } + } + // Holding mu across the check and the two stores is what made the TS + // single thread safe here: a shutdown that already set closing must never + // see traffic re-enabled behind it. + s.mu.Lock() + defer s.mu.Unlock() + if s.closing { + return nil + } + signaling, err := signal.New(s.signalOptions) + if err != nil { + return err + } + s.signaling.Store(signaling) + s.acceptingTraffic.Store(true) + return nil +} + +// Close stops accepting traffic, closes the signaling server, the listener and +// the room store, and leaves the rooms in place. Hosted restarts use it. +func (s *Server) Close(ctx context.Context) error { + return s.shutdown(ctx, false) +} + +// End is Close preceded by ending every room. The Client uses it, because its +// authority dies with the process. +func (s *Server) End(ctx context.Context) error { + return s.shutdown(ctx, true) +} + +// shutdown is memoised exactly as the TS shutdownOperation was: the second +// caller joins the first and gets its result, whichever entry point it used. +func (s *Server) shutdown(ctx context.Context, endRooms bool) error { + s.shutdownOnce.Do(func() { s.shutdownErr = s.runShutdown(ctx, endRooms) }) + return s.shutdownErr +} + +func (s *Server) runShutdown(ctx context.Context, endRooms bool) error { + // O35: stop traffic, mark closing, then wait for an in-flight startup. + s.mu.Lock() + s.acceptingTraffic.Store(false) + s.closing = true + awaitStartup := s.startupRequested + s.mu.Unlock() + if awaitStartup { + // A6: the listen caller owns the startup error; shutdown only waits. + <-s.startupDone + } + + var failures []error + signaling := s.signaling.Load() + if endRooms { + failures = append(failures, s.endAllRooms(signaling)) + } + if signaling != nil { + failures = append(failures, signaling.Close(ctx)) + } + // The signaling server must be closed before the listener: its connections + // live on it. + failures = append(failures, s.stopServing(ctx)) + failures = append(failures, s.store.Close()) + if joined := errors.Join(failures...); joined != nil { + return fmt.Errorf("Screener server shutdown failed: %w", joined) + } + return nil +} + +// endAllRooms is the TS `signaling ? signaling.endAllRooms() : +// roomStore.abandonAllRooms()`: without a signaling server nobody is connected, +// so the store can be closed down directly. +func (s *Server) endAllRooms(signaling *signal.Server) error { + if signaling != nil { + return signaling.EndAllRooms() + } + _, err := s.store.AbandonAllRooms() + return err +} diff --git a/internal/server/app/app_test.go b/internal/server/app/app_test.go new file mode 100644 index 0000000..df95d0d --- /dev/null +++ b/internal/server/app/app_test.go @@ -0,0 +1,1577 @@ +package app + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" + "testing/fstest" + "time" + + "github.com/coder/websocket" + _ "modernc.org/sqlite" + + "github.com/TNTcraftHIM/Screener/internal/server/config" + "github.com/TNTcraftHIM/Screener/internal/server/protocol" + "github.com/TNTcraftHIM/Screener/internal/server/room" + "github.com/TNTcraftHIM/Screener/internal/server/sfu" + "github.com/TNTcraftHIM/Screener/internal/server/sfu/sfutest" +) + +// The scenarios below are ported from tests/server-http.test.ts. + +const allowedOrigin = "http://allowed.test" + +// setCookiePattern is the exact attribute order access-session.ts emits and +// app.ts writes verbatim. +var setCookiePattern = regexp.MustCompile( + `^screener-site-access=v1\.[0-9]+\.[A-Za-z0-9_-]{43}; Path=/; Max-Age=86400; HttpOnly; SameSite=Strict$`) + +var secureSetCookiePattern = regexp.MustCompile( + `^__Host-screener-site-access=v1\.[0-9]+\.[A-Za-z0-9_-]{43}; Path=/; Max-Age=86400; HttpOnly; SameSite=Strict; Secure$`) + +type fakeTokenIssuer struct{} + +func (fakeTokenIssuer) IssueToken(sfu.TokenRequest) (string, error) { + return "unused-test-token", nil +} + +func testConfig(t *testing.T) config.Config { + t.Helper() + publicBaseURL, err := url.Parse("https://share.example.test") + if err != nil { + t.Fatalf("url.Parse: %v", err) + } + return config.Config{ + Env: config.EnvironmentDevelopment, + Port: 0, + ListenHost: "127.0.0.1", + PublicBaseURL: publicBaseURL, + AllowedOrigins: map[string]struct{}{allowedOrigin: {}}, + SiteAccessPassword: testAccessPassword, + RoomLeaseMs: 86_400_000, + MaxViewersPerRoom: 8, + EndpointMediaCopyCapacity: 2, + NATPredictionEnabled: false, + } +} + +func livekitFallback() *config.LiveKitFallback { + return &config.LiveKitFallback{ + URL: "ws://livekit.test:7880", + APIURL: "http://127.0.0.1:7880", + APIKey: "test-key", + APISecret: strings.Repeat("s", 32), + } +} + +// newServer builds a server without listening. It mirrors the TS harness: the +// heartbeat and cleanup intervals are pushed out of the way and a fake room +// control is injected whenever a LiveKit fallback is configured. +func newServer(t *testing.T, options Options) *Server { + t.Helper() + if options.Config.PublicBaseURL == nil { + options.Config = testConfig(t) + } + if options.HeartbeatIntervalMs == 0 { + options.HeartbeatIntervalMs = 60_000 + } + if options.CleanupIntervalMs == 0 { + options.CleanupIntervalMs = 60_000 + } + if options.Config.LiveKit != nil { + if options.SFURoomControl == nil { + options.SFURoomControl = sfutest.New() + } + if options.SFUTokenIssuer == nil { + options.SFUTokenIssuer = fakeTokenIssuer{} + } + } + server, err := New(options) + if err != nil { + t.Fatalf("New: %v", err) + } + t.Cleanup(func() { _ = server.Close(context.Background()) }) + return server +} + +type harness struct { + *Server + t *testing.T + baseURL string + client *http.Client +} + +func start(t *testing.T, options Options) *harness { + t.Helper() + server := newServer(t, options) + port, err := server.Listen(context.Background()) + if err != nil { + t.Fatalf("Listen: %v", err) + } + return &harness{Server: server, t: t, baseURL: baseURL(port), client: testClient(t)} +} + +func baseURL(port int) string { return fmt.Sprintf("http://127.0.0.1:%d", port) } + +// testClient never reuses a connection, so a closed server never leaves a +// pooled socket behind for the next test. +func testClient(t *testing.T) *http.Client { + t.Helper() + transport := &http.Transport{DisableKeepAlives: true} + t.Cleanup(transport.CloseIdleConnections) + return &http.Client{Transport: transport, Timeout: 30 * time.Second} +} + +// freePort reserves and releases a port so a test can know the listen address +// before Listen returns. The window is a few microseconds on a test machine. +func freePort(t *testing.T) int { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("net.Listen: %v", err) + } + port := listener.Addr().(*net.TCPAddr).Port + if err := listener.Close(); err != nil { + t.Fatalf("close probe listener: %v", err) + } + return port +} + +type reply struct { + t *testing.T + status int + header http.Header + body string +} + +type requestOption func(*http.Request) + +func withOrigin(origin string) requestOption { + return func(request *http.Request) { request.Header.Set("Origin", origin) } +} + +func withCookie(cookie string) requestOption { + return func(request *http.Request) { + if cookie != "" { + request.Header.Set("Cookie", cookie) + } + } +} + +func withBearer(token string) requestOption { + return func(request *http.Request) { + if token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + } +} + +func withBody(contentType, payload string) requestOption { + return func(request *http.Request) { + if contentType != "" { + request.Header.Set("Content-Type", contentType) + } + request.Body = io.NopCloser(strings.NewReader(payload)) + request.ContentLength = int64(len(payload)) + } +} + +func withJSON(payload string) requestOption { + return withBody("application/json", payload) +} + +func (h *harness) do(method, path string, options ...requestOption) reply { + h.t.Helper() + return send(h.t, h.client, method, h.baseURL+path, options...) +} + +func send( + t *testing.T, + client *http.Client, + method, target string, + options ...requestOption, +) reply { + t.Helper() + request, err := http.NewRequest(method, target, nil) + if err != nil { + t.Fatalf("http.NewRequest: %v", err) + } + for _, option := range options { + option(request) + } + response, err := client.Do(request) + if err != nil { + t.Fatalf("%s %s: %v", method, target, err) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + return reply{t: t, status: response.StatusCode, header: response.Header, body: string(body)} +} + +func (r reply) expect(status int, body string) reply { + r.t.Helper() + if r.status != status { + r.t.Fatalf("status = %d, want %d (body %s)", r.status, status, r.body) + } + if r.body != body { + r.t.Fatalf("body = %s, want %s", r.body, body) + } + return r +} + +func (r reply) expectStatus(status int) reply { + r.t.Helper() + if r.status != status { + r.t.Fatalf("status = %d, want %d (body %s)", r.status, status, r.body) + } + return r +} + +func (r reply) expectHeader(name, want string) reply { + r.t.Helper() + if got := r.header.Get(name); got != want { + r.t.Fatalf("header %s = %q, want %q", name, got, want) + } + return r +} + +func (r reply) room() protocol.CreateRoomResponse { + r.t.Helper() + var created protocol.CreateRoomResponse + if err := json.Unmarshal([]byte(r.body), &created); err != nil { + r.t.Fatalf("decode CreateRoomResponse from %s: %v", r.body, err) + } + return created +} + +// login is the TS login(): a POST with the site password as a bearer token. +func (h *harness) login() reply { + h.t.Helper() + return h.do(http.MethodPost, "/api/site-access", + withBearer(testAccessPassword), withOrigin(allowedOrigin)) +} + +func (h *harness) cookie() string { + h.t.Helper() + response := h.login().expectStatus(http.StatusOK) + return cookiePair(h.t, response.header.Get("Set-Cookie")) +} + +type roomRequest struct { + cookie string + codeEntryPolicy string + password *string + preferredRoomID string +} + +func (h *harness) createRoom(request roomRequest) reply { + h.t.Helper() + policy := request.codeEntryPolicy + if policy == "" { + policy = "open" + } + fields := []string{fmt.Sprintf("%q:%q", "codeEntryPolicy", policy)} + if request.password != nil { + fields = append(fields, fmt.Sprintf("%q:%q", "roomPassword", *request.password)) + } + if request.preferredRoomID != "" { + fields = append(fields, fmt.Sprintf("%q:%q", "preferredRoomId", request.preferredRoomID)) + } + return h.do(http.MethodPost, "/api/rooms", + withOrigin(allowedOrigin), withCookie(request.cookie), + withJSON("{"+strings.Join(fields, ",")+"}")) +} + +type accessRequest struct { + roomID string + hostToken string + body string + cookie string + origin string + method string +} + +func (h *harness) updateRoomAccess(request accessRequest) reply { + h.t.Helper() + method := request.method + if method == "" { + method = http.MethodPost + } + origin := request.origin + if origin == "" { + origin = allowedOrigin + } + options := []requestOption{ + withOrigin(origin), withCookie(request.cookie), withBearer(request.hostToken), + } + if method == http.MethodPost { + options = append(options, withJSON(request.body)) + } + return h.do(method, "/api/rooms/"+request.roomID+"/access", options...) +} + +func (h *harness) replaceRoom(roomID, hostToken, body, cookie string) reply { + h.t.Helper() + return h.do(http.MethodPost, "/api/rooms/"+roomID+"/replacement", + withOrigin(allowedOrigin), withCookie(cookie), withBearer(hostToken), + withJSON(body)) +} + +func waitFor(t *testing.T, what string, condition func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for !condition() { + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for %s", what) + } + time.Sleep(2 * time.Millisecond) + } +} + +func assertPortFree(t *testing.T, port int) { + t.Helper() + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + t.Fatalf("port %d is still owned: %v", port, err) + } + _ = listener.Close() +} + +// expectGoroutinesSettled is the close tests' stand-in for the race detector +// (unavailable without cgo): the listener's Serve loop, the signaling +// goroutines and the timers must all have exited once Close (or a failed +// Listen) returns, so the count returns to what it was before New. +func expectGoroutinesSettled(t *testing.T, before int) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for runtime.NumGoroutine() > before { + if time.Now().After(deadline) { + stacks := make([]byte, 1<<20) + t.Fatalf("goroutines: %d before, %d after close\n%s", + before, runtime.NumGoroutine(), stacks[:runtime.Stack(stacks, true)]) + } + time.Sleep(2 * time.Millisecond) + } +} + +// --- runtime capabilities ------------------------------------------------- + +func TestCapabilitiesKeepNATPredictionAbsentByDefault(t *testing.T) { + server := start(t, Options{Config: testConfig(t)}) + + server.do(http.MethodGet, "/api/capabilities"). + expect(http.StatusOK, `{"natPrediction":false}`) +} + +func TestCapabilitiesReportOptionalNATPredictionWithoutExposingConfiguration(t *testing.T) { + configuration := testConfig(t) + configuration.STUNURLs = []string{"stun:share.example.test:3478"} + configuration.NATPredictionEnabled = true + server := start(t, Options{Config: configuration}) + + server.do(http.MethodGet, "/api/capabilities"). + expect(http.StatusOK, `{"natPrediction":true}`). + expectHeader("Cache-Control", "no-store"). + expectHeader("X-Content-Type-Options", "nosniff") + + server.do(http.MethodPost, "/api/capabilities"). + expect(http.StatusMethodNotAllowed, `{"error":"Method not allowed"}`). + expectHeader("Allow", "GET") +} + +// --- site access ---------------------------------------------------------- + +func TestSiteAccessReportsStatusAndIssuesStatelessDayLongCookie(t *testing.T) { + server := start(t, Options{Config: testConfig(t)}) + + server.do(http.MethodGet, "/api/site-access"). + expect(http.StatusOK, `{"required":true,"authenticated":false}`). + expectHeader("Cache-Control", "no-store"). + expectHeader("Set-Cookie", "") + + server.do(http.MethodPost, "/api/site-access", + withBearer("wrong-password"), withOrigin(allowedOrigin)). + expect(http.StatusUnauthorized, `{"error":"Unauthorized"}`). + expectHeader("Set-Cookie", ""). + expectHeader("WWW-Authenticate", "Bearer") + + authenticated := server.login(). + expect(http.StatusOK, `{"required":true,"authenticated":true}`) + setCookie := authenticated.header.Get("Set-Cookie") + if !setCookiePattern.MatchString(setCookie) { + t.Fatalf("Set-Cookie = %q, want %v", setCookie, setCookiePattern) + } + if strings.Contains(setCookie, testAccessPassword) { + t.Fatal("Set-Cookie leaked the site access password") + } + cookie := cookiePair(t, setCookie) + + server.do(http.MethodPost, "/api/site-access", + withBearer("wrong-password"), withCookie(cookie), withOrigin(allowedOrigin)). + expect(http.StatusUnauthorized, `{"error":"Unauthorized"}`). + expectHeader("Set-Cookie", "") + + status := server.do(http.MethodGet, "/api/site-access", withCookie(cookie)). + expect(http.StatusOK, `{"required":true,"authenticated":true}`) + if !setCookiePattern.MatchString(status.header.Get("Set-Cookie")) { + t.Fatalf("renewal Set-Cookie = %q", status.header.Get("Set-Cookie")) + } +} + +func TestSiteAccessRenewsAnAuthenticatedCookieAcrossIdleDeadlines(t *testing.T) { + var now atomic.Int64 + now.Store(1_000) + server := start(t, Options{ + Config: testConfig(t), + Now: now.Load, + SiteAccessTTLSeconds: 12, + }) + original := cookiePair(t, server.login().header.Get("Set-Cookie")) + + now.Store(7_000) + firstRenewed := cookiePair(t, server.do( + http.MethodGet, "/api/site-access", withCookie(original)). + header.Get("Set-Cookie")) + if firstRenewed == original { + t.Fatal("renewal reissued the same cookie") + } + + now.Store(13_000) + server.do(http.MethodGet, "/api/site-access", withCookie(original)). + expect(http.StatusOK, `{"required":true,"authenticated":false}`). + expectHeader("Set-Cookie", "") + + secondRenewed := cookiePair(t, server.do( + http.MethodGet, "/api/site-access", withCookie(firstRenewed)). + header.Get("Set-Cookie")) + if secondRenewed == firstRenewed { + t.Fatal("second renewal reissued the same cookie") + } + + now.Store(20_000) + server.do(http.MethodGet, "/api/site-access", withCookie(firstRenewed)). + expect(http.StatusOK, `{"required":true,"authenticated":false}`). + expectHeader("Set-Cookie", "") + active := server.do(http.MethodGet, "/api/site-access", withCookie(secondRenewed)). + expect(http.StatusOK, `{"required":true,"authenticated":true}`) + if active.header.Get("Set-Cookie") == "" { + t.Fatal("an active cookie was not renewed") + } +} + +func TestSiteAccessUsesASecureHostCookieForProductionHTTPS(t *testing.T) { + configuration := testConfig(t) + configuration.Env = config.EnvironmentProduction + server := start(t, Options{Config: configuration}) + + setCookie := server.login().expectStatus(http.StatusOK).header.Get("Set-Cookie") + if !secureSetCookiePattern.MatchString(setCookie) { + t.Fatalf("Set-Cookie = %q, want %v", setCookie, secureSetCookiePattern) + } + if strings.Contains(setCookie, "Domain=") { + t.Fatalf("Set-Cookie = %q must not scope a domain", setCookie) + } +} + +func TestSiteAccessRequiresAnAllowedOriginAndAnEmptyLoginBody(t *testing.T) { + server := start(t, Options{Config: testConfig(t)}) + forbidden := `{"error":"Forbidden"}` + + server.do(http.MethodPost, "/api/site-access", withBearer(testAccessPassword)). + expect(http.StatusForbidden, forbidden) + server.do(http.MethodPost, "/api/site-access", + withBearer(testAccessPassword), withOrigin("https://foreign.test")). + expect(http.StatusForbidden, forbidden) + server.do(http.MethodPost, "/api/site-access", + withBearer(testAccessPassword), withOrigin(allowedOrigin+"/path")). + expect(http.StatusForbidden, forbidden) + server.do(http.MethodPost, "/api/site-access", + withBearer(testAccessPassword), withOrigin(allowedOrigin), + withBody("text/plain;charset=UTF-8", "{}")). + expect(http.StatusBadRequest, `{"error":"Request body is not accepted"}`) + + server.do(http.MethodDelete, "/api/site-access", withOrigin(allowedOrigin)). + expect(http.StatusMethodNotAllowed, `{"error":"Method not allowed"}`). + expectHeader("Allow", "GET, POST") +} + +func TestSiteAccessRejectsAnExpiredOrModifiedCookie(t *testing.T) { + var now atomic.Int64 + now.Store(1_000) + server := start(t, Options{ + Config: testConfig(t), + Now: now.Load, + SiteAccessTTLSeconds: 1, + }) + cookie := cookiePair(t, server.login().header.Get("Set-Cookie")) + + server.do(http.MethodGet, "/api/site-access", withCookie(cookie+"x")). + expect(http.StatusOK, `{"required":true,"authenticated":false}`) + + now.Store(2_000) + server.do(http.MethodGet, "/api/site-access", withCookie(cookie)). + expect(http.StatusOK, `{"required":true,"authenticated":false}`) +} + +func TestSiteAccessIsImmediateWhenTheAccessPasswordIsEmpty(t *testing.T) { + configuration := testConfig(t) + configuration.SiteAccessPassword = "" + server := start(t, Options{Config: configuration}) + + server.do(http.MethodGet, "/api/site-access"). + expect(http.StatusOK, `{"required":false,"authenticated":true}`). + expectHeader("Set-Cookie", "") + server.do(http.MethodPost, "/api/site-access", withOrigin(allowedOrigin)). + expect(http.StatusOK, `{"required":false,"authenticated":true}`). + expectHeader("Set-Cookie", "") +} + +// --- room HTTP API -------------------------------------------------------- + +func TestRoomCreationRequiresALoggedInBrowser(t *testing.T) { + server := start(t, Options{Config: testConfig(t)}) + + server.createRoom(roomRequest{}). + expect(http.StatusUnauthorized, `{"error":"Unauthorized"}`). + expectHeader("WWW-Authenticate", "") + + // Hazard 12: the site password as a bearer token is not a browser session. + server.do(http.MethodPost, "/api/rooms", + withOrigin(allowedOrigin), withBearer(testAccessPassword), + withJSON(`{"codeEntryPolicy":"open"}`)). + expect(http.StatusUnauthorized, `{"error":"Unauthorized"}`) +} + +func TestRoomCreationIssuesAnIndependentFragmentOnlyViewerGrant(t *testing.T) { + server := start(t, Options{Config: testConfig(t)}) + + response := server.createRoom(roomRequest{cookie: server.cookie()}). + expectStatus(http.StatusCreated). + expectHeader("Cache-Control", "no-store"). + expectHeader("X-Content-Type-Options", "nosniff") + created := response.room() + + if !regexp.MustCompile(`^[1-9][0-9]{3}$`).MatchString(created.RoomID) { + t.Fatalf("roomId = %q", created.RoomID) + } + invite, err := url.Parse(created.InviteURL) + if err != nil { + t.Fatalf("url.Parse(inviteUrl): %v", err) + } + if config.Origin(invite) != "https://share.example.test" { + t.Fatalf("invite origin = %q", config.Origin(invite)) + } + if invite.Path != "/r/"+created.RoomID { + t.Fatalf("invite path = %q", invite.Path) + } + if !regexp.MustCompile(`^v=[A-Za-z0-9_-]{21}[AQgw]$`).MatchString(invite.Fragment) { + t.Fatalf("invite fragment = %q", invite.Fragment) + } + if invite.RawQuery != "" { + t.Fatalf("invite query = %q", invite.RawQuery) + } + if created.CodeEntryPolicy != "open" { + t.Fatalf("codeEntryPolicy = %q", created.CodeEntryPolicy) + } + if created.ExpiresAt == nil || *created.ExpiresAt == "" { + t.Fatal("expiresAt is missing") + } + if created.RoomLeaseSeconds != 86_400 { + t.Fatalf("roomLeaseSeconds = %d", created.RoomLeaseSeconds) + } + // The response carries no viewer grant expiry and no ICE configuration. + var raw map[string]json.RawMessage + if err := json.Unmarshal([]byte(response.body), &raw); err != nil { + t.Fatalf("decode body: %v", err) + } + for _, absent := range []string{"viewerGrantExpiresAt", "iceConfig", "viewerGrant"} { + if _, present := raw[absent]; present { + t.Fatalf("response exposes %q", absent) + } + } +} + +func TestRoomCreationPassesTheSharedViewerCeilingToRoomAdmission(t *testing.T) { + configuration := testConfig(t) + configuration.SiteAccessPassword = "" + configuration.MaxViewersPerRoom = protocol.MaxViewersPerRoomLimit + server := start(t, Options{Config: configuration}) + + server.createRoom(roomRequest{}).expectStatus(http.StatusCreated) + if got := server.Store().MaxViewersPerRoom(); got != protocol.MaxViewersPerRoomLimit { + t.Fatalf("maxViewersPerRoom = %d, want %d", got, protocol.MaxViewersPerRoomLimit) + } +} + +func TestRoomCreationCreatesPrivateRoomsWithOptionalPasswordsAtomically(t *testing.T) { + server := start(t, Options{Config: testConfig(t)}) + cookie := server.cookie() + password := "room-password" + + withPassword := server.createRoom(roomRequest{ + cookie: cookie, codeEntryPolicy: "private", password: &password, + }).expectStatus(http.StatusCreated).room() + if withPassword.CodeEntryPolicy != "private" { + t.Fatalf("codeEntryPolicy = %q", withPassword.CodeEntryPolicy) + } + + withoutPassword := server.createRoom(roomRequest{ + cookie: cookie, codeEntryPolicy: "private", + }).expectStatus(http.StatusCreated).room() + if withoutPassword.CodeEntryPolicy != "private" { + t.Fatalf("codeEntryPolicy = %q", withoutPassword.CodeEntryPolicy) + } +} + +func TestRoomCreationAllowsExplicitOpenCreationWithoutSiteAccess(t *testing.T) { + configuration := testConfig(t) + configuration.SiteAccessPassword = "" + server := start(t, Options{Config: configuration}) + + created := server.createRoom(roomRequest{codeEntryPolicy: "open"}). + expectStatus(http.StatusCreated).room() + if created.CodeEntryPolicy != "open" { + t.Fatalf("codeEntryPolicy = %q", created.CodeEntryPolicy) + } + if !strings.Contains(created.InviteURL, "#v=") { + t.Fatalf("inviteUrl = %q", created.InviteURL) + } +} + +func TestRoomCreationAllocatesUniqueFourDigitCodesConcurrently(t *testing.T) { + server := start(t, Options{Config: testConfig(t)}) + cookie := server.cookie() + + var wait sync.WaitGroup + created := make([]protocol.CreateRoomResponse, 2) + for index := range created { + wait.Add(1) + go func() { + defer wait.Done() + created[index] = server.createRoom(roomRequest{cookie: cookie}). + expectStatus(http.StatusCreated).room() + }() + } + wait.Wait() + + if created[0].RoomID == created[1].RoomID { + t.Fatalf("both rooms took the code %q", created[0].RoomID) + } + for _, room := range created { + if room.ExpiresAt == nil { + t.Fatal("expiresAt is null") + } + } +} + +func TestRoomCreationReusesAFreePreferredCodeAndNeverReplacesAnOccupiedRoom(t *testing.T) { + configuration := testConfig(t) + configuration.RoomLeaseMs = 90_000 + server := start(t, Options{Config: configuration}) + cookie := server.cookie() + + preferred := server.createRoom(roomRequest{cookie: cookie, preferredRoomID: "4321"}). + expectStatus(http.StatusCreated).room() + fallback := server.createRoom(roomRequest{cookie: cookie, preferredRoomID: "4321"}). + expectStatus(http.StatusCreated).room() + + if preferred.RoomID != "4321" { + t.Fatalf("preferred roomId = %q", preferred.RoomID) + } + if preferred.RoomLeaseSeconds != 90 { + t.Fatalf("roomLeaseSeconds = %d", preferred.RoomLeaseSeconds) + } + if fallback.RoomID == "4321" { + t.Fatal("the occupied preferred code was handed out twice") + } + if fallback.HostToken == preferred.HostToken { + t.Fatal("the fallback room reused the Host token") + } +} + +func TestRoomReplacementReplacesAuthorityAndClosesOldMembership(t *testing.T) { + server := start(t, Options{Config: testConfig(t)}) + cookie := server.cookie() + password := "old-password" + original := server.createRoom(roomRequest{ + cookie: cookie, codeEntryPolicy: "private", + password: &password, preferredRoomID: "4321", + }).expectStatus(http.StatusCreated).room() + + store := server.Store() + host, err := store.ConnectParticipant(room.ConnectParticipantInput{ + RoomID: original.RoomID, Role: protocol.RoleHost, Token: original.HostToken, + ClientID: "host-client", SessionID: "host-session", + }) + if err != nil { + t.Fatalf("connect host: %v", err) + } + invite, err := url.Parse(original.InviteURL) + if err != nil { + t.Fatalf("url.Parse(inviteUrl): %v", err) + } + if _, err := store.ConnectParticipant(room.ConnectParticipantInput{ + RoomID: original.RoomID, Role: protocol.RoleViewer, + ViewerGrant: strings.TrimPrefix(invite.Fragment, "v="), + ClientID: "viewer-client", SessionID: "viewer-session", + }); err != nil { + t.Fatalf("connect viewer: %v", err) + } + + server.replaceRoom(original.RoomID, original.HostToken, + `{"codeEntryPolicy":"open"}`, ""). + expect(http.StatusUnauthorized, `{"error":"Unauthorized"}`) + + replacement := server.replaceRoom(original.RoomID, original.HostToken, + `{"codeEntryPolicy":"private","roomPassword":"new-password"}`, cookie). + expectStatus(http.StatusCreated).room() + + if replacement.RoomID == original.RoomID { + t.Fatal("the replacement kept the old room code") + } + if replacement.CodeEntryPolicy != "private" { + t.Fatalf("codeEntryPolicy = %q", replacement.CodeEntryPolicy) + } + if _, connected := store.GetConnectedHost(original.RoomID); connected { + t.Fatal("the old Host is still connected") + } + if viewers := store.GetConnectedViewers(original.RoomID); len(viewers) != 0 { + t.Fatalf("old viewers = %v", viewers) + } + if _, err := store.ConnectParticipant(room.ConnectParticipantInput{ + RoomID: original.RoomID, Role: protocol.RoleHost, Token: original.HostToken, + ClientID: "old-host", SessionID: "old-session", + }); roomErrorCode(err) != room.CodeInvalidToken { + t.Fatalf("reconnect error = %v, want INVALID_TOKEN", err) + } + if host.PeerID == "" { + t.Fatal("the replaced Host never had a peer id") + } +} + +// TestRoomMutationFailuresMapToTheHTTPTable covers the two TS cases that used +// vi.spyOn to make RoomStore reject: "keeps valid Host authority on a transient +// replacement rejection" (ROOM_ACCESS_DENIED) and "maps a busy password gate to +// 503 for every HTTP mutation" (ROOM_BUSY on all three). Go cannot monkey patch +// a concrete *room.Store, and neither code is reachable from HTTP without a +// racing second mutation, so the pure mapping functions the three routes share +// are driven directly instead; that the store leaves the authority intact when +// it rejects is the room package's own contract. +func TestRoomMutationFailuresMapToTheHTTPTable(t *testing.T) { + otherError := errors.New("storage failed") + cases := []struct { + name string + mapper func(error) (int, string, bool) + err error + status int + message string + mapped bool + }{ + {"replacement limit", replacementFailure, &room.Error{Code: room.CodeRoomLimit}, 503, "Room capacity reached", true}, + {"replacement denied", replacementFailure, &room.Error{Code: room.CodeRoomAccessDenied}, 503, "Room replacement unavailable", true}, + {"replacement busy", replacementFailure, &room.Error{Code: room.CodeRoomBusy}, 503, "Room replacement unavailable", true}, + {"replacement token", replacementFailure, &room.Error{Code: room.CodeInvalidToken}, 404, "Room not found", true}, + {"replacement missing", replacementFailure, &room.Error{Code: room.CodeRoomNotFound}, 404, "Room not found", true}, + {"replacement expired", replacementFailure, &room.Error{Code: room.CodeRoomExpired}, 404, "Room not found", true}, + {"replacement full", replacementFailure, &room.Error{Code: room.CodeRoomFull}, 0, "", false}, + {"replacement other", replacementFailure, otherError, 0, "", false}, + {"access busy", accessFailure, &room.Error{Code: room.CodeRoomBusy}, 503, "Room access update unavailable", true}, + {"access denied", accessFailure, &room.Error{Code: room.CodeRoomAccessDenied}, 409, "Room access update rejected", true}, + {"access token", accessFailure, &room.Error{Code: room.CodeInvalidToken}, 404, "Room not found", true}, + {"access limit", accessFailure, &room.Error{Code: room.CodeRoomLimit}, 404, "Room not found", true}, + {"access other", accessFailure, otherError, 0, "", false}, + {"creation limit", creationFailure, &room.Error{Code: room.CodeRoomLimit}, 503, "Room capacity reached", true}, + {"creation busy", creationFailure, &room.Error{Code: room.CodeRoomBusy}, 503, "Room creation unavailable", true}, + {"creation token", creationFailure, &room.Error{Code: room.CodeInvalidToken}, 400, "Invalid room request", true}, + {"creation denied", creationFailure, &room.Error{Code: room.CodeRoomAccessDenied}, 0, "", false}, + {"creation other", creationFailure, otherError, 0, "", false}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + status, message, mapped := testCase.mapper(testCase.err) + if status != testCase.status || message != testCase.message || mapped != testCase.mapped { + t.Fatalf("got (%d, %q, %t), want (%d, %q, %t)", + status, message, mapped, + testCase.status, testCase.message, testCase.mapped) + } + }) + } + // A wrapped store error still maps: the routes use errors.As. + if status, _, mapped := creationFailure( + fmt.Errorf("create: %w", &room.Error{Code: room.CodeRoomLimit}), + ); !mapped || status != 503 { + t.Fatalf("wrapped ROOM_LIMIT mapped=%t status=%d", mapped, status) + } +} + +func TestRoomAccessManagesADormantRoomWithoutStartingSharingOrRenewing(t *testing.T) { + var now atomic.Int64 + configuration := testConfig(t) + configuration.RoomLeaseMs = 1_000 + server := start(t, Options{Config: configuration, Now: now.Load}) + cookie := server.cookie() + created := server.createRoom(roomRequest{cookie: cookie}). + expectStatus(http.StatusCreated).room() + now.Store(900) + + server.updateRoomAccess(accessRequest{ + roomID: created.RoomID, hostToken: created.HostToken, cookie: cookie, + body: `{"action":"set-code-entry-policy","policy":"private"}`, + }). + expect(http.StatusOK, + `{"type":"code-entry-policy-updated","codeEntryPolicy":"private","viewerPasswordEnabled":false}`). + expectHeader("Cache-Control", "no-store") + + server.updateRoomAccess(accessRequest{ + roomID: created.RoomID, hostToken: created.HostToken, cookie: cookie, + body: `{"action":"set-viewer-password","password":"room-password"}`, + }).expect(http.StatusOK, `{"type":"viewer-password-updated","enabled":true}`) + + server.updateRoomAccess(accessRequest{ + roomID: created.RoomID, hostToken: created.HostToken, cookie: cookie, + body: `{"action":"set-viewer-password","password":null}`, + }).expect(http.StatusOK, `{"type":"viewer-password-updated","enabled":false}`) + + rotated := server.updateRoomAccess(accessRequest{ + roomID: created.RoomID, hostToken: created.HostToken, cookie: cookie, + body: `{"action":"rotate-viewer-grant"}`, + }).expectStatus(http.StatusOK) + var rotatedBody struct { + Type string `json:"type"` + ViewerAuthorizationGeneration string `json:"viewerAuthorizationGeneration"` + InviteURL *string `json:"inviteUrl"` + } + if err := json.Unmarshal([]byte(rotated.body), &rotatedBody); err != nil { + t.Fatalf("decode rotation: %v", err) + } + if rotatedBody.Type != "viewer-grant-updated" || rotatedBody.InviteURL == nil { + t.Fatalf("rotation body = %s", rotated.body) + } + if !regexp.MustCompile(`#v=[A-Za-z0-9_-]{22}$`).MatchString(*rotatedBody.InviteURL) { + t.Fatalf("rotated inviteUrl = %q", *rotatedBody.InviteURL) + } + + revoked := server.updateRoomAccess(accessRequest{ + roomID: created.RoomID, hostToken: created.HostToken, cookie: cookie, + body: `{"action":"revoke-viewer-grant"}`, + }).expectStatus(http.StatusOK) + if !strings.Contains(revoked.body, `"inviteUrl":null`) || + !strings.Contains(revoked.body, `"type":"viewer-grant-updated"`) { + t.Fatalf("revocation body = %s", revoked.body) + } + if _, connected := server.Store().GetConnectedHost(created.RoomID); connected { + t.Fatal("managing access started a sharing session") + } + + now.Store(1_001) + expired, err := server.Store().ExpireRooms(1_001) + if err != nil { + t.Fatalf("ExpireRooms: %v", err) + } + found := false + for _, closed := range expired { + if closed.RoomID == created.RoomID { + found = true + } + } + if !found { + t.Fatalf("access management renewed the lease; expired = %v", expired) + } +} + +func TestRoomAccessRequiresSameOriginSiteAccessAndTheExactHostToken(t *testing.T) { + server := start(t, Options{Config: testConfig(t)}) + cookie := server.cookie() + first := server.createRoom(roomRequest{cookie: cookie}). + expectStatus(http.StatusCreated).room() + second := server.createRoom(roomRequest{cookie: cookie}). + expectStatus(http.StatusCreated).room() + rotate := `{"action":"rotate-viewer-grant"}` + + server.updateRoomAccess(accessRequest{ + roomID: first.RoomID, hostToken: first.HostToken, body: rotate, + }).expect(http.StatusUnauthorized, `{"error":"Unauthorized"}`) + + server.updateRoomAccess(accessRequest{ + roomID: first.RoomID, hostToken: first.HostToken, body: rotate, + cookie: cookie, origin: "https://foreign.test", + }).expect(http.StatusForbidden, `{"error":"Forbidden"}`) + + server.updateRoomAccess(accessRequest{ + roomID: first.RoomID, body: rotate, cookie: cookie, + }).expect(http.StatusNotFound, `{"error":"Room not found"}`) + + server.updateRoomAccess(accessRequest{ + roomID: first.RoomID, hostToken: "wrong-token", body: rotate, cookie: cookie, + }).expect(http.StatusNotFound, `{"error":"Room not found"}`) + + server.updateRoomAccess(accessRequest{ + roomID: first.RoomID, hostToken: second.HostToken, body: rotate, cookie: cookie, + }).expect(http.StatusNotFound, `{"error":"Room not found"}`) + + server.updateRoomAccess(accessRequest{ + roomID: first.RoomID, hostToken: first.HostToken, cookie: cookie, + body: `{"action":"unknown"}`, + }).expect(http.StatusBadRequest, `{"error":"Invalid room access request"}`) + + server.updateRoomAccess(accessRequest{ + roomID: first.RoomID, hostToken: first.HostToken, body: rotate, + cookie: cookie, method: http.MethodGet, + }). + expect(http.StatusMethodNotAllowed, `{"error":"Method not allowed"}`). + expectHeader("Allow", "POST") +} + +func TestRoomCreationRejectsMalformedRequestsAndForeignBrowserOrigins(t *testing.T) { + configuration := testConfig(t) + configuration.SiteAccessPassword = "" + server := start(t, Options{Config: configuration}) + + server.do(http.MethodPost, "/api/rooms", withOrigin(allowedOrigin), withJSON("{}")). + expect(http.StatusBadRequest, `{"error":"Invalid room request"}`) + + server.do(http.MethodPost, "/api/rooms", + withOrigin("https://foreign.test"), withJSON(`{"codeEntryPolicy":"open"}`)). + expect(http.StatusForbidden, `{"error":"Forbidden"}`) + + // An oversized or non-JSON body is the same 400. + server.do(http.MethodPost, "/api/rooms", withOrigin(allowedOrigin), + withJSON(`{"codeEntryPolicy":"open","preferredRoomId":"`+strings.Repeat("1", 1_024)+`"}`)). + expect(http.StatusBadRequest, `{"error":"Invalid room request"}`) + server.do(http.MethodPost, "/api/rooms", withOrigin(allowedOrigin), + withBody("text/plain", `{"codeEntryPolicy":"open"}`)). + expect(http.StatusBadRequest, `{"error":"Invalid room request"}`) + + server.do(http.MethodGet, "/api/rooms", withOrigin(allowedOrigin)). + expect(http.StatusMethodNotAllowed, `{"error":"Method not allowed"}`). + expectHeader("Allow", "POST") +} + +func TestRoomCreationReturnsServiceUnavailableAtTheGlobalRoomBound(t *testing.T) { + configuration := testConfig(t) + configuration.SiteAccessPassword = "" + store, err := room.New(room.Options{ + LeaseMs: 86_400_000, MaxRooms: 1, MaxViewersPerRoom: 8, + }) + if err != nil { + t.Fatalf("room.New: %v", err) + } + server := start(t, Options{Config: configuration, RoomStore: store}) + + server.createRoom(roomRequest{}).expectStatus(http.StatusCreated) + server.createRoom(roomRequest{}). + expect(http.StatusServiceUnavailable, `{"error":"Room capacity reached"}`) +} + +// --- server HTTP listener and health -------------------------------------- + +func TestServesAnExplicitStaticFrontendIndependentlyOfTheEnvironment(t *testing.T) { + assets := fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte("
local-static
")}, + "assets/app.js": &fstest.MapFile{ + Data: []byte("export const marker = 1;\n"), + }, + } + server := start(t, Options{Config: testConfig(t), Assets: assets}) + + server.do(http.MethodGet, "/local-route"). + expect(http.StatusOK, "
local-static
"). + expectHeader("Content-Type", "text/html;charset=utf-8"). + expectHeader("X-Content-Type-Options", "nosniff"). + expectHeader("Referrer-Policy", "no-referrer") + + server.do(http.MethodGet, "/assets/app.js"). + expect(http.StatusOK, "export const marker = 1;\n"). + expectHeader("Content-Type", "text/javascript") + + // /api/** never falls through to the frontend. + server.do(http.MethodGet, "/api/unknown"). + expect(http.StatusNotFound, `{"error":"Not found"}`). + expectHeader("Cache-Control", "no-store") + // A missing asset with an extension keeps the JSON 404. + server.do(http.MethodGet, "/assets/missing.js"). + expect(http.StatusNotFound, `{"error":"Not found"}`) +} + +func TestApiOnlyCompositionAnswersJSONNotFound(t *testing.T) { + server := start(t, Options{Config: testConfig(t)}) + + server.do(http.MethodGet, "/local-route"). + expect(http.StatusNotFound, `{"error":"Not found"}`). + expectHeader("Cache-Control", "") + server.do(http.MethodGet, "/api/unknown"). + expect(http.StatusNotFound, `{"error":"Not found"}`). + expectHeader("Cache-Control", "no-store"). + expectHeader("X-Content-Type-Options", "") +} + +func TestRestoresStableRoomAuthorityAcrossAnApplicationRestart(t *testing.T) { + databasePath := filepath.Join(t.TempDir(), "rooms.sqlite") + var now atomic.Int64 + now.Store(100) + configuration := testConfig(t) + configuration.SiteAccessPassword = "" + configuration.RoomDatabasePath = databasePath + configuration.RoomLeaseMs = 1_000 + + first := start(t, Options{Config: configuration, Now: now.Load}) + created := first.createRoom(roomRequest{codeEntryPolicy: "private"}). + expectStatus(http.StatusCreated).room() + if err := first.Close(context.Background()); err != nil { + t.Fatalf("close first: %v", err) + } + + now.Store(200) + second := start(t, Options{Config: configuration, Now: now.Load}) + if size := second.Store().Size(); size != 1 { + t.Fatalf("restored room count = %d, want 1", size) + } + second.updateRoomAccess(accessRequest{ + roomID: created.RoomID, hostToken: created.HostToken, + body: `{"action":"set-code-entry-policy","policy":"open"}`, + }).expect(http.StatusOK, + `{"type":"code-entry-policy-updated","codeEntryPolicy":"open","viewerPasswordEnabled":false}`) +} + +func TestStartsWithAnInjectedOptionalSFUTokenIssuer(t *testing.T) { + configuration := testConfig(t) + configuration.LiveKit = livekitFallback() + server := start(t, Options{Config: configuration, SFUTokenIssuer: fakeTokenIssuer{}}) + + server.do(http.MethodGet, "/healthz").expect(http.StatusOK, `{"status":"ok"}`) +} + +func TestClearsAStaleManagedLiveKitRoomBeforeServingTraffic(t *testing.T) { + roomControl := sfutest.New() + roomControl.SeedRoom(sfu.ResourceFence{ + RoomID: "42", + ShareGeneration: "stale_share_generation", + PublicationGeneration: "stale_publication_generation", + }, "host", "viewer:stale") + configuration := testConfig(t) + configuration.LiveKit = livekitFallback() + server := start(t, Options{Config: configuration, SFURoomControl: roomControl}) + + if calls := roomControl.InitializeCalls(); calls != 1 { + t.Fatalf("initialize calls = %d, want 1", calls) + } + if deleted := roomControl.StartupDeletedRoomNames(); len(deleted) != 1 { + t.Fatalf("startup deleted rooms = %v, want one", deleted) + } + if count := roomControl.RoomCount(); count != 0 { + t.Fatalf("live rooms = %d, want 0", count) + } + server.do(http.MethodGet, "/healthz").expect(http.StatusOK, `{"status":"ok"}`) +} + +func TestBindsTheApplicationListenerBeforeLiveKitReconciliation(t *testing.T) { + roomControl := sfutest.New() + barrier := make(chan struct{}) + roomControl.SetInitializeBarrier(barrier) + port := freePort(t) + configuration := testConfig(t) + configuration.Port = port + configuration.LiveKit = livekitFallback() + server := newServer(t, Options{Config: configuration, SFURoomControl: roomControl}) + client := testClient(t) + + listening := make(chan error, 1) + go func() { + bound, err := server.Listen(context.Background()) + if err == nil && bound != port { + err = fmt.Errorf("bound port %d, want %d", bound, port) + } + listening <- err + }() + waitFor(t, "LiveKit reconciliation to start", + func() bool { return roomControl.InitializeCalls() == 1 }) + + // Hazard 13: the listener is bound but no traffic is accepted yet. + send(t, client, http.MethodGet, baseURL(port)+"/healthz"). + expect(http.StatusServiceUnavailable, `{"error":"Service starting"}`). + expectHeader("Retry-After", "1"). + expectHeader("Cache-Control", "no-store") + // The /signal upgrade is not served either. + send(t, client, http.MethodGet, baseURL(port)+"/signal", + withOrigin(allowedOrigin), + func(request *http.Request) { + request.Header.Set("Connection", "Upgrade") + request.Header.Set("Upgrade", "websocket") + request.Header.Set("Sec-WebSocket-Version", "13") + request.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==") + }). + expect(http.StatusServiceUnavailable, `{"error":"Service starting"}`) + + close(barrier) + if err := <-listening; err != nil { + t.Fatalf("Listen: %v", err) + } + send(t, client, http.MethodGet, baseURL(port)+"/healthz"). + expect(http.StatusOK, `{"status":"ok"}`) +} + +func TestMakesNoLiveKitCallWhenAnotherProcessOwnsTheListener(t *testing.T) { + owner := start(t, Options{Config: testConfig(t)}) + ownerPort := ownerBoundPort(t, owner) + + databasePath := filepath.Join(t.TempDir(), "missing-parent", "rooms.sqlite") + roomControl := sfutest.New() + configuration := testConfig(t) + configuration.Port = ownerPort + configuration.RoomDatabasePath = databasePath + configuration.LiveKit = livekitFallback() + contender := newServer(t, Options{Config: configuration, SFURoomControl: roomControl}) + + // The TS test asserted the EADDRINUSE code; Windows reports WSAEADDRINUSE, + // which errors.Is does not fold into syscall.EADDRINUSE, so the contract + // asserted here is the one that matters: the bind failed, nothing was + // reconciled, and the owner still holds the port. + _, err := contender.Listen(context.Background()) + if err == nil { + t.Fatal("the contender bound a port another process owns") + } + var addressError *net.OpError + if !errors.As(err, &addressError) || addressError.Op != "listen" { + t.Fatalf("Listen error = %v, want a listen failure", err) + } + if calls := roomControl.InitializeCalls(); calls != 0 { + t.Fatalf("initialize calls = %d, want 0", calls) + } + if _, statErr := os.Stat(databasePath); !os.IsNotExist(statErr) { + t.Fatalf("the room database was created: %v", statErr) + } + owner.do(http.MethodGet, "/healthz").expect(http.StatusOK, `{"status":"ok"}`) +} + +// ownerBoundPort re-reads the port a started harness listens on. +func ownerBoundPort(t *testing.T, server *harness) int { + t.Helper() + parsed, err := url.Parse(server.baseURL) + if err != nil { + t.Fatalf("url.Parse: %v", err) + } + port := 0 + if _, err := fmt.Sscanf(parsed.Port(), "%d", &port); err != nil { + t.Fatalf("parse port: %v", err) + } + return port +} + +func TestRejectsASecondDatabaseOwnerBeforeLiveKitReconciliation(t *testing.T) { + databasePath := filepath.Join(t.TempDir(), "rooms.sqlite") + ownerConfig := testConfig(t) + ownerConfig.RoomDatabasePath = databasePath + start(t, Options{Config: ownerConfig}) + + roomControl := sfutest.New() + contenderConfig := testConfig(t) + contenderConfig.Port = freePort(t) + contenderConfig.RoomDatabasePath = databasePath + contenderConfig.LiveKit = livekitFallback() + contender := newServer(t, Options{Config: contenderConfig, SFURoomControl: roomControl}) + + _, err := contender.Listen(context.Background()) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "locked") { + t.Fatalf("Listen error = %v, want a locked database", err) + } + if calls := roomControl.InitializeCalls(); calls != 0 { + t.Fatalf("initialize calls = %d, want 0", calls) + } + assertPortFree(t, contenderConfig.Port) +} + +func TestRejectsAMismatchedDatabaseBeforeLiveKitReconciliation(t *testing.T) { + databasePath := filepath.Join(t.TempDir(), "rooms.sqlite") + raw, err := sql.Open("sqlite", "file:"+filepath.ToSlash(databasePath)) + if err != nil { + t.Fatalf("sql.Open: %v", err) + } + if _, err := raw.Exec("CREATE TABLE unrelated(value TEXT) STRICT"); err != nil { + t.Fatalf("seed unrelated schema: %v", err) + } + if err := raw.Close(); err != nil { + t.Fatalf("close raw database: %v", err) + } + + roomControl := sfutest.New() + configuration := testConfig(t) + configuration.Port = freePort(t) + configuration.RoomDatabasePath = databasePath + configuration.LiveKit = livekitFallback() + server := newServer(t, Options{Config: configuration, SFURoomControl: roomControl}) + + _, err = server.Listen(context.Background()) + if err == nil || + !strings.Contains(err.Error(), "Room database application identity does not match") { + t.Fatalf("Listen error = %v", err) + } + if calls := roomControl.InitializeCalls(); calls != 0 { + t.Fatalf("initialize calls = %d, want 0", calls) + } + assertPortFree(t, configuration.Port) +} + +func TestRejectsAnInaccessibleDatabasePathBeforeLiveKitReconciliation(t *testing.T) { + databasePath := filepath.Join(t.TempDir(), "missing-parent", "rooms.sqlite") + roomControl := sfutest.New() + configuration := testConfig(t) + configuration.Port = freePort(t) + configuration.RoomDatabasePath = databasePath + configuration.LiveKit = livekitFallback() + server := newServer(t, Options{Config: configuration, SFURoomControl: roomControl}) + + if _, err := server.Listen(context.Background()); err == nil { + t.Fatal("Listen accepted an unreachable database path") + } + if calls := roomControl.InitializeCalls(); calls != 0 { + t.Fatalf("initialize calls = %d, want 0", calls) + } + if _, err := os.Stat(databasePath); !os.IsNotExist(err) { + t.Fatalf("the room database was created: %v", err) + } + assertPortFree(t, configuration.Port) +} + +func TestHoldsListenerOwnershipUntilAnInFlightStartupSettles(t *testing.T) { + before := runtime.NumGoroutine() + roomControl := sfutest.New() + barrier := make(chan struct{}) + roomControl.SetInitializeBarrier(barrier) + port := freePort(t) + configuration := testConfig(t) + configuration.Port = port + configuration.LiveKit = livekitFallback() + server := newServer(t, Options{Config: configuration, SFURoomControl: roomControl}) + + listening := make(chan int, 1) + go func() { + bound, err := server.Listen(context.Background()) + if err != nil { + bound = -1 + } + listening <- bound + }() + waitFor(t, "LiveKit reconciliation to start", + func() bool { return roomControl.InitializeCalls() == 1 }) + + var closed atomic.Bool + closing := make(chan error, 1) + go func() { + err := server.Close(context.Background()) + closed.Store(true) + closing <- err + }() + time.Sleep(20 * time.Millisecond) + if closed.Load() { + t.Fatal("Close returned while startup was still in flight") + } + // A14: the listener is still owned, so a contender cannot take the port. + if listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)); err == nil { + _ = listener.Close() + t.Fatal("the listener was released before startup settled") + } + + close(barrier) + if bound := <-listening; bound != port { + t.Fatalf("Listen returned %d, want %d", bound, port) + } + if err := <-closing; err != nil { + t.Fatalf("Close: %v", err) + } + if !closed.Load() { + t.Fatal("Close never settled") + } + assertPortFree(t, port) + expectGoroutinesSettled(t, before) +} + +func TestClosesTheListenerWhenLiveKitStartupReconciliationFails(t *testing.T) { + before := runtime.NumGoroutine() + roomControl := sfutest.New() + roomControl.SetInitializeError(errors.New("startup reconciliation failed")) + port := freePort(t) + configuration := testConfig(t) + configuration.Port = port + configuration.LiveKit = livekitFallback() + server := newServer(t, Options{Config: configuration, SFURoomControl: roomControl}) + + _, err := server.Listen(context.Background()) + if err == nil || !strings.Contains(err.Error(), "startup reconciliation failed") { + t.Fatalf("Listen error = %v", err) + } + if calls := roomControl.InitializeCalls(); calls != 1 { + t.Fatalf("initialize calls = %d, want 1", calls) + } + assertPortFree(t, port) + expectGoroutinesSettled(t, before) +} + +func TestUsesTheConfiguredListenHostByDefault(t *testing.T) { + server := start(t, Options{Config: testConfig(t)}) + + if !strings.HasPrefix(server.baseURL, "http://127.0.0.1:") { + t.Fatalf("listen address = %q", server.baseURL) + } + server.do(http.MethodGet, "/healthz").expect(http.StatusOK, `{"status":"ok"}`) +} + +func TestReportsProcessLivenessWithoutAccessChecks(t *testing.T) { + server := start(t, Options{Config: testConfig(t)}) + + server.do(http.MethodGet, "/healthz", withOrigin("https://foreign.test")). + expect(http.StatusOK, `{"status":"ok"}`). + expectHeader("Cache-Control", "no-store"). + expectHeader("Content-Type", "application/json; charset=utf-8") +} + +func TestOnlyAcceptsGETHealthChecks(t *testing.T) { + server := start(t, Options{Config: testConfig(t)}) + + server.do(http.MethodPost, "/healthz"). + expect(http.StatusMethodNotAllowed, `{"error":"Method not allowed"}`). + expectHeader("Allow", "GET"). + expectHeader("Cache-Control", "no-store") +} + +func TestListenIsSingleShotAndRefusedAfterClose(t *testing.T) { + before := runtime.NumGoroutine() + server := start(t, Options{Config: testConfig(t)}) + + if _, err := server.Listen(context.Background()); err == nil || + err.Error() != "Screener server startup was already requested" { + t.Fatalf("second Listen error = %v", err) + } + if err := server.Close(context.Background()); err != nil { + t.Fatalf("Close: %v", err) + } + expectGoroutinesSettled(t, before) + + fresh := newServer(t, Options{Config: testConfig(t)}) + if err := fresh.Close(context.Background()); err != nil { + t.Fatalf("Close before Listen: %v", err) + } + if _, err := fresh.Listen(context.Background()); err == nil || + err.Error() != "Screener server is closing" { + t.Fatalf("Listen after Close error = %v", err) + } +} + +// --- request failures and server settings --------------------------------- + +// The createServer .catch of app.ts: a handler that throws answers 500 and the +// record carries the method and the path only (D11). +func TestRequestFailureAnswers500AndLogsMethodAndPathOnly(t *testing.T) { + var logged bytes.Buffer + server := newServer(t, Options{ + Config: testConfig(t), + Logger: slog.New(slog.NewTextHandler(&logged, nil)), + }) + server.frontend = http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + panic("viewer-grant-that-must-not-be-logged") + }) + server.acceptingTraffic.Store(true) + + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, + httptest.NewRequest(http.MethodGet, "/boom?grant=viewer-grant-that-must-not-be-logged", nil)) + + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", recorder.Code) + } + if body := recorder.Body.String(); body != `{"error":"Internal server error"}` { + t.Fatalf("body = %s", body) + } + record := logged.String() + if !strings.Contains(record, "HTTP request failed") || + !strings.Contains(record, "method=GET") || + !strings.Contains(record, "path=/boom") { + t.Fatalf("log record = %q", record) + } + if strings.Contains(record, "viewer-grant-that-must-not-be-logged") { + t.Fatalf("the failure log leaked the request: %q", record) + } +} + +// A failure after the response started destroyed the socket in Node; in Go the +// equivalent is http.ErrAbortHandler, which net/http turns into a silent close. +func TestRequestFailureAfterTheResponseStartedAbortsTheConnection(t *testing.T) { + server := newServer(t, Options{ + Config: testConfig(t), + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + server.frontend = http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusOK) + panic("late failure") + }) + server.acceptingTraffic.Store(true) + + defer func() { + if recovered := recover(); recovered != http.ErrAbortHandler { + t.Fatalf("recovered = %v, want http.ErrAbortHandler", recovered) + } + }() + server.ServeHTTP(httptest.NewRecorder(), + httptest.NewRequest(http.MethodGet, "/late", nil)) + t.Fatal("ServeHTTP returned instead of aborting the connection") +} + +// The three Node HTTP server settings of map section 3.3 / D11, and the write +// deadline Node never had. +func TestHTTPServerKeepsTheNodeTimeouts(t *testing.T) { + server := newServer(t, Options{Config: testConfig(t)}) + + if got := server.httpServer.ReadHeaderTimeout; got != 15*time.Second { + t.Fatalf("ReadHeaderTimeout = %v, want headersTimeout 15s", got) + } + if got := server.httpServer.ReadTimeout; got != 10*time.Second { + t.Fatalf("ReadTimeout = %v, want requestTimeout 10s", got) + } + if got := server.httpServer.IdleTimeout; got != 5*time.Second { + t.Fatalf("IdleTimeout = %v, want keepAliveTimeout 5s", got) + } + if got := server.httpServer.WriteTimeout; got != 0 { + t.Fatalf("WriteTimeout = %v, want none", got) + } +} + +// The upgrade dispatch of ServeHTTP: Node handed every Connection: Upgrade +// request to the signaling server's own listener, which owns the whole +// rejection ladder, and a plain GET of the same path stayed an ordinary +// request. It is also the only cover for the response wrapper being +// unwrappable to the http.Hijacker websocket.Accept needs. +func TestSignalUpgradeIsDispatchedToTheSignalingServer(t *testing.T) { + server := start(t, Options{Config: testConfig(t)}) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // websocket.Dial refuses a client with a Timeout, so the harness client + // cannot be reused here. + client := &http.Client{Transport: &http.Transport{DisableKeepAlives: true}} + target := "ws" + strings.TrimPrefix(server.baseURL, "http") + "/signal" + + connection, _, err := websocket.Dial(ctx, target, &websocket.DialOptions{ + HTTPClient: client, + HTTPHeader: http.Header{"Origin": []string{allowedOrigin}}, + }) + if err != nil { + t.Fatalf("upgrade /signal: %v", err) + } + if err := connection.Close(websocket.StatusNormalClosure, ""); err != nil { + t.Fatalf("close signal connection: %v", err) + } + + if _, _, err := websocket.Dial(ctx, target, &websocket.DialOptions{ + HTTPClient: client, + HTTPHeader: http.Header{"Origin": []string{"https://foreign.test"}}, + }); err == nil { + t.Fatal("a foreign origin completed the upgrade") + } + + server.do(http.MethodGet, "/signal"). + expect(http.StatusNotFound, `{"error":"Not found"}`) +} + +// --- startup failures the TypeScript could not have ------------------------ + +// panickingRoomControl is a reconciliation step that fails with a panic rather +// than an error, which is what a nil dereference inside a future startup step +// would look like. +type panickingRoomControl struct{ sfu.RoomControl } + +func (panickingRoomControl) Initialize(context.Context) error { + panic(errors.New("startup reconciliation panicked")) +} + +// app.ts:219 awaited startupOperation inside a try/catch, so a rejected +// startup still settled the promise shutdown waited on. A panic out of start() +// must release the same latch. +func TestShutdownIsNotBlockedByAStartupThatPanicked(t *testing.T) { + configuration := testConfig(t) + configuration.LiveKit = livekitFallback() + server, err := New(Options{ + Config: configuration, + SFURoomControl: panickingRoomControl{}, + SFUTokenIssuer: fakeTokenIssuer{}, + HeartbeatIntervalMs: 60_000, + CleanupIntervalMs: 60_000, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + func() { + defer func() { + if recovered := recover(); recovered == nil { + t.Fatal("Listen swallowed the startup panic") + } + }() + _, _ = server.Listen(context.Background()) + }() + + closed := make(chan error, 1) + go func() { closed <- server.Close(context.Background()) }() + select { + case <-closed: + case <-time.After(5 * time.Second): + t.Fatal("Close blocked on the startup latch after a panicking startup") + } +} + +// syncBuffer collects log records written from the Serve goroutine. +type syncBuffer struct { + mu sync.Mutex + records bytes.Buffer +} + +func (buffer *syncBuffer) Write(record []byte) (int, error) { + buffer.mu.Lock() + defer buffer.mu.Unlock() + return buffer.records.Write(record) +} + +func (buffer *syncBuffer) String() string { + buffer.mu.Lock() + defer buffer.mu.Unlock() + return buffer.records.String() +} + +// Node ended the process when the listening server raised, because +// bindHttpServer had already removed its "error" handler. Serve's error must +// at least reach the operator instead of leaving nothing serving in silence. +func TestReportsAListenerThatDiesWhileServing(t *testing.T) { + var logged syncBuffer + server := newServer(t, Options{ + Config: testConfig(t), + Logger: slog.New(slog.NewTextHandler(&logged, nil)), + }) + if _, err := server.Listen(context.Background()); err != nil { + t.Fatalf("Listen: %v", err) + } + + // Out of band: no Shutdown, so Serve returns a real accept failure. + if err := server.listener.Close(); err != nil { + t.Fatalf("close the listener: %v", err) + } + waitFor(t, "the serve failure to be reported", func() bool { + return strings.Contains(logged.String(), "Screener HTTP server stopped unexpectedly") + }) +} diff --git a/internal/server/app/json.go b/internal/server/app/json.go new file mode 100644 index 0000000..736a28d --- /dev/null +++ b/internal/server/app/json.go @@ -0,0 +1,148 @@ +package app + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "strings" +) + +// Request and response helpers ported from src/server/app.ts. + +// maxJSONRequestBytes is the readJsonBody(request, 1_024) budget every JSON +// route shares. +const maxJSONRequestBytes = 1024 + +// errorBody is the { error } shape every failing route answers with. +type errorBody struct { + Error string `json:"error"` +} + +// healthBody is the /healthz payload. +type healthBody struct { + Status string `json:"status"` +} + +// siteAccessBody is siteAccessStatus(); the key order is the TS literal's. +type siteAccessBody struct { + Required bool `json:"required"` + Authenticated bool `json:"authenticated"` +} + +// sendJSON always sets the exact Content-Length and the charset, as app.ts did. +// Encoding one of this package's own values cannot fail, so a marshal error is +// a programming error and reaches the recover wrapper. +func sendJSON(writer http.ResponseWriter, status int, body any) { + var buffer bytes.Buffer + encoder := json.NewEncoder(&buffer) + // D9: JSON.stringify does not escape HTML either. + encoder.SetEscapeHTML(false) + if err := encoder.Encode(body); err != nil { + panic(err) + } + encoded := bytes.TrimSuffix(buffer.Bytes(), []byte("\n")) + header := writer.Header() + header.Set("Content-Type", "application/json; charset=utf-8") + header.Set("Content-Length", strconv.Itoa(len(encoded))) + writer.WriteHeader(status) + _, _ = writer.Write(encoded) +} + +// readJSONBody ports readJsonBody: the content type must be exactly +// application/json once its parameters are cut, the body must be at most 1024 +// bytes and must not be empty. Every failure collapses to the route's 400, so +// the messages only exist to keep the TS reasons readable. +func readJSONBody(request *http.Request) ([]byte, error) { + contentType, _, _ := strings.Cut(request.Header.Get("Content-Type"), ";") + if strings.TrimSpace(contentType) != "application/json" { + return nil, errors.New("Request content type must be application/json") + } + body, err := io.ReadAll(io.LimitReader(request.Body, maxJSONRequestBytes+1)) + if err != nil { + return nil, err + } + if len(body) > maxJSONRequestBytes { + return nil, errors.New("Request body is too large") + } + if len(body) == 0 { + return nil, errors.New("Request body is required") + } + return body, nil +} + +// hasRequestBody ports hasRequestBody. net/http parses Content-Length and moves +// Transfer-Encoding out of Header, so a chunked body is ContentLength -1 and an +// unparseable length never reaches a handler at all. +func hasRequestBody(request *http.Request) bool { + return len(request.TransferEncoding) > 0 || request.ContentLength != 0 +} + +// requestPath is the WHATWG `url.pathname` app.ts routed and logged on. It is +// the escaped path on purpose: `new URL()` does not percent-decode, so +// "/api%2frooms" must not reach the /api/rooms route. +// +// Deviation: `new URL()` also resolves "." and ".." segments, which net/http +// leaves in the path. "/x/../api/rooms" therefore reaches the frontend handler +// here where TypeScript routed it to room creation. Only stricter: a raw path +// that already names a route still matches, so nothing can slip past an origin, +// cookie or token check that way. +func requestPath(request *http.Request) string { + return request.URL.EscapedPath() +} + +// cookieHeader rebuilds the single Cookie header value the Node parser handed +// to access-session.ts; Go keeps repeated Cookie headers apart. +func cookieHeader(request *http.Request) string { + return strings.Join(request.Header.Values("Cookie"), "; ") +} + +// bearerToken ports readBearerToken: the prefix is case sensitive and an empty +// token counts as absent. +func bearerToken(request *http.Request) string { + authorization := request.Header.Get("Authorization") + if !strings.HasPrefix(authorization, "Bearer ") { + return "" + } + return strings.TrimPrefix(authorization, "Bearer ") +} + +// isUpgradeRequest reproduces the condition under which Node emitted "upgrade" +// instead of "request": the parser needs both a Connection: upgrade token and +// an Upgrade header. The SignalingServer, not this router, owns the rejection +// ladder for a path or query that is not exactly /signal. +func isUpgradeRequest(request *http.Request) bool { + if request.Header.Get("Upgrade") == "" { + return false + } + for _, token := range strings.Split(request.Header.Get("Connection"), ",") { + if strings.EqualFold(strings.TrimSpace(token), "upgrade") { + return true + } + } + return false +} + +// responseRecorder tracks response.headersSent so the recover wrapper can pick +// between the JSON 500 and destroying the connection, as app.ts did. +type responseRecorder struct { + http.ResponseWriter + wrote bool +} + +func (recorder *responseRecorder) WriteHeader(status int) { + recorder.wrote = true + recorder.ResponseWriter.WriteHeader(status) +} + +func (recorder *responseRecorder) Write(data []byte) (int, error) { + recorder.wrote = true + return recorder.ResponseWriter.Write(data) +} + +// Unwrap lets http.ResponseController reach the real writer's Flush and Hijack. +func (recorder *responseRecorder) Unwrap() http.ResponseWriter { + return recorder.ResponseWriter +} diff --git a/internal/server/app/routes.go b/internal/server/app/routes.go new file mode 100644 index 0000000..d1a7372 --- /dev/null +++ b/internal/server/app/routes.go @@ -0,0 +1,442 @@ +package app + +import ( + "errors" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/TNTcraftHIM/Screener/internal/server/config" + "github.com/TNTcraftHIM/Screener/internal/server/protocol" + "github.com/TNTcraftHIM/Screener/internal/server/room" + "github.com/TNTcraftHIM/Screener/internal/server/signal" +) + +// The request router of src/server/app.ts. Route order is a contract (map +// section 4.3, O34): the first match wins and the /api/ catch-all sits after +// every concrete route. + +var ( + roomReplacementPath = regexp.MustCompile(`^/api/rooms/([1-9][0-9]{3})/replacement$`) + roomAccessPath = regexp.MustCompile(`^/api/rooms/([1-9][0-9]{3})/access$`) +) + +// ServeHTTP is the createServer request listener of app.ts. +func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + recorder := &responseRecorder{ResponseWriter: writer} + defer func() { + recovered := recover() + if recovered == nil { + return + } + if recovered == http.ErrAbortHandler { + panic(recovered) + } + // TS: console.error("HTTP request failed", {method, path, error}). + // D11 keeps the method and the path only. + s.requestFailed(recorder, request) + }() + + if !s.acceptingTraffic.Load() { + // Hazard 13: no traffic at all until the SignalingServer exists, so + // /healthz answers 503 while LiveKit reconciliation runs. + recorder.Header().Set("Cache-Control", "no-store") + recorder.Header().Set("Retry-After", "1") + sendJSON(recorder, http.StatusServiceUnavailable, errorBody{"Service starting"}) + return + } + signaling := s.signaling.Load() + if isUpgradeRequest(request) { + // Node routed every upgrade to the SignalingServer's "upgrade" + // listener, which was attached only while traffic was accepted. + signaling.ServeHTTP(recorder, request) + return + } + s.route(recorder, request, signaling) +} + +func (s *Server) route( + writer http.ResponseWriter, + request *http.Request, + signaling *signal.Server, +) { + path := requestPath(request) + + if path == "/healthz" { + writer.Header().Set("Cache-Control", "no-store") + if !allowMethod(writer, request, http.MethodGet) { + return + } + sendJSON(writer, http.StatusOK, healthBody{"ok"}) + return + } + + if path == "/api/capabilities" { + noStoreJSON(writer) + if !allowMethod(writer, request, http.MethodGet) { + return + } + sendJSON(writer, http.StatusOK, protocol.RuntimeCapabilities{ + NatPrediction: s.config.NATPredictionEnabled, + }) + return + } + + if path == "/api/site-access" { + s.handleSiteAccess(writer, request) + return + } + + if match := roomReplacementPath.FindStringSubmatch(path); match != nil { + s.handleRoomReplacement(writer, request, signaling, match[1]) + return + } + + if match := roomAccessPath.FindStringSubmatch(path); match != nil { + s.handleRoomAccess(writer, request, signaling, match[1]) + return + } + + if path == "/api/rooms" { + s.handleRoomCreation(writer, request, signaling) + return + } + + if strings.HasPrefix(path, "/api/") { + // The catch-all sets no nosniff header; only the concrete routes do. + writer.Header().Set("Cache-Control", "no-store") + sendJSON(writer, http.StatusNotFound, errorBody{"Not found"}) + return + } + + if s.frontend != nil { + s.frontend.ServeHTTP(writer, request) + return + } + notFoundJSON(writer, request) +} + +// notFoundJSON is the fallthrough app.ts handed to sirv as its next() +// callback and used when no frontend is composed at all. +func notFoundJSON(writer http.ResponseWriter, _ *http.Request) { + sendJSON(writer, http.StatusNotFound, errorBody{"Not found"}) +} + +// noStoreJSON sets the two headers every /api route sets before its method +// check, so a 405 carries them too. +func noStoreJSON(writer http.ResponseWriter) { + writer.Header().Set("Cache-Control", "no-store") + writer.Header().Set("X-Content-Type-Options", "nosniff") +} + +// allowMethod answers the 405 row of the HTTP table, Allow header included. +func allowMethod(writer http.ResponseWriter, request *http.Request, allowed string) bool { + if request.Method == allowed { + return true + } + writer.Header().Set("Allow", allowed) + sendJSON(writer, http.StatusMethodNotAllowed, errorBody{"Method not allowed"}) + return false +} + +// handleSiteAccess is handleSiteAccessRequest of app.ts. +func (s *Server) handleSiteAccess(writer http.ResponseWriter, request *http.Request) { + noStoreJSON(writer) + + if request.Method == http.MethodGet { + status := siteAccessBody{ + Required: s.siteAccess.required(), + Authenticated: s.siteAccess.isAuthenticated(cookieHeader(request)), + } + if status.Required && status.Authenticated { + // The Set-Cookie value is written verbatim, in the TS attribute + // order (D11). + writer.Header().Set("Set-Cookie", s.siteAccess.createCookie()) + } + sendJSON(writer, http.StatusOK, status) + return + } + + if request.Method != http.MethodPost { + writer.Header().Set("Allow", "GET, POST") + sendJSON(writer, http.StatusMethodNotAllowed, errorBody{"Method not allowed"}) + return + } + + if !s.allowedRequestOrigin(request) { + sendJSON(writer, http.StatusForbidden, errorBody{"Forbidden"}) + return + } + if hasRequestBody(request) { + sendJSON(writer, http.StatusBadRequest, errorBody{"Request body is not accepted"}) + return + } + if !s.siteAccess.required() { + sendJSON(writer, http.StatusOK, siteAccessBody{Required: false, Authenticated: true}) + return + } + if provided := bearerToken(request); provided == "" || !s.siteAccess.passwordMatches(provided) { + writer.Header().Set("WWW-Authenticate", "Bearer") + sendJSON(writer, http.StatusUnauthorized, errorBody{"Unauthorized"}) + return + } + writer.Header().Set("Set-Cookie", s.siteAccess.createCookie()) + sendJSON(writer, http.StatusOK, siteAccessBody{Required: true, Authenticated: true}) +} + +func (s *Server) handleRoomReplacement( + writer http.ResponseWriter, + request *http.Request, + signaling *signal.Server, + roomID string, +) { + noStoreJSON(writer) + if !allowMethod(writer, request, http.MethodPost) { + return + } + hostToken, authorized := s.authorizedRoomHostToken(writer, request) + if !authorized { + return + } + body, err := readJSONBody(request) + var parsed protocol.ReplaceRoomRequest + if err == nil { + parsed, err = protocol.DecodeReplaceRoomRequest(body) + } + if err != nil { + sendJSON(writer, http.StatusBadRequest, errorBody{"Invalid room replacement request"}) + return + } + if signaling == nil { + sendJSON(writer, http.StatusServiceUnavailable, errorBody{"Service unavailable"}) + return + } + created, err := signaling.ReplaceRoom( + roomID, + hostToken, + parsed.CodeEntryPolicy, + parsed.RoomPassword.Value, + ) + if err != nil { + if status, message, mapped := replacementFailure(err); mapped { + sendJSON(writer, status, errorBody{message}) + return + } + s.requestFailed(writer, request) + return + } + sendJSON(writer, http.StatusCreated, s.createRoomResponse(created)) +} + +func (s *Server) handleRoomAccess( + writer http.ResponseWriter, + request *http.Request, + signaling *signal.Server, + roomID string, +) { + noStoreJSON(writer) + if !allowMethod(writer, request, http.MethodPost) { + return + } + hostToken, authorized := s.authorizedRoomHostToken(writer, request) + if !authorized { + return + } + body, err := readJSONBody(request) + var parsed protocol.RoomAccessUpdateRequest + if err == nil { + parsed, err = protocol.DecodeRoomAccessUpdateRequest(body) + } + if err != nil { + sendJSON(writer, http.StatusBadRequest, errorBody{"Invalid room access request"}) + return + } + if signaling == nil { + sendJSON(writer, http.StatusServiceUnavailable, errorBody{"Service unavailable"}) + return + } + updated, err := signaling.UpdateRoomAccess(roomID, hostToken, parsed) + if err != nil { + if status, message, mapped := accessFailure(err); mapped { + sendJSON(writer, status, errorBody{message}) + return + } + s.requestFailed(writer, request) + return + } + sendJSON(writer, http.StatusOK, updated) +} + +func (s *Server) handleRoomCreation( + writer http.ResponseWriter, + request *http.Request, + signaling *signal.Server, +) { + noStoreJSON(writer) + if !allowMethod(writer, request, http.MethodPost) { + return + } + if !s.allowedRequestOrigin(request) { + sendJSON(writer, http.StatusForbidden, errorBody{"Forbidden"}) + return + } + // Hazard 12: the cookie is the only room-creation credential. A + // `Bearer ` header must still be 401. + if !s.roomCreationAuthorized(request) { + sendJSON(writer, http.StatusUnauthorized, errorBody{"Unauthorized"}) + return + } + body, err := readJSONBody(request) + var parsed protocol.CreateRoomRequest + if err == nil { + parsed, err = protocol.DecodeCreateRoomRequest(body) + } + if err != nil { + sendJSON(writer, http.StatusBadRequest, errorBody{"Invalid room request"}) + return + } + // TS called roomStore.createRoom directly; the store is now guarded by the + // signaling server's global mutex, so creation goes through it. There is + // no `signaling == nil` answer here, because app.ts had no getSignaling() + // check on this route: reconcile stores the signaling server before it + // sets acceptingTraffic and never clears it again, so reaching a handler + // at all means the pointer is set (the two sibling room routes keep their + // 503 only because the TypeScript had one). + created, err := signaling.CreateRoom( + parsed.CodeEntryPolicy, + parsed.RoomPassword.Value, + parsed.PreferredRoomID, + ) + if err != nil { + if status, message, mapped := creationFailure(err); mapped { + sendJSON(writer, status, errorBody{message}) + return + } + s.requestFailed(writer, request) + return + } + sendJSON(writer, http.StatusCreated, s.createRoomResponse(created)) +} + +// authorizedRoomHostToken of app.ts: origin, then site access, then the bearer +// Host token, each with its own terminal response. +func (s *Server) authorizedRoomHostToken( + writer http.ResponseWriter, + request *http.Request, +) (string, bool) { + if !s.allowedRequestOrigin(request) { + sendJSON(writer, http.StatusForbidden, errorBody{"Forbidden"}) + return "", false + } + if !s.roomCreationAuthorized(request) { + sendJSON(writer, http.StatusUnauthorized, errorBody{"Unauthorized"}) + return "", false + } + hostToken := bearerToken(request) + if hostToken == "" { + // An absent Host token is indistinguishable from a wrong one. + sendJSON(writer, http.StatusNotFound, errorBody{"Room not found"}) + return "", false + } + return hostToken, true +} + +// allowedRequestOrigin ports isStrictlyAllowedRequestOrigin: the header must be +// present, must parse, must be exactly its own serialised origin (so +// "http://allowed.test/path" is refused) and must be configured. +func (s *Server) allowedRequestOrigin(request *http.Request) bool { + origin := request.Header.Get("Origin") + if origin == "" { + return false + } + parsed, err := url.Parse(origin) + if err != nil || config.Origin(parsed) != origin { + return false + } + _, allowed := s.config.AllowedOrigins[origin] + return allowed +} + +// roomCreationAuthorized ports isRoomCreationAuthorized. +func (s *Server) roomCreationAuthorized(request *http.Request) bool { + return !s.siteAccess.required() || s.siteAccess.isAuthenticated(cookieHeader(request)) +} + +// createRoomResponse of app.ts. signal.InviteURL is the one owner of the invite +// URL shape; the rotated-grant notification on the signaling side uses it too. +func (s *Server) createRoomResponse(created room.CreatedRoom) protocol.CreateRoomResponse { + return protocol.CreateRoomResponse{ + RoomID: created.RoomID, + HostToken: created.HostToken, + InviteURL: signal.InviteURL(s.config.PublicBaseURL, created.RoomID, created.ViewerGrant), + CodeEntryPolicy: created.CodeEntryPolicy, + ExpiresAt: created.ExpiresAt, + // The lease is configured in whole seconds; TS divided by 1000 into a + // JS number and the response schema declares an integer. + RoomLeaseSeconds: protocol.Int(s.config.RoomLeaseMs / 1_000), + } +} + +// requestFailed is the createServer .catch: log the method and the path only — +// never a body, header, token or grant (D11) — then answer 500 unless the +// response already started, where Node destroyed the socket. +func (s *Server) requestFailed(writer http.ResponseWriter, request *http.Request) { + s.logger.Error("HTTP request failed", + "method", request.Method, "path", requestPath(request)) + if recorder, ok := writer.(*responseRecorder); ok && recorder.wrote { + panic(http.ErrAbortHandler) + } + sendJSON(writer, http.StatusInternalServerError, errorBody{"Internal server error"}) +} + +// roomErrorCode returns the RoomStoreError code, or "" when err is not one. +func roomErrorCode(err error) room.ErrorCode { + var roomError *room.Error + if errors.As(err, &roomError) { + return roomError.Code + } + return "" +} + +// replacementFailure maps replaceRoom errors; an unmapped error rethrows into +// the 500 handler, as the TS `throw error` did. +func replacementFailure(err error) (int, string, bool) { + switch roomErrorCode(err) { + case room.CodeRoomLimit: + return http.StatusServiceUnavailable, "Room capacity reached", true + case room.CodeRoomAccessDenied, room.CodeRoomBusy: + return http.StatusServiceUnavailable, "Room replacement unavailable", true + case room.CodeInvalidToken, room.CodeRoomNotFound, room.CodeRoomExpired: + return http.StatusNotFound, "Room not found", true + } + return 0, "", false +} + +// accessFailure maps updateRoomAccess errors. Unlike the other two tables, +// every remaining RoomStoreError is a 404; only a non-store error rethrows. +func accessFailure(err error) (int, string, bool) { + switch code := roomErrorCode(err); code { + case "": + return 0, "", false + case room.CodeRoomBusy: + return http.StatusServiceUnavailable, "Room access update unavailable", true + case room.CodeRoomAccessDenied: + return http.StatusConflict, "Room access update rejected", true + default: + return http.StatusNotFound, "Room not found", true + } +} + +// creationFailure maps createRoom errors. +func creationFailure(err error) (int, string, bool) { + switch roomErrorCode(err) { + case room.CodeRoomLimit: + return http.StatusServiceUnavailable, "Room capacity reached", true + case room.CodeRoomBusy: + return http.StatusServiceUnavailable, "Room creation unavailable", true + case room.CodeInvalidToken: + return http.StatusBadRequest, "Invalid room request", true + } + return 0, "", false +} diff --git a/internal/server/app/static.go b/internal/server/app/static.go new file mode 100644 index 0000000..0e15f9a --- /dev/null +++ b/internal/server/app/static.go @@ -0,0 +1,162 @@ +package app + +import ( + "io" + "io/fs" + "net/http" + "path" + "strings" + + "github.com/TNTcraftHIM/Screener/internal/server/protocol" +) + +// staticContentTypes freezes mrmime's answers for the extensions a Vite build +// of this repo can emit. Go's mime.TypeByExtension overlays the Windows +// registry or /etc/mime.types, so the served Content-Type would otherwise +// depend on the machine; with X-Content-Type-Options: nosniff a wrong type on a +// font or a webmanifest is user visible. See evidence/runtime.md section 4.2. +var staticContentTypes = map[string]string{ + ".html": "text/html;charset=utf-8", // sirv appends ";charset=utf-8" with no space + ".htm": "text/html;charset=utf-8", + ".js": "text/javascript", + ".mjs": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".map": "application/json", + ".txt": "text/plain", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", + ".avif": "image/avif", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".otf": "font/otf", + ".wasm": "application/wasm", + ".webmanifest": "application/manifest+json", + ".bin": "application/octet-stream", + ".xml": "text/xml", + ".ico": "", // mrmime has no .ico, so sirv sends an EMPTY Content-Type +} + +// staticHandler reproduces `sirv(directory, { single: true, setHeaders })` as +// src/server/app.ts used it, over an fs.FS instead of a directory walk. +// notFound is the app's JSON 404, which sirv reached through its next() +// callback. Verified against sirv@3.0.2 in evidence/runtime.md section 4.4. +func staticHandler(assets fs.FS, notFound http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + // sirv never inspects req.method: app.ts routes /healthz and /api/** + // first, so everything else reaches the frontend handler as-is. + cleaned := path.Clean("/" + request.URL.Path) + name := strings.TrimPrefix(cleaned, "/") + + serve := func(candidate string) bool { + // path.Clean already dropped any "..", and totalist never indexed a + // dot-prefixed segment, so neither can be served. + if candidate == "" || hasDotSegment(candidate) { + return false + } + file, err := assets.Open(candidate) + if err != nil { + return false + } + defer file.Close() + info, err := file.Stat() + if err != nil || info.IsDir() { + return false + } + seeker, ok := file.(io.ReadSeeker) + if !ok { + return false + } + // app.ts setHeaders. + writer.Header().Set("X-Content-Type-Options", "nosniff") + writer.Header().Set("Referrer-Policy", "no-referrer") + // Set Content-Type explicitly, possibly to "", so ServeContent + // never sniffs; the empty name keeps it from guessing either. + writer.Header()["Content-Type"] = []string{staticContentTypes[strings.ToLower(path.Ext(candidate))]} + // embed.FS reports a zero ModTime, so no Last-Modified is emitted + // and no conditional request is honoured. sirv sends + // Last-Modified but never answers 304 either; do not fabricate one. + http.ServeContent(writer, request, "", info.ModTime(), seeker) + return true + } + + // sirv strips a trailing "/" before the lookup, and its FILES map holds + // files only, so "/assets/" can never hit a file. + if !strings.HasSuffix(request.URL.Path, "/") && serve(name) { + return + } + // opts.single: fall back to index.html unless the request matches one + // of sirv's two default ignores. + if !looksLikeFile(cleaned) && !strings.Contains(cleaned, "/.well-known") { + if serve("index.html") { + return + } + } + notFound.ServeHTTP(writer, request) + }) +} + +// hasDotSegment reports whether any path segment starts with '.', reproducing +// totalist's filter `!opts.dotfiles && /(^\.|[\\+|\/+]\.)/.test(name)` together +// with the `/\.well-known[\\+\/]/` exemption that runs before it. +func hasDotSegment(name string) bool { + if strings.Contains(name, ".well-known/") { + return false + } + for _, segment := range strings.Split(name, "/") { + if strings.HasPrefix(segment, ".") { + return true + } + } + return false +} + +// looksLikeFile ports sirv's default "any extn" ignore, +// /[/]([A-Za-z\s\d~$._-]+\.\w+){1,}$/. Because the character class excludes +// "/" and the match is anchored at the end, only the last path segment can +// match: it must contain a dot with a non-empty [A-Za-z0-9_] extension after +// it, and everything before that dot must be in the class. The class is +// ASCII-only, so "/文件.js" and "/café.css" are NOT ignored and do get the +// index.html fallback. +func looksLikeFile(pathname string) bool { + slash := strings.LastIndexByte(pathname, '/') + if slash < 0 { + return false + } + segment := pathname[slash+1:] + dot := strings.LastIndexByte(segment, '.') + if dot <= 0 || dot == len(segment)-1 { + return false + } + for _, character := range segment[:dot] { // [A-Za-z\s\d~$._-] + switch { + case character >= 'A' && character <= 'Z', + character >= 'a' && character <= 'z', + character >= '0' && character <= '9', + character == '~', character == '$', character == '.', + character == '_', character == '-', + // URL paths are percent-decoded before this test, so a non-ASCII + // space can genuinely reach it; protocol owns the JavaScript + // whitespace class. + protocol.IsJSWhitespace(character): + default: + return false + } + } + for _, character := range segment[dot+1:] { // \w+ + switch { + case character >= 'A' && character <= 'Z', + character >= 'a' && character <= 'z', + character >= '0' && character <= '9', + character == '_': + default: + return false + } + } + return true +} diff --git a/internal/server/app/static_test.go b/internal/server/app/static_test.go new file mode 100644 index 0000000..f769732 --- /dev/null +++ b/internal/server/app/static_test.go @@ -0,0 +1,227 @@ +package app + +import ( + "bytes" + "io" + "io/fs" + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" +) + +const staticNotFoundBody = `{"error":"Not found"}` + +// staticNotFound is app.ts's `sendJson(response, 404, { error: "Not found" })`, +// the next() callback sirv fell through to. +var staticNotFound = http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Type", "application/json; charset=utf-8") + writer.WriteHeader(http.StatusNotFound) + io.WriteString(writer, staticNotFoundBody) +}) + +// staticFixture mirrors the tree evidence/runtime.md served through real +// sirv@3.0.2, byte lengths included, so the parity table below is comparable. +func staticFixture() fs.FS { + filled := func(size int) *fstest.MapFile { + return &fstest.MapFile{Data: bytes.Repeat([]byte("x"), size)} + } + return fstest.MapFS{ + "index.html": filled(100), + "assets/app.js": filled(20), + "assets/app.css": filled(15), + "third-party-licenses.txt": filled(21), + "favicon.svg": filled(41), + "fonts/inter.woff2": filled(300), + "manifest.webmanifest": filled(19), + "data.json": filled(7), + "nomime.bin": filled(64), + "noext": filled(23), + ".gitkeep": filled(0), + ".vite/manifest.json": filled(7), + ".well-known/x.txt": filled(10), + } +} + +// TestStaticHandlerMatchesSirv replays the 26-row parity table measured against +// sirv@3.0.2 in evidence/runtime.md section 4.4. +func TestStaticHandlerMatchesSirv(t *testing.T) { + handler := staticHandler(staticFixture(), staticNotFound) + + const html = "text/html;charset=utf-8" + for _, testCase := range []struct { + method string + path string + headerName string + headerValue string + status int + contentType string + contentLength string + contentRange string + bodyLength int + }{ + {method: "GET", path: "/", status: 200, contentType: html, contentLength: "100", bodyLength: 100}, + {method: "GET", path: "/index.html", status: 200, contentType: html, contentLength: "100", bodyLength: 100}, + {method: "GET", path: "/host", status: 200, contentType: html, contentLength: "100", bodyLength: 100}, + {method: "GET", path: "/room/1234", status: 200, contentType: html, contentLength: "100", bodyLength: 100}, + {method: "GET", path: "/assets/app.js", status: 200, contentType: "text/javascript", contentLength: "20", bodyLength: 20}, + {method: "GET", path: "/assets/app.css", status: 200, contentType: "text/css", contentLength: "15", bodyLength: 15}, + {method: "GET", path: "/third-party-licenses.txt", status: 200, contentType: "text/plain", contentLength: "21", bodyLength: 21}, + {method: "GET", path: "/favicon.svg", status: 200, contentType: "image/svg+xml", contentLength: "41", bodyLength: 41}, + {method: "GET", path: "/fonts/inter.woff2", status: 200, contentType: "font/woff2", contentLength: "300", bodyLength: 300}, + {method: "GET", path: "/manifest.webmanifest", status: 200, contentType: "application/manifest+json", contentLength: "19", bodyLength: 19}, + {method: "GET", path: "/data.json", status: 200, contentType: "application/json", contentLength: "7", bodyLength: 7}, + {method: "GET", path: "/nomime.bin", status: 200, contentType: "application/octet-stream", contentLength: "64", bodyLength: 64}, + // mrmime knows no extension here, so sirv sends an empty Content-Type. + {method: "GET", path: "/noext", status: 200, contentType: "", contentLength: "23", bodyLength: 23}, + {method: "GET", path: "/assets/missing.js", status: 404, contentType: "application/json; charset=utf-8", bodyLength: len(staticNotFoundBody)}, + {method: "GET", path: "/missing.css", status: 404, contentType: "application/json; charset=utf-8", bodyLength: len(staticNotFoundBody)}, + {method: "GET", path: "/.well-known/x.txt", status: 200, contentType: "text/plain", contentLength: "10", bodyLength: 10}, + // The dotfile is not in sirv's FILES and does not match the "any extn" + // ignore, so it falls back to index.html. + {method: "GET", path: "/.gitkeep", status: 200, contentType: html, contentLength: "100", bodyLength: 100}, + {method: "GET", path: "/.vite/manifest.json", status: 404, contentType: "application/json; charset=utf-8", bodyLength: len(staticNotFoundBody)}, + {method: "GET", path: "/assets", status: 200, contentType: html, contentLength: "100", bodyLength: 100}, + {method: "GET", path: "/assets/", status: 200, contentType: html, contentLength: "100", bodyLength: 100}, + {method: "HEAD", path: "/index.html", status: 200, contentType: html, contentLength: "100", bodyLength: 0}, + { + method: "GET", path: "/index.html", headerName: "Range", headerValue: "bytes=0-9", + status: 206, contentType: html, contentLength: "10", contentRange: "bytes 0-9/100", bodyLength: 10, + }, + { + method: "GET", path: "/index.html", headerName: "Accept", headerValue: "application/json", + status: 200, contentType: html, contentLength: "100", bodyLength: 100, + }, + {method: "GET", path: "/../go.mod", status: 404, contentType: "application/json; charset=utf-8", bodyLength: len(staticNotFoundBody)}, + // sirv ignores req.method entirely; app.ts routes /api/** first. + {method: "POST", path: "/", status: 200, contentType: html, contentLength: "100", bodyLength: 100}, + // The "any extn" class is ASCII-only, so this is not ignored. + {method: "GET", path: "/文件.js", status: 200, contentType: html, contentLength: "100", bodyLength: 100}, + } { + request := httptest.NewRequest(testCase.method, testCase.path, nil) + if testCase.headerName != "" { + request.Header.Set(testCase.headerName, testCase.headerValue) + } + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + + label := testCase.method + " " + testCase.path + if recorder.Code != testCase.status { + t.Errorf("%s: status = %d, want %d", label, recorder.Code, testCase.status) + } + contentType, present := recorder.Header()["Content-Type"] + if !present { + t.Errorf("%s: Content-Type header is absent", label) + } else if len(contentType) != 1 || contentType[0] != testCase.contentType { + t.Errorf("%s: Content-Type = %q, want [%q]", label, contentType, testCase.contentType) + } + if got := recorder.Header().Get("Content-Length"); testCase.contentLength != "" && got != testCase.contentLength { + t.Errorf("%s: Content-Length = %q, want %q", label, got, testCase.contentLength) + } + if got := recorder.Header().Get("Content-Range"); got != testCase.contentRange { + t.Errorf("%s: Content-Range = %q, want %q", label, got, testCase.contentRange) + } + if got := recorder.Body.Len(); got != testCase.bodyLength { + t.Errorf("%s: body length = %d, want %d", label, got, testCase.bodyLength) + } + // sirv never emits either, and a fabricated Last-Modified would add + // 304 responses Node never sent. + if got := recorder.Header().Get("Last-Modified"); got != "" { + t.Errorf("%s: unexpected Last-Modified %q", label, got) + } + if got := recorder.Header().Get("Cache-Control"); got != "" { + t.Errorf("%s: unexpected Cache-Control %q", label, got) + } + if got := recorder.Header().Get("Etag"); got != "" { + t.Errorf("%s: unexpected ETag %q", label, got) + } + // app.ts setHeaders runs for served files only, not for the JSON 404. + wantSecurityHeaders := testCase.status != http.StatusNotFound + for header, want := range map[string]string{ + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + } { + got := recorder.Header().Get(header) + if wantSecurityHeaders && got != want { + t.Errorf("%s: %s = %q, want %q", label, header, got, want) + } + if !wantSecurityHeaders && got != "" { + t.Errorf("%s: unexpected %s %q on the 404", label, header, got) + } + } + } +} + +func TestStaticHandlerRefusesTraversal(t *testing.T) { + handler := staticHandler(staticFixture(), staticNotFound) + + for _, target := range []string{ + "/../go.mod", "/../../go.mod", "/assets/../../go.mod", + "/..%2fgo.mod", "/./../package.json", "//../go.mod", + } { + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest("GET", target, nil)) + if recorder.Code != http.StatusNotFound { + t.Errorf("GET %s: status = %d, want 404", target, recorder.Code) + } + } +} + +func TestStaticHandlerWithoutIndexHTML(t *testing.T) { + // The former frontend mode "none": nothing can be served, everything 404s. + handler := staticHandler(fstest.MapFS{".gitkeep": &fstest.MapFile{}}, staticNotFound) + + for _, target := range []string{"/", "/host", "/assets/app.js", "/.gitkeep"} { + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest("GET", target, nil)) + if recorder.Code != http.StatusNotFound { + t.Errorf("GET %s: status = %d, want 404", target, recorder.Code) + } + } +} + +// TestLooksLikeFileMatchesSirvIgnore replays the Node oracle for sirv's +// "any extn" ignore recorded in evidence/runtime.md section 4.1. +func TestLooksLikeFileMatchesSirvIgnore(t *testing.T) { + for pathname, want := range map[string]bool{ + "/": false, + "/host": false, + "/room/1234": false, + "/assets": false, + "/assets/": false, + "/index.html": true, + "/assets/index-abc123.js": true, + "/a.b.c.js": true, + "/favicon.ico": true, + "/my file.txt": true, + "/.gitkeep": false, + "/x.": false, + "/.env": false, + "/a.b-c": false, + "/文件.js": false, + "/café.css": false, + "/v1.2/thing": false, + } { + if got := looksLikeFile(pathname); got != want { + t.Errorf("looksLikeFile(%q) = %v, want %v", pathname, got, want) + } + } +} + +func TestHasDotSegment(t *testing.T) { + for name, want := range map[string]bool{ + "index.html": false, + "assets/app.js": false, + ".gitkeep": true, + ".vite/manifest.json": true, + "assets/.hidden": true, + ".well-known/x.txt": false, + "deep/.well-known/x.txt": false, + "well-known/x.txt": false, + ".well-knownish/file.txt": true, + } { + if got := hasDotSegment(name); got != want { + t.Errorf("hasDotSegment(%q) = %v, want %v", name, got, want) + } + } +} diff --git a/internal/server/config/config.go b/internal/server/config/config.go new file mode 100644 index 0000000..8f38a2c --- /dev/null +++ b/internal/server/config/config.go @@ -0,0 +1,538 @@ +// Package config ports src/server/config.ts, src/server/local-config.ts and +// src/server/ice.ts: the Hosted environment contract, the Client's local +// composition, and the ICE configuration derived from either. It performs no +// I/O; the caller supplies the environment as a map. +package config + +import ( + "errors" + "fmt" + "math" + "net/url" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/TNTcraftHIM/Screener/internal/server/protocol" +) + +// Environment ports RuntimeEnvironment. DECISIONS D8 renames NODE_ENV to +// SCREENER_ENV and drops "test". +type Environment string + +// The two accepted SCREENER_ENV values. +const ( + EnvironmentDevelopment Environment = "development" + EnvironmentProduction Environment = "production" +) + +// Bounds and defaults from src/server/config.ts. +const ( + minSiteAccessPasswordBytes = 8 + maxSiteAccessPasswordBytes = 128 + minLiveKitAPISecretBytes = 32 + defaultMaxViewersPerRoom = 8 + defaultRoomLeaseSeconds = 86_400 + defaultPort = 8787 + maxPort = 65_535 +) + +// VISIBLE_ASCII_PATTERN. +var visibleASCIIPattern = regexp.MustCompile(`^[\x21-\x7e]+$`) + +const removedTurnReason = "ordinary ICE accepts STUN_URLS only" + +// removedEnvironmentVariables ports REMOVED_ENVIRONMENT_VARIABLES together with +// the two names the TypeScript checked separately, and NODE_ENV (DECISIONS D8: +// a stale unit file must not fail open). An empty reason takes the TURN default. +// The order is the report order when several removed names are present. +var removedEnvironmentVariables = []struct{ name, reason string }{ + {"TURN_URLS", ""}, + {"TURN_SHARED_SECRET", ""}, + {"TURN_CREDENTIAL_TTL_SECONDS", ""}, + {"PEER_ICE_TURN_URLS", ""}, + {"PEER_ICE_TURN_SHARED_SECRET", ""}, + {"PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS", ""}, + {"SELECTED_EDGE_TURN_URLS", ""}, + {"SELECTED_EDGE_TURN_SHARED_SECRET", ""}, + {"SELECTED_EDGE_TURN_CREDENTIAL_TTL_SECONDS", ""}, + {"SELECTED_EDGE_TURN_ALLOCATION_CAPACITY", ""}, + {"PEER_ASSISTED_ROOM_IDS", "peer-assisted media applies to every room"}, + {"PEER_ASSISTED_MEDIA", "peer-assisted media is always enabled"}, + {"HOST_ADMISSION_PASSWORD", "use SITE_ACCESS_PASSWORD"}, + {"MAX_PEER_RELAY_DOWNSTREAM_EDGES", "use ENDPOINT_MEDIA_COPY_CAPACITY"}, + {"ROOM_TTL_SECONDS", "use ROOM_LEASE_SECONDS"}, + {"ACCESS_PASSWORD", "use SITE_ACCESS_PASSWORD"}, + {"NODE_ENV", "use SCREENER_ENV"}, +} + +// LiveKitFallback ports LiveKitFallbackConfig. URL and APIURL are serialised +// WHATWG origins. +type LiveKitFallback struct { + URL string + APIURL string + APIKey string + APISecret string +} + +// Config ports ServerConfig. Fields the TypeScript left `undefined` are the +// zero value here: an empty SiteAccessPassword means site access is open, an +// empty RoomDatabasePath means memory mode, and a nil LiveKit means no SFU +// fallback. +type Config struct { + Env Environment + Port int + ListenHost string + PublicBaseURL *url.URL + // AllowedOrigins is a membership test only; Go map order is not observable + // here (src/server/app.ts only ever calls `allowedOrigins.has(origin)`). + AllowedOrigins map[string]struct{} + SiteAccessPassword string + RoomDatabasePath string + RoomLeaseMs int64 + MaxViewersPerRoom int + EndpointMediaCopyCapacity int + LiveKit *LiveKitFallback + STUNURLs []string + NATPredictionEnabled bool + // NATPredictionSTUNURLs is nil where the TypeScript left it `undefined`, + // which IceConfig distinguishes from a configured empty list. + NATPredictionSTUNURLs []string +} + +// Load ports loadConfig. env is the whole process environment: cmd passes +// os.Environ() as a map, tests pass a literal. A key present with an empty +// value is `""` in TypeScript too, so presence is the only distinction that +// needs the two-value lookup. +func Load(env map[string]string) (Config, error) { + for _, removed := range removedEnvironmentVariables { + if _, present := env[removed.name]; present { + reason := removed.reason + if reason == "" { + reason = removedTurnReason + } + return Config{}, fmt.Errorf("%s is no longer supported; %s", removed.name, reason) + } + } + + environment, err := parseEnvironment(env) + if err != nil { + return Config{}, err + } + port, err := parsePositiveInteger(env["PORT"], defaultPort, "PORT") + if err != nil { + return Config{}, err + } + if port > maxPort { + return Config{}, fmt.Errorf("PORT must be at most %d", maxPort) + } + listenHost := strings.TrimFunc(env["LISTEN_HOST"], protocol.IsJSWhitespace) + if listenHost == "" { + listenHost = "0.0.0.0" + } + + // TS: environment.PUBLIC_BASE_URL ?? `http://localhost:${port}` — only an + // absent variable takes the default. + rawPublicBaseURL, present := env["PUBLIC_BASE_URL"] + if !present { + rawPublicBaseURL = fmt.Sprintf("http://localhost:%d", port) + } + publicBaseURL, err := parseOriginURL(rawPublicBaseURL, "PUBLIC_BASE_URL", "http", "https") + if err != nil { + return Config{}, err + } + if environment == EnvironmentProduction && publicBaseURL.Scheme != "https" { + return Config{}, errors.New("PUBLIC_BASE_URL must use https in production") + } + + siteAccessPassword := env["SITE_ACCESS_PASSWORD"] + roomDatabasePath, err := parseRoomDatabasePath(env["ROOM_DATABASE_PATH"]) + if err != nil { + return Config{}, err + } + stunURLs, err := parseStunURLList(env["STUN_URLS"], "STUN_URLS") + if err != nil { + return Config{}, err + } + natPredictionEnabled, err := parseBoolean(env["NAT_PREDICTION_ENABLED"], "NAT_PREDICTION_ENABLED") + if err != nil { + return Config{}, err + } + if natPredictionEnabled && len(NATPredictionStunURLs(stunURLs)) == 0 { + return Config{}, errors.New("NAT_PREDICTION_ENABLED requires a STUN_URLS entry on UDP 3478") + } + if natPredictionEnabled && + len(stunURLs)+protocol.MaxNatPredictionAuxiliaryStunURLs > protocol.MaxIceServerURLs { + return Config{}, fmt.Errorf( + "STUN_URLS must contain at most %d URLs when NAT_PREDICTION_ENABLED=true", + protocol.MaxIceServerURLs-protocol.MaxNatPredictionAuxiliaryStunURLs) + } + maxViewersPerRoom, err := parseBoundedInteger(env["MAX_VIEWERS_PER_ROOM"], + defaultMaxViewersPerRoom, "MAX_VIEWERS_PER_ROOM", 1, protocol.MaxViewersPerRoomLimit) + if err != nil { + return Config{}, err + } + endpointMediaCopyCapacity, err := parseBoundedInteger(env["ENDPOINT_MEDIA_COPY_CAPACITY"], + protocol.DefaultEndpointMediaCopyCapacity, "ENDPOINT_MEDIA_COPY_CAPACITY", + 1, protocol.MaxEndpointMediaCopyCapacity) + if err != nil { + return Config{}, err + } + liveKit, err := parseLiveKitFallback(env, environment) + if err != nil { + return Config{}, err + } + + secrets := make([]string, 0, 3) + if siteAccessPassword != "" { + secrets = append(secrets, siteAccessPassword) + } + if liveKit != nil { + secrets = append(secrets, liveKit.APIKey, liveKit.APISecret) + } + distinct := make(map[string]struct{}, len(secrets)) + for _, secret := range secrets { + distinct[secret] = struct{}{} + } + if len(distinct) != len(secrets) { + return Config{}, errors.New( + "SITE_ACCESS_PASSWORD, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must use independent values") + } + // Buffer.byteLength is the UTF-8 length, which is len() in Go. The pattern + // already restricts the value to one byte per character. + if siteAccessPassword != "" && + (!visibleASCIIPattern.MatchString(siteAccessPassword) || + len(siteAccessPassword) < minSiteAccessPasswordBytes || + len(siteAccessPassword) > maxSiteAccessPasswordBytes) { + return Config{}, fmt.Errorf("SITE_ACCESS_PASSWORD must contain %d to %d visible ASCII bytes", + minSiteAccessPasswordBytes, maxSiteAccessPasswordBytes) + } + if environment == EnvironmentProduction && siteAccessPassword == "" { + return Config{}, errors.New("SITE_ACCESS_PASSWORD is required in production") + } + if environment == EnvironmentProduction && len(stunURLs) == 0 { + return Config{}, errors.New("STUN is required in production") + } + + // ALLOWED_ORIGINS and ROOM_LEASE_SECONDS are validated inside the returned + // object literal, i.e. after every check above; keep that order. + allowedOrigins, err := parseOrigins(env["ALLOWED_ORIGINS"], Origin(publicBaseURL)) + if err != nil { + return Config{}, err + } + roomLeaseSeconds, err := parsePositiveInteger(env["ROOM_LEASE_SECONDS"], + defaultRoomLeaseSeconds, "ROOM_LEASE_SECONDS") + if err != nil { + return Config{}, err + } + + return Config{ + Env: environment, + Port: int(port), + ListenHost: listenHost, + PublicBaseURL: publicBaseURL, + AllowedOrigins: allowedOrigins, + SiteAccessPassword: siteAccessPassword, + RoomDatabasePath: roomDatabasePath, + RoomLeaseMs: roomLeaseSeconds * 1_000, + MaxViewersPerRoom: int(maxViewersPerRoom), + EndpointMediaCopyCapacity: int(endpointMediaCopyCapacity), + LiveKit: liveKit, + STUNURLs: stunURLs, + NATPredictionEnabled: natPredictionEnabled, + }, nil +} + +// parseEnvironment ports parseEnvironment for SCREENER_ENV. +func parseEnvironment(env map[string]string) (Environment, error) { + // TS: value ?? "development" — a present empty value is not nullish and so + // reaches the check below. + value, present := env["SCREENER_ENV"] + if !present { + return EnvironmentDevelopment, nil + } + switch Environment(value) { + case EnvironmentDevelopment, EnvironmentProduction: + return Environment(value), nil + } + return "", errors.New("SCREENER_ENV must be development or production") +} + +// parseBoolean ports parseBoolean; every caller used a false fallback. +func parseBoolean(value string, name string) (bool, error) { + switch value { + case "", "false": + return false, nil + case "true": + return true, nil + } + return false, fmt.Errorf("%s must be true or false", name) +} + +// parsePositiveInteger ports parsePositiveInteger, including Number.isSafeInteger. +func parsePositiveInteger(value string, fallback int64, name string) (int64, error) { + if value == "" { + return fallback, nil + } + number, ok := jsNumber(value) + if !ok || number != math.Trunc(number) || number <= 0 || number > protocol.MaxSafeInteger { + return 0, fmt.Errorf("%s must be a positive integer", name) + } + return int64(number), nil +} + +// parseBoundedInteger ports parseBoundedInteger. +func parseBoundedInteger(value string, fallback int64, name string, minimum, maximum int64) (int64, error) { + parsed, err := parsePositiveInteger(value, fallback, name) + if err != nil { + return 0, err + } + if parsed < minimum || parsed > maximum { + return 0, fmt.Errorf("%s must be between %d and %d", name, minimum, maximum) + } + return parsed, nil +} + +// parseRoomDatabasePath ports parseRoomDatabasePath. DECISIONS D8: absoluteness +// is filepath.IsAbs, which differs from Node's path.isAbsolute on Windows, +// where "/var/lib/screener/rooms.sqlite" is relative to the current volume and +// is therefore rejected here. Hosted runs on Linux. +func parseRoomDatabasePath(value string) (string, error) { + path := strings.TrimFunc(value, protocol.IsJSWhitespace) + if path == "" { + return "", nil + } + if path == ":memory:" || strings.ContainsRune(path, 0) || !filepath.IsAbs(path) { + return "", errors.New("ROOM_DATABASE_PATH must be an absolute file path") + } + return path, nil +} + +// parseLiveKitFallback ports parseLiveKitFallback. +func parseLiveKitFallback(env map[string]string, environment Environment) (*LiveKitFallback, error) { + rawURL := strings.TrimFunc(env["LIVEKIT_URL"], protocol.IsJSWhitespace) + rawAPIURL := strings.TrimFunc(env["LIVEKIT_API_URL"], protocol.IsJSWhitespace) + apiKey := strings.TrimFunc(env["LIVEKIT_API_KEY"], protocol.IsJSWhitespace) + apiSecret := strings.TrimFunc(env["LIVEKIT_API_SECRET"], protocol.IsJSWhitespace) + + configured := 0 + for _, value := range []string{rawURL, rawAPIURL, apiKey, apiSecret} { + if value != "" { + configured++ + } + } + if configured == 0 { + return nil, nil + } + if configured != 4 { + return nil, errors.New( + "LIVEKIT_URL, LIVEKIT_API_URL, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must be configured together") + } + if len(apiSecret) < minLiveKitAPISecretBytes { + return nil, fmt.Errorf("LIVEKIT_API_SECRET must contain at least %d bytes", minLiveKitAPISecretBytes) + } + + parsedURL, err := parseOriginURL(rawURL, "LIVEKIT_URL", "ws", "wss") + if err != nil { + return nil, err + } + if environment == EnvironmentProduction && parsedURL.Scheme != "wss" { + return nil, errors.New("LIVEKIT_URL must use wss in production") + } + parsedAPIURL, err := parseOriginURL(rawAPIURL, "LIVEKIT_API_URL", "http", "https") + if err != nil { + return nil, err + } + if environment == EnvironmentProduction && parsedAPIURL.Scheme != "https" && + !isLoopbackHostname(parsedAPIURL.Hostname()) { + return nil, errors.New("LIVEKIT_API_URL must use https or loopback in production") + } + return &LiveKitFallback{ + URL: Origin(parsedURL), + APIURL: Origin(parsedAPIURL), + APIKey: apiKey, + APISecret: apiSecret, + }, nil +} + +// isLoopbackHostname ports isLoopbackHostname. url.URL.Hostname strips the IPv6 +// brackets, so only the unbracketed "::1" can actually match here. +func isLoopbackHostname(hostname string) bool { + return hostname == "localhost" || hostname == "127.0.0.1" || + hostname == "[::1]" || hostname == "::1" +} + +// parseURLList ports parseUrlList. +func parseURLList(value string, name string) ([]string, error) { + if strings.TrimFunc(value, protocol.IsJSWhitespace) == "" { + return nil, nil + } + entries := strings.Split(value, ",") + list := make([]string, 0, len(entries)) + for _, entry := range entries { + trimmed := strings.TrimFunc(entry, protocol.IsJSWhitespace) + if trimmed == "" { + return nil, fmt.Errorf("%s contains an empty URL", name) + } + list = append(list, trimmed) + } + return list, nil +} + +// parseStunURLList ports parseStunUrlList. The TypeScript `maximum` parameter +// was only ever the default. +func parseStunURLList(value string, name string) ([]string, error) { + values, err := parseURLList(value, name) + if err != nil { + return nil, err + } + if len(values) > protocol.MaxIceServerURLs { + return nil, fmt.Errorf("%s must contain at most %d URLs", name, protocol.MaxIceServerURLs) + } + for _, value := range values { + if !protocol.ValidStunURLValue(value) { + return nil, fmt.Errorf("%s contains an invalid STUN URL", name) + } + } + return values, nil +} + +// parseOrigins ports parseOrigins. +func parseOrigins(value string, fallback string) (map[string]struct{}, error) { + origins, err := parseURLList(value, "ALLOWED_ORIGINS") + if err != nil { + return nil, err + } + if len(origins) == 0 { + origins = []string{fallback} + } + set := make(map[string]struct{}, len(origins)) + for _, entry := range origins { + origin, err := toOrigin(entry) + if err != nil { + return nil, err + } + set[origin] = struct{}{} + } + return set, nil +} + +// toOrigin ports toOrigin. The TypeScript let an unparsable entry escape as a +// raw TypeError from `new URL`; it fails startup here with a message instead. +func toOrigin(value string) (string, error) { + parsed, err := url.Parse(value) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "", errors.New("Allowed origins must be valid http or https URLs") + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", errors.New("Allowed origins must use http or https") + } + return Origin(parsed), nil +} + +// parseOriginURL ports the `new URL` + protocol + shape ladder that +// PUBLIC_BASE_URL, LIVEKIT_URL and LIVEKIT_API_URL each repeat, and returns the +// WHATWG-normalised URL (lowercase host, default port removed, path "/"). +func parseOriginURL(value, name, scheme, alternative string) (*url.URL, error) { + parsed, err := url.Parse(value) + // net/url accepts relative references and opaque URLs that the WHATWG + // parser rejects (or, for "wss:host", repairs); require both parts. + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return nil, fmt.Errorf("%s must be a valid %s or %s origin", name, scheme, alternative) + } + if parsed.Scheme != scheme && parsed.Scheme != alternative { + return nil, fmt.Errorf("%s must use %s or %s", name, scheme, alternative) + } + // TS: url.username || url.password || url.pathname !== "/" || url.search || + // url.hash. WHATWG reports "/" for an empty path, which net/url leaves "". + if hasUserinfo(parsed) || (parsed.Path != "" && parsed.Path != "/") || + parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, fmt.Errorf( + "%s must be an origin without credentials, path, query, or fragment", name) + } + return &url.URL{Scheme: parsed.Scheme, Host: normalizedHost(parsed), Path: "/"}, nil +} + +// hasUserinfo ports `url.username || url.password`: WHATWG reports empty +// strings for "https://@host" and "https://:@host", where net/url reports a +// non-nil (but empty) Userinfo. +func hasUserinfo(parsed *url.URL) bool { + if parsed.User == nil { + return false + } + password, _ := parsed.User.Password() + return parsed.User.Username() != "" || password != "" +} + +// Origin ports the WHATWG URL `origin` getter for the http/https/ws/wss schemes +// this package accepts: new URL("https://Example.com:443/").origin is +// "https://example.com". It is exported so app and cmd can compare request +// origins without a second copy of the rule. +func Origin(parsed *url.URL) string { + return parsed.Scheme + "://" + normalizedHost(parsed) +} + +// normalizedHost lowercases the host, keeps an IPv6 literal bracketed and drops +// a port equal to the scheme default. Non-ASCII hosts are not IDNA-encoded, so +// they keep the form they were configured with. +func normalizedHost(parsed *url.URL) string { + host := strings.ToLower(parsed.Hostname()) + if strings.Contains(host, ":") { + host = "[" + host + "]" + } + // WHATWG parses the port as a number, so "0443" and "443" are both default. + if number, err := strconv.Atoi(parsed.Port()); err == nil { + if port := strconv.Itoa(number); port != defaultSchemePort(parsed.Scheme) { + host += ":" + port + } + } + return host +} + +func defaultSchemePort(scheme string) string { + switch scheme { + case "http", "ws": + return "80" + case "https", "wss": + return "443" + } + return "" +} + +// decimalNumberPattern is the StrDecimalLiteral grammar Number(string) accepts. +var decimalNumberPattern = regexp.MustCompile( + `^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$`) + +// jsNumber ports Number(string). The second result is false where JavaScript +// yields NaN; values JavaScript renders as Infinity are reported as NaN too, +// because every caller rejects them through Number.isSafeInteger anyway. +func jsNumber(value string) (float64, bool) { + text := strings.TrimFunc(value, protocol.IsJSWhitespace) + if text == "" { + return 0, true // Number("") and Number(" ") are 0. + } + if len(text) > 2 && text[0] == '0' { + if base := numericBase(text[1]); base != 0 { + number, err := strconv.ParseUint(text[2:], base, 64) + return float64(number), err == nil + } + } + if !decimalNumberPattern.MatchString(text) { + return 0, false + } + number, err := strconv.ParseFloat(text, 64) + return number, err == nil +} + +func numericBase(marker byte) int { + switch marker { + case 'x', 'X': + return 16 + case 'o', 'O': + return 8 + case 'b', 'B': + return 2 + } + return 0 +} diff --git a/internal/server/config/config_test.go b/internal/server/config/config_test.go new file mode 100644 index 0000000..ee6ebf1 --- /dev/null +++ b/internal/server/config/config_test.go @@ -0,0 +1,558 @@ +package config + +import ( + "fmt" + "maps" + "path/filepath" + "reflect" + "slices" + "strconv" + "strings" + "testing" + + "github.com/TNTcraftHIM/Screener/internal/server/protocol" +) + +// Ported from tests/server-config.test.ts. + +// env merges the given maps, later keys winning, so a case can extend a base. +func env(parts ...map[string]string) map[string]string { + merged := map[string]string{} + for _, part := range parts { + maps.Copy(merged, part) + } + return merged +} + +var ( + liveKitAdmission = map[string]string{"LIVEKIT_API_URL": "https://livekit-api.test"} + liveKitSecret = strings.Repeat("s", 32) + productionBase = map[string]string{ + "SCREENER_ENV": "production", + "PUBLIC_BASE_URL": "https://share.test", + "SITE_ACCESS_PASSWORD": "host-password-12", + "STUN_URLS": "stun:stun.test:3478", + } +) + +func stunList(count int, prefix string) string { + urls := make([]string, 0, count) + for index := range count { + urls = append(urls, fmt.Sprintf("stun:%s-%d.test:3478", prefix, index)) + } + return strings.Join(urls, ",") +} + +func mustLoad(t *testing.T, environment map[string]string) Config { + t.Helper() + config, err := Load(environment) + if err != nil { + t.Fatalf("Load: unexpected error %v", err) + } + return config +} + +func TestLoadDevelopmentDefaults(t *testing.T) { + config := mustLoad(t, map[string]string{"SCREENER_ENV": "development", "PORT": "9123"}) + + if config.Env != EnvironmentDevelopment { + t.Errorf("Env = %q", config.Env) + } + if config.ListenHost != "0.0.0.0" { + t.Errorf("ListenHost = %q", config.ListenHost) + } + if got := config.PublicBaseURL.String(); got != "http://localhost:9123/" { + t.Errorf("PublicBaseURL = %q", got) + } + want := map[string]struct{}{"http://localhost:9123": {}} + if !reflect.DeepEqual(config.AllowedOrigins, want) { + t.Errorf("AllowedOrigins = %v", config.AllowedOrigins) + } + if config.RoomDatabasePath != "" { + t.Errorf("RoomDatabasePath = %q", config.RoomDatabasePath) + } + if len(config.STUNURLs) != 0 { + t.Errorf("STUNURLs = %v", config.STUNURLs) + } + if config.NATPredictionEnabled { + t.Error("NATPredictionEnabled = true") + } + if config.NATPredictionSTUNURLs != nil { + t.Errorf("NATPredictionSTUNURLs = %v, want nil (TS left it undefined)", config.NATPredictionSTUNURLs) + } + if config.MaxViewersPerRoom != 8 { + t.Errorf("MaxViewersPerRoom = %d", config.MaxViewersPerRoom) + } + if config.EndpointMediaCopyCapacity != 2 { + t.Errorf("EndpointMediaCopyCapacity = %d", config.EndpointMediaCopyCapacity) + } + if config.LiveKit != nil { + t.Errorf("LiveKit = %+v", config.LiveKit) + } + if config.RoomLeaseMs != 86_400_000 { + t.Errorf("RoomLeaseMs = %d", config.RoomLeaseMs) + } + if config.SiteAccessPassword != "" { + t.Error("SiteAccessPassword is set") + } +} + +func TestLoadLiveKitTuple(t *testing.T) { + config := mustLoad(t, map[string]string{ + "LIVEKIT_URL": " ws://livekit.test:7880 ", + "LIVEKIT_API_URL": " http://livekit.test:7880 ", + "LIVEKIT_API_KEY": " test-key ", + "LIVEKIT_API_SECRET": " " + liveKitSecret + " ", + }) + want := LiveKitFallback{ + URL: "ws://livekit.test:7880", + APIURL: "http://livekit.test:7880", + APIKey: "test-key", + APISecret: liveKitSecret, + } + if config.LiveKit == nil || *config.LiveKit != want { + t.Errorf("LiveKit = %+v, want %+v", config.LiveKit, want) + } +} + +func TestLoadAccepts(t *testing.T) { + developmentPath, err := filepath.Abs(filepath.Join("state", "rooms.sqlite")) + if err != nil { + t.Fatalf("Abs: %v", err) + } + productionPath, err := filepath.Abs(filepath.Join("production-state", "rooms.sqlite")) + if err != nil { + t.Fatalf("Abs: %v", err) + } + + cases := []struct { + name string + env map[string]string + check func(*testing.T, Config) + }{ + {"default viewer limit", map[string]string{}, func(t *testing.T, c Config) { + if c.MaxViewersPerRoom != 8 { + t.Errorf("MaxViewersPerRoom = %d", c.MaxViewersPerRoom) + } + }}, + {"viewer limit lower boundary", map[string]string{"MAX_VIEWERS_PER_ROOM": "1"}, + func(t *testing.T, c Config) { + if c.MaxViewersPerRoom != 1 { + t.Errorf("MaxViewersPerRoom = %d", c.MaxViewersPerRoom) + } + }}, + {"viewer limit ceiling", + map[string]string{"MAX_VIEWERS_PER_ROOM": strconv.Itoa(protocol.MaxViewersPerRoomLimit)}, + func(t *testing.T, c Config) { + if c.MaxViewersPerRoom != protocol.MaxViewersPerRoomLimit { + t.Errorf("MaxViewersPerRoom = %d", c.MaxViewersPerRoom) + } + }}, + {"copy capacity 1", map[string]string{"ENDPOINT_MEDIA_COPY_CAPACITY": "1"}, + func(t *testing.T, c Config) { + if c.EndpointMediaCopyCapacity != 1 { + t.Errorf("EndpointMediaCopyCapacity = %d", c.EndpointMediaCopyCapacity) + } + }}, + {"copy capacity 2", map[string]string{"ENDPOINT_MEDIA_COPY_CAPACITY": "2"}, + func(t *testing.T, c Config) { + if c.EndpointMediaCopyCapacity != 2 { + t.Errorf("EndpointMediaCopyCapacity = %d", c.EndpointMediaCopyCapacity) + } + }}, + {"copy capacity 3", map[string]string{"ENDPOINT_MEDIA_COPY_CAPACITY": "3"}, + func(t *testing.T, c Config) { + if c.EndpointMediaCopyCapacity != 3 { + t.Errorf("EndpointMediaCopyCapacity = %d", c.EndpointMediaCopyCapacity) + } + }}, + {"explicit loopback listen host", map[string]string{"LISTEN_HOST": " 127.0.0.1 "}, + func(t *testing.T, c Config) { + if c.ListenHost != "127.0.0.1" { + t.Errorf("ListenHost = %q", c.ListenHost) + } + }}, + {"production STUN", productionBase, func(t *testing.T, c Config) { + if !slices.Equal(c.STUNURLs, []string{"stun:stun.test:3478"}) { + t.Errorf("STUNURLs = %v", c.STUNURLs) + } + }}, + {"production site access password", + env(productionBase, map[string]string{"SITE_ACCESS_PASSWORD": "easy-key"}), + func(t *testing.T, c Config) { + if c.SiteAccessPassword != "easy-key" { + t.Errorf("SiteAccessPassword = %q", c.SiteAccessPassword) + } + }}, + {"STUN-only production with LiveKit", env(productionBase, liveKitAdmission, map[string]string{ + "LIVEKIT_URL": "wss://livekit.test", + "LIVEKIT_API_KEY": "test-key", + "LIVEKIT_API_SECRET": liveKitSecret, + }), func(t *testing.T, c Config) { + if c.LiveKit == nil || c.LiveKit.URL != "wss://livekit.test" { + t.Errorf("LiveKit = %+v", c.LiveKit) + } + }}, + {"production loopback LiveKit control", env(productionBase, map[string]string{ + "LIVEKIT_URL": "wss://livekit.test", + "LIVEKIT_API_URL": "http://127.0.0.1:7880", + "LIVEKIT_API_KEY": "test-key", + "LIVEKIT_API_SECRET": liveKitSecret, + }), func(t *testing.T, c Config) { + if c.LiveKit == nil || c.LiveKit.APIURL != "http://127.0.0.1:7880" { + t.Errorf("LiveKit = %+v", c.LiveKit) + } + }}, + {"uppercase STUN scheme and IPv6 host", map[string]string{"STUN_URLS": "STUN:[2001:db8::1]:3478"}, + func(t *testing.T, c Config) { + if !slices.Equal(c.STUNURLs, []string{"STUN:[2001:db8::1]:3478"}) { + t.Errorf("STUNURLs = %v", c.STUNURLs) + } + }}, + {"NAT prediction with a base listener", map[string]string{ + "STUN_URLS": "stun:share.test:3478", + "NAT_PREDICTION_ENABLED": "true", + }, func(t *testing.T, c Config) { + if !c.NATPredictionEnabled { + t.Error("NATPredictionEnabled = false") + } + }}, + {"room lease default", map[string]string{}, func(t *testing.T, c Config) { + if c.RoomLeaseMs != 86_400_000 { + t.Errorf("RoomLeaseMs = %d", c.RoomLeaseMs) + } + }}, + {"room lease override", map[string]string{"ROOM_LEASE_SECONDS": "3600"}, + func(t *testing.T, c Config) { + if c.RoomLeaseMs != 3_600_000 { + t.Errorf("RoomLeaseMs = %d", c.RoomLeaseMs) + } + }}, + {"file-backed room authority in development", + map[string]string{"ROOM_DATABASE_PATH": " " + developmentPath + " "}, + func(t *testing.T, c Config) { + if c.RoomDatabasePath != developmentPath { + t.Errorf("RoomDatabasePath = %q", c.RoomDatabasePath) + } + }}, + {"blank room database path", map[string]string{"ROOM_DATABASE_PATH": ""}, + func(t *testing.T, c Config) { + if c.RoomDatabasePath != "" { + t.Errorf("RoomDatabasePath = %q", c.RoomDatabasePath) + } + }}, + {"file-backed room authority in production", + env(productionBase, map[string]string{"ROOM_DATABASE_PATH": productionPath}), + func(t *testing.T, c Config) { + if c.RoomDatabasePath != productionPath { + t.Errorf("RoomDatabasePath = %q", c.RoomDatabasePath) + } + }}, + {"allowed origins are normalised", + map[string]string{"ALLOWED_ORIGINS": " https://Example.com:443/path , http://a.test:80 "}, + func(t *testing.T, c Config) { + want := map[string]struct{}{"https://example.com": {}, "http://a.test": {}} + if !reflect.DeepEqual(c.AllowedOrigins, want) { + t.Errorf("AllowedOrigins = %v", c.AllowedOrigins) + } + }}, + // Number(string) coercion, which the TS parsers inherited. + {"port with surrounding whitespace", map[string]string{"PORT": " 9123 "}, + func(t *testing.T, c Config) { + if c.Port != 9123 { + t.Errorf("Port = %d", c.Port) + } + }}, + {"hexadecimal port", map[string]string{"PORT": "0x1F5B"}, func(t *testing.T, c Config) { + if c.Port != 8027 { + t.Errorf("Port = %d", c.Port) + } + }}, + {"exponent room lease", map[string]string{"ROOM_LEASE_SECONDS": "1e3"}, + func(t *testing.T, c Config) { + if c.RoomLeaseMs != 1_000_000 { + t.Errorf("RoomLeaseMs = %d", c.RoomLeaseMs) + } + }}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + testCase.check(t, mustLoad(t, testCase.env)) + }) + } +} + +func TestLoadRejects(t *testing.T) { + cases := []struct { + name string + env map[string]string + want string + }{ + // Partial LiveKit credential tuples. + {"only LiveKit URL", map[string]string{"LIVEKIT_URL": "wss://livekit.test"}, + "LIVEKIT_URL, LIVEKIT_API_URL, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must be configured together"}, + {"only LiveKit control URL", liveKitAdmission, + "LIVEKIT_URL, LIVEKIT_API_URL, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must be configured together"}, + {"only LiveKit key", map[string]string{"LIVEKIT_API_KEY": "test-key"}, + "must be configured together"}, + {"only LiveKit secret", map[string]string{"LIVEKIT_API_SECRET": liveKitSecret}, + "must be configured together"}, + {"LiveKit without secret", env(liveKitAdmission, map[string]string{ + "LIVEKIT_URL": "wss://livekit.test", "LIVEKIT_API_KEY": "test-key", + }), "must be configured together"}, + + // Invalid LiveKit media origins. + {"LiveKit https scheme", liveKitURL("https://livekit.test"), "LIVEKIT_URL must use ws or wss"}, + {"LiveKit credentials", liveKitURL("wss://user:pass@livekit.test"), + "LIVEKIT_URL must be an origin without credentials, path, query, or fragment"}, + {"LiveKit path", liveKitURL("wss://livekit.test/rtc"), "LIVEKIT_URL must be an origin"}, + {"LiveKit query", liveKitURL("wss://livekit.test?token=value"), "LIVEKIT_URL must be an origin"}, + {"LiveKit fragment", liveKitURL("wss://livekit.test#fragment"), "LIVEKIT_URL must be an origin"}, + {"LiveKit not a URL", liveKitURL("livekit.test"), "LIVEKIT_URL must be a valid ws or wss origin"}, + {"LiveKit ws in production", env(productionBase, liveKitAdmission, map[string]string{ + "LIVEKIT_URL": "ws://livekit.test:7880", "LIVEKIT_API_KEY": "test-key", + "LIVEKIT_API_SECRET": liveKitSecret, + }), "LIVEKIT_URL must use wss in production"}, + + // Invalid LiveKit control origins. + {"control ws scheme", liveKitAPIURL("ws://livekit-api.test"), "LIVEKIT_API_URL must use http or https"}, + {"control credentials", liveKitAPIURL("http://user:pass@livekit-api.test"), + "LIVEKIT_API_URL must be an origin without credentials, path, query, or fragment"}, + {"control path", liveKitAPIURL("http://livekit-api.test/rtc"), "LIVEKIT_API_URL must be an origin"}, + {"control query", liveKitAPIURL("http://livekit-api.test?token=value"), "LIVEKIT_API_URL must be an origin"}, + {"control fragment", liveKitAPIURL("http://livekit-api.test#fragment"), "LIVEKIT_API_URL must be an origin"}, + {"control not a URL", liveKitAPIURL("livekit-api.test"), + "LIVEKIT_API_URL must be a valid http or https origin"}, + {"control plaintext in production", env(productionBase, map[string]string{ + "LIVEKIT_URL": "wss://livekit.test", "LIVEKIT_API_URL": "http://livekit-api.test:7880", + "LIVEKIT_API_KEY": "test-key", "LIVEKIT_API_SECRET": liveKitSecret, + }), "must use https or loopback"}, + {"short LiveKit secret", env(liveKitAdmission, map[string]string{ + "LIVEKIT_URL": "wss://livekit.test", "LIVEKIT_API_KEY": "test-key", + "LIVEKIT_API_SECRET": "too-short", + }), "LIVEKIT_API_SECRET must contain at least 32 bytes"}, + + // Reused infrastructure secrets. + {"site password reused as LiveKit secret", env(liveKitAdmission, map[string]string{ + "SITE_ACCESS_PASSWORD": strings.Repeat("x", 32), "LIVEKIT_URL": "wss://livekit.test", + "LIVEKIT_API_KEY": "test-key", "LIVEKIT_API_SECRET": strings.Repeat("x", 32), + }), "must use independent values"}, + {"LiveKit key reused as LiveKit secret", env(liveKitAdmission, map[string]string{ + "LIVEKIT_URL": "wss://livekit.test", "LIVEKIT_API_KEY": strings.Repeat("x", 32), + "LIVEKIT_API_SECRET": strings.Repeat("x", 32), + }), "must use independent values"}, + + // Bounded integers. + {"viewer limit above ceiling", + map[string]string{"MAX_VIEWERS_PER_ROOM": strconv.Itoa(protocol.MaxViewersPerRoomLimit + 1)}, + "MAX_VIEWERS_PER_ROOM must be between 1 and 20"}, + {"viewer limit zero", map[string]string{"MAX_VIEWERS_PER_ROOM": "0"}, + "MAX_VIEWERS_PER_ROOM must be a positive integer"}, + {"fractional viewer limit", map[string]string{"MAX_VIEWERS_PER_ROOM": "1.5"}, + "MAX_VIEWERS_PER_ROOM must be a positive integer"}, + {"copy capacity zero", map[string]string{"ENDPOINT_MEDIA_COPY_CAPACITY": "0"}, + "ENDPOINT_MEDIA_COPY_CAPACITY must be a positive integer"}, + {"copy capacity above ceiling", map[string]string{"ENDPOINT_MEDIA_COPY_CAPACITY": "4"}, + "ENDPOINT_MEDIA_COPY_CAPACITY must be between 1 and 3"}, + {"fractional copy capacity", map[string]string{"ENDPOINT_MEDIA_COPY_CAPACITY": "1.5"}, + "ENDPOINT_MEDIA_COPY_CAPACITY must be a positive integer"}, + {"zero room lease", map[string]string{"ROOM_LEASE_SECONDS": "0"}, + "ROOM_LEASE_SECONDS must be a positive integer"}, + {"port zero", map[string]string{"PORT": "0"}, "PORT must be a positive integer"}, + {"port not a number", map[string]string{"PORT": "eight"}, "PORT must be a positive integer"}, + {"port above the TCP ceiling", map[string]string{"PORT": "65536"}, "PORT must be at most 65535"}, + + // Public base URL. + {"public base URL credentials", map[string]string{"PUBLIC_BASE_URL": "https://user:pass@share.test"}, + "PUBLIC_BASE_URL must be an origin"}, + {"public base URL path", map[string]string{"PUBLIC_BASE_URL": "https://share.test/path"}, + "PUBLIC_BASE_URL must be an origin"}, + {"public base URL query", map[string]string{"PUBLIC_BASE_URL": "https://share.test?query=1"}, + "PUBLIC_BASE_URL must be an origin"}, + {"public base URL fragment", map[string]string{"PUBLIC_BASE_URL": "https://share.test#fragment"}, + "PUBLIC_BASE_URL must be an origin"}, + {"public base URL scheme", map[string]string{"PUBLIC_BASE_URL": "ftp://share.test"}, + "PUBLIC_BASE_URL must use http or https"}, + {"blank public base URL", map[string]string{"PUBLIC_BASE_URL": ""}, + "PUBLIC_BASE_URL must be a valid http or https origin"}, + {"plaintext public base URL in production", env(productionBase, map[string]string{ + "PUBLIC_BASE_URL": "http://share.test", + }), "PUBLIC_BASE_URL must use https in production"}, + + // Allowed origins. + {"allowed origin scheme", map[string]string{"ALLOWED_ORIGINS": "ftp://share.test"}, + "Allowed origins must use http or https"}, + {"unparsable allowed origin", map[string]string{"ALLOWED_ORIGINS": "share.test"}, + "Allowed origins must be valid http or https URLs"}, + {"empty allowed origin entry", map[string]string{"ALLOWED_ORIGINS": "https://a.test,,https://b.test"}, + "ALLOWED_ORIGINS contains an empty URL"}, + + // Production requirements. + {"production without STUN", map[string]string{ + "SCREENER_ENV": "production", "PUBLIC_BASE_URL": "https://share.test", + "SITE_ACCESS_PASSWORD": "host-password-12", + }, "STUN is required in production"}, + {"production without a site password", map[string]string{ + "SCREENER_ENV": "production", "PUBLIC_BASE_URL": "https://share.test", + "STUN_URLS": "stun:stun.test:3478", + }, "SITE_ACCESS_PASSWORD is required in production"}, + + // Site access password bounds (UTF-8 bytes). + {"site password too short", map[string]string{"SITE_ACCESS_PASSWORD": strings.Repeat("x", 7)}, + "SITE_ACCESS_PASSWORD must contain 8 to 128 visible ASCII bytes"}, + {"site password outside ASCII", map[string]string{"SITE_ACCESS_PASSWORD": "密码密码密码密码"}, + "SITE_ACCESS_PASSWORD must contain 8 to 128 visible ASCII bytes"}, + {"site password with spaces", map[string]string{"SITE_ACCESS_PASSWORD": "contains spaces"}, + "SITE_ACCESS_PASSWORD must contain 8 to 128 visible ASCII bytes"}, + {"site password too long", map[string]string{"SITE_ACCESS_PASSWORD": strings.Repeat("x", 129)}, + "SITE_ACCESS_PASSWORD must contain 8 to 128 visible ASCII bytes"}, + + // STUN lists. + {"STUN path", map[string]string{"STUN_URLS": "stun:stun.test/path"}, + "STUN_URLS contains an invalid STUN URL"}, + {"STUN query", map[string]string{"STUN_URLS": "stun:stun.test?transport=udp"}, + "STUN_URLS contains an invalid STUN URL"}, + {"STUN fragment", map[string]string{"STUN_URLS": "stun:stun.test#fragment"}, + "STUN_URLS contains an invalid STUN URL"}, + {"STUN without authority", map[string]string{"STUN_URLS": "stun:"}, + "STUN_URLS contains an invalid STUN URL"}, + {"STUN with an empty port", map[string]string{"STUN_URLS": "stun:stun.test:"}, + "STUN_URLS contains an invalid STUN URL"}, + {"STUN port zero", map[string]string{"STUN_URLS": "stun:stun.test:0"}, + "STUN_URLS contains an invalid STUN URL"}, + {"STUN userinfo", map[string]string{"STUN_URLS": "stun:user@stun.test:3478"}, + "STUN_URLS contains an invalid STUN URL"}, + {"TURN scheme", map[string]string{"STUN_URLS": "turn:turn.test:3478"}, + "STUN_URLS contains an invalid STUN URL"}, + {"STUNS scheme", map[string]string{"STUN_URLS": "stuns:stun.test:5349"}, + "STUN_URLS contains an invalid STUN URL"}, + {"empty STUN entry", map[string]string{"STUN_URLS": "stun:a.test:3478,,stun:b.test:3478"}, + "STUN_URLS contains an empty URL"}, + {"too many STUN URLs", map[string]string{"STUN_URLS": stunList(9, "stun")}, + "STUN_URLS must contain at most 8 URLs"}, + {"no NAT prediction slots", map[string]string{ + "STUN_URLS": stunList(7, "ordinary"), "NAT_PREDICTION_ENABLED": "true", + }, "STUN_URLS must contain at most 6 URLs when NAT_PREDICTION_ENABLED=true"}, + + // NAT prediction. + {"NAT prediction without STUN", map[string]string{"NAT_PREDICTION_ENABLED": "true"}, + "NAT_PREDICTION_ENABLED requires a STUN_URLS entry on UDP 3478"}, + {"NAT prediction off the base port", map[string]string{ + "STUN_URLS": "stun:share.test:5349", "NAT_PREDICTION_ENABLED": "true", + }, "NAT_PREDICTION_ENABLED requires a STUN_URLS entry on UDP 3478"}, + {"NAT prediction not a boolean", map[string]string{"NAT_PREDICTION_ENABLED": "sometimes"}, + "NAT_PREDICTION_ENABLED must be true or false"}, + + // Room database path. + {"memory room database path", map[string]string{"ROOM_DATABASE_PATH": ":memory:"}, + "ROOM_DATABASE_PATH must be an absolute file path"}, + {"room database path with a NUL", map[string]string{"ROOM_DATABASE_PATH": "rooms\x00.sqlite"}, + "ROOM_DATABASE_PATH must be an absolute file path"}, + {"bare room database file name", map[string]string{"ROOM_DATABASE_PATH": "rooms.sqlite"}, + "ROOM_DATABASE_PATH must be an absolute file path"}, + {"relative room database path", map[string]string{"ROOM_DATABASE_PATH": "./state/rooms.sqlite"}, + "ROOM_DATABASE_PATH must be an absolute file path"}, + + // Runtime environment (DECISIONS D8). + {"NODE_ENV is removed", map[string]string{"NODE_ENV": "production"}, + "NODE_ENV is no longer supported; use SCREENER_ENV"}, + {"blank NODE_ENV is removed", map[string]string{"NODE_ENV": ""}, + "NODE_ENV is no longer supported; use SCREENER_ENV"}, + {"test environment is gone", map[string]string{"SCREENER_ENV": "test"}, + "SCREENER_ENV must be development or production"}, + {"blank environment", map[string]string{"SCREENER_ENV": ""}, + "SCREENER_ENV must be development or production"}, + + // Removed variables carrying their own replacement. + {"removed relay downstream setting", map[string]string{"MAX_PEER_RELAY_DOWNSTREAM_EDGES": "2"}, + "MAX_PEER_RELAY_DOWNSTREAM_EDGES is no longer supported; use ENDPOINT_MEDIA_COPY_CAPACITY"}, + {"removed room rollout setting", map[string]string{"PEER_ASSISTED_ROOM_IDS": "1"}, + "PEER_ASSISTED_ROOM_IDS is no longer supported; peer-assisted media applies to every room"}, + {"removed peer-assisted toggle", map[string]string{"PEER_ASSISTED_MEDIA": "true"}, + "PEER_ASSISTED_MEDIA is no longer supported; peer-assisted media is always enabled"}, + {"removed room TTL", map[string]string{"ROOM_TTL_SECONDS": ""}, + "ROOM_TTL_SECONDS is no longer supported; use ROOM_LEASE_SECONDS"}, + {"removed access password", map[string]string{"ACCESS_PASSWORD": "legacy-password"}, + "ACCESS_PASSWORD is no longer supported; use SITE_ACCESS_PASSWORD"}, + {"blank access password", map[string]string{"ACCESS_PASSWORD": ""}, + "ACCESS_PASSWORD is no longer supported; use SITE_ACCESS_PASSWORD"}, + {"removed host admission password", map[string]string{"HOST_ADMISSION_PASSWORD": "legacy-password"}, + "HOST_ADMISSION_PASSWORD is no longer supported; use SITE_ACCESS_PASSWORD"}, + {"blank host admission password", map[string]string{"HOST_ADMISSION_PASSWORD": ""}, + "HOST_ADMISSION_PASSWORD is no longer supported; use SITE_ACCESS_PASSWORD"}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + _, err := Load(testCase.env) + if err == nil { + t.Fatalf("Load: want error containing %q", testCase.want) + } + if !strings.Contains(err.Error(), testCase.want) { + t.Errorf("Load: error %q, want it to contain %q", err, testCase.want) + } + }) + } +} + +// TestLoadRejectsRemovedTurnVariables covers the ten TURN names that share the +// default reason, blank included. +func TestLoadRejectsRemovedTurnVariables(t *testing.T) { + names := []string{ + "TURN_URLS", "TURN_SHARED_SECRET", "TURN_CREDENTIAL_TTL_SECONDS", + "PEER_ICE_TURN_URLS", "PEER_ICE_TURN_SHARED_SECRET", + "PEER_ICE_TURN_CREDENTIAL_TTL_SECONDS", "SELECTED_EDGE_TURN_URLS", + "SELECTED_EDGE_TURN_SHARED_SECRET", "SELECTED_EDGE_TURN_CREDENTIAL_TTL_SECONDS", + "SELECTED_EDGE_TURN_ALLOCATION_CAPACITY", + } + for _, name := range names { + t.Run(name, func(t *testing.T) { + _, err := Load(map[string]string{name: ""}) + want := name + " is no longer supported; ordinary ICE accepts STUN_URLS only" + if err == nil || err.Error() != want { + t.Errorf("Load: error %v, want %q", err, want) + } + }) + } +} + +func TestOrigin(t *testing.T) { + cases := []struct{ raw, scheme, alternative, want string }{ + {"https://Example.com:443/", "http", "https", "https://example.com"}, + {"http://Example.com:80", "http", "https", "http://example.com"}, + {"https://share.test:0443", "http", "https", "https://share.test"}, + {"http://localhost:9123", "http", "https", "http://localhost:9123"}, + {"wss://LiveKit.test:443", "ws", "wss", "wss://livekit.test"}, + {"ws://livekit.test:7880", "ws", "wss", "ws://livekit.test:7880"}, + {"https://[2001:DB8::1]:8443", "http", "https", "https://[2001:db8::1]:8443"}, + {"http://[::1]:7880", "http", "https", "http://[::1]:7880"}, + } + for _, testCase := range cases { + t.Run(testCase.raw, func(t *testing.T) { + parsed, err := parseOriginURL(testCase.raw, "TEST", testCase.scheme, testCase.alternative) + if err != nil { + t.Fatalf("parseOriginURL: %v", err) + } + if got := Origin(parsed); got != testCase.want { + t.Errorf("Origin = %q, want %q", got, testCase.want) + } + if got := parsed.String(); got != testCase.want+"/" { + t.Errorf("String = %q, want %q", got, testCase.want+"/") + } + }) + } +} + +func liveKitURL(value string) map[string]string { + return env(liveKitAdmission, map[string]string{ + "LIVEKIT_URL": value, "LIVEKIT_API_KEY": "test-key", "LIVEKIT_API_SECRET": liveKitSecret, + }) +} + +func liveKitAPIURL(value string) map[string]string { + return map[string]string{ + "LIVEKIT_URL": "wss://livekit.test", "LIVEKIT_API_URL": value, + "LIVEKIT_API_KEY": "test-key", "LIVEKIT_API_SECRET": liveKitSecret, + } +} diff --git a/internal/server/config/ice.go b/internal/server/config/ice.go new file mode 100644 index 0000000..aafa831 --- /dev/null +++ b/internal/server/config/ice.go @@ -0,0 +1,81 @@ +package config + +import ( + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/TNTcraftHIM/Screener/internal/server/protocol" +) + +// Ported from src/server/ice.ts. +const natPredictionBasePort = 3478 + +// natPredictionAuxiliaryPorts are NAT_PREDICTION_AUXILIARY_PORTS: the two +// listeners a NAT-prediction STUN deployment binds beside the base port, whose +// answers reveal the peer's port-allocation stride. +var natPredictionAuxiliaryPorts = [...]int{natPredictionBasePort + 1, natPredictionBasePort + 2} + +// NATPredictionStunURLs ports natPredictionStunUrls: the two auxiliary +// listeners derived from the first ordinary STUN authority on UDP 3478. +func NATPredictionStunURLs(stunURLs []string) []string { + for _, stunURL := range stunURLs { + // TS: stunUrl.slice(stunUrl.indexOf(":") + 1) — a URL without a colon + // keeps its whole text, which then fails to yield a host below. + authorityText := stunURL[strings.IndexByte(stunURL, ':')+1:] + authority, err := url.Parse("http://" + authorityText) + if err != nil || authority.Hostname() == "" { + continue + } + port := authority.Port() + // The WHATWG parser drops a port equal to the scheme default, so + // "stun:host:80" reaches the TypeScript's `: NAT_PREDICTION_BASE_PORT` + // fallback and counts as a base listener. + if port == defaultSchemePort("http") { + port = "" + } + if port != "" { + number, err := strconv.Atoi(port) + if err != nil || number != natPredictionBasePort { + continue + } + } + // WHATWG lowercases the host and keeps an IPv6 literal bracketed; + // url.URL.Hostname strips the brackets, so put them back. + hostname := strings.ToLower(authority.Hostname()) + if strings.Contains(hostname, ":") { + hostname = "[" + hostname + "]" + } + derived := make([]string, 0, len(natPredictionAuxiliaryPorts)) + for _, auxiliaryPort := range natPredictionAuxiliaryPorts { + derived = append(derived, fmt.Sprintf("stun:%s:%d", hostname, auxiliaryPort)) + } + return derived + } + return nil +} + +// IceConfig ports createIceConfig. +func IceConfig(c Config) protocol.IceConfig { + // TS: options.natPredictionStunUrls ?? natPredictionStunUrls(stunUrls). A + // configured empty list (Local without prediction) is kept as-is; only an + // absent one is derived, so nil and empty differ here. + prediction := c.NATPredictionSTUNURLs + if prediction == nil { + prediction = NATPredictionStunURLs(c.STUNURLs) + } + config := protocol.IceConfig{ + IceServers: []protocol.IceServer{}, + NatPredictionStunURLs: []string{}, + } + if len(c.STUNURLs) > 0 { + config.IceServers = []protocol.IceServer{ + {URLs: protocol.IceServerURLList(append([]string{}, c.STUNURLs...)...)}, + } + } + if c.NATPredictionEnabled { + config.NatPredictionStunURLs = append([]string{}, prediction...) + } + return config +} diff --git a/internal/server/config/ice_test.go b/internal/server/config/ice_test.go new file mode 100644 index 0000000..21e05a0 --- /dev/null +++ b/internal/server/config/ice_test.go @@ -0,0 +1,136 @@ +package config + +import ( + "encoding/json" + "slices" + "testing" + + "github.com/TNTcraftHIM/Screener/internal/server/protocol" +) + +// Ported from tests/ice.test.ts. + +func TestIceConfig(t *testing.T) { + cases := []struct { + name string + config Config + want string + }{ + { + name: "returns only the configured STUN servers", + config: Config{ + STUNURLs: []string{"stun:stun-a.example.test:3478", "stun:stun-b.example.test:3478"}, + NATPredictionEnabled: true, + NATPredictionSTUNURLs: []string{ + "stun:survey-a.example.test:3478", "stun:survey-b.example.test:3478", + }, + }, + want: `{"iceServers":[{"urls":["stun:stun-a.example.test:3478",` + + `"stun:stun-b.example.test:3478"]}],"natPredictionStunUrls":` + + `["stun:survey-a.example.test:3478","stun:survey-b.example.test:3478"]}`, + }, + { + name: "keeps local development explicit when STUN is absent", + config: Config{}, + want: `{"iceServers":[],"natPredictionStunUrls":[]}`, + }, + { + name: "does not expose auxiliary listeners when the capability is disabled", + config: Config{STUNURLs: []string{"stun:share.example.test:3478"}}, + want: `{"iceServers":[{"urls":["stun:share.example.test:3478"]}],` + + `"natPredictionStunUrls":[]}`, + }, + { + name: "derives auxiliary listeners from an IPv6 STUN authority", + config: Config{ + STUNURLs: []string{"STUN:[2001:db8::1]:3478"}, + NATPredictionEnabled: true, + }, + want: `{"iceServers":[{"urls":["STUN:[2001:db8::1]:3478"]}],` + + `"natPredictionStunUrls":["stun:[2001:db8::1]:3479","stun:[2001:db8::1]:3480"]}`, + }, + { + // TS: `options.natPredictionStunUrls ?? natPredictionStunUrls(...)` + // keeps a configured empty list instead of deriving one. + name: "keeps a configured empty prediction list", + config: Config{ + STUNURLs: []string{"stun:share.example.test:3478"}, + NATPredictionEnabled: true, + NATPredictionSTUNURLs: []string{}, + }, + want: `{"iceServers":[{"urls":["stun:share.example.test:3478"]}],` + + `"natPredictionStunUrls":[]}`, + }, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + encoded, err := json.Marshal(IceConfig(testCase.config)) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if string(encoded) != testCase.want { + t.Errorf("IceConfig = %s, want %s", encoded, testCase.want) + } + }) + } +} + +func TestIceConfigDoesNotAliasTheConfiguration(t *testing.T) { + config := Config{ + STUNURLs: []string{"stun:a.test:3478"}, + NATPredictionEnabled: true, + NATPredictionSTUNURLs: []string{"stun:a.test:3479", "stun:a.test:3480"}, + } + derived := IceConfig(config) + derived.IceServers[0].URLs.List[0] = "stun:mutated.test:3478" + derived.NatPredictionStunURLs[0] = "stun:mutated.test:3479" + if config.STUNURLs[0] != "stun:a.test:3478" || config.NATPredictionSTUNURLs[0] != "stun:a.test:3479" { + t.Errorf("IceConfig shares its slices with the Config: %v %v", + config.STUNURLs, config.NATPredictionSTUNURLs) + } +} + +func TestNATPredictionStunURLs(t *testing.T) { + cases := []struct { + name string + stunURLs []string + want []string + }{ + {"no URLs", nil, nil}, + {"implicit base port", []string{"stun:share.test"}, + []string{"stun:share.test:3479", "stun:share.test:3480"}}, + {"explicit base port", []string{"stun:share.test:3478"}, + []string{"stun:share.test:3479", "stun:share.test:3480"}}, + {"leading zeros in the port", []string{"stun:share.test:03478"}, + []string{"stun:share.test:3479", "stun:share.test:3480"}}, + // WHATWG drops a port equal to the scheme default, so "http://host:80" + // reports no port and the TS fallback treats it as 3478. + {"HTTP default port reads as the base port", []string{"stun:share.test:80"}, + []string{"stun:share.test:3479", "stun:share.test:3480"}}, + {"uppercase scheme and host", []string{"STUN:Share.TEST:3478"}, + []string{"stun:share.test:3479", "stun:share.test:3480"}}, + {"IPv6 authority", []string{"stun:[2001:DB8::1]:3478"}, + []string{"stun:[2001:db8::1]:3479", "stun:[2001:db8::1]:3480"}}, + {"first base listener wins", + []string{"stun:a.test:5349", "stun:b.test:3478", "stun:c.test:3478"}, + []string{"stun:b.test:3479", "stun:b.test:3480"}}, + {"no base listener", []string{"stun:a.test:5349", "stun:b.test:19302"}, nil}, + // TS: indexOf(":") is -1, so the whole text becomes the authority. + {"no scheme separator", []string{"sharetest"}, + []string{"stun:sharetest:3479", "stun:sharetest:3480"}}, + {"no authority", []string{"stun:"}, nil}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + got := NATPredictionStunURLs(testCase.stunURLs) + if !slices.Equal(got, testCase.want) { + t.Errorf("NATPredictionStunURLs = %v, want %v", got, testCase.want) + } + if len(got) > protocol.MaxNatPredictionAuxiliaryStunURLs { + t.Errorf("NATPredictionStunURLs returned %d URLs", len(got)) + } + }) + } +} diff --git a/internal/server/config/local.go b/internal/server/config/local.go new file mode 100644 index 0000000..3576152 --- /dev/null +++ b/internal/server/config/local.go @@ -0,0 +1,151 @@ +package config + +import ( + "errors" + "fmt" + "net" + "net/url" + "regexp" + "slices" + "strings" + + "github.com/TNTcraftHIM/Screener/internal/server/protocol" +) + +// Ported from src/server/local-config.ts. +const localRoomLeaseMs = 24 * 60 * 60 * 1_000 + +// localPasswordPattern is VISIBLE_ASCII_PATTERN from local-config.ts. Because +// it admits one byte per character the TypeScript's extra Buffer.byteLength +// bounds are already implied by {8,128}. +var localPasswordPattern = regexp.MustCompile(`^[\x21-\x7e]{8,128}$`) + +// LocalOptions ports LocalServerConfigOptions. loadLocalServerConfig, the +// environment form, went away with the Node child process, so Local never +// reads the process environment. +type LocalOptions struct { + // Port of 0 selects the default 8787, as TypeScript's absent `port` did. + // An explicit 0 is indistinguishable from absent here, where TypeScript + // rejected it; 0 is not a usable port either way. + Port int + PublicAddress string + PublicOrigin string + AllowedAddresses []string + SiteAccessPassword string + STUNURLs []string + NATPredictionSTUNURLs []string +} + +// Local ports createLocalServerConfig. +func Local(options LocalOptions) (Config, error) { + port := options.Port + if port == 0 { + port = defaultPort + } + if port < 1 || port > maxPort { + return Config{}, fmt.Errorf("Local server port must be an integer between 1 and %d", maxPort) + } + publicAddress, err := localIPv4(options.PublicAddress, "public address") + if err != nil { + return Config{}, err + } + if publicAddress == "0.0.0.0" || strings.HasPrefix(publicAddress, "127.") { + return Config{}, errors.New("Local server public address must be reachable from the LAN") + } + siteAccessPassword := strings.TrimFunc(options.SiteAccessPassword, protocol.IsJSWhitespace) + if siteAccessPassword != "" && !localPasswordPattern.MatchString(siteAccessPassword) { + return Config{}, errors.New("Local access password must contain 8 to 128 visible ASCII bytes") + } + + allowedAddresses := []string{publicAddress} + for _, address := range options.AllowedAddresses { + normalized, err := localIPv4(address, "allowed address") + if err != nil { + return Config{}, err + } + if !slices.Contains(allowedAddresses, normalized) { + allowedAddresses = append(allowedAddresses, normalized) + } + } + + // TS spreads `?? []`, so both lists are always arrays; keeping them non-nil + // is what tells IceConfig that NAT prediction URLs were configured. + stunURLs := append([]string{}, options.STUNURLs...) + natPredictionSTUNURLs := append([]string{}, options.NATPredictionSTUNURLs...) + if len(stunURLs)+len(natPredictionSTUNURLs) > protocol.MaxIceServerURLs || + !allValidStunURLs(stunURLs) || + !allValidStunURLs(natPredictionSTUNURLs) || + (len(natPredictionSTUNURLs) != 0 && + len(natPredictionSTUNURLs) != protocol.MaxNatPredictionAuxiliaryStunURLs) || + (len(natPredictionSTUNURLs) > 0 && len(stunURLs) == 0) { + return Config{}, errors.New("Local STUN URLs are invalid") + } + + // TS builds these with a template string, so a non-default port is always + // spelled out and port 80 would produce "http://host:80", which is not the + // serialised origin. Kept as-is: Local never runs on 80. + origin := func(host string) string { return fmt.Sprintf("http://%s:%d", host, port) } + publicBaseURL := &url.URL{Scheme: "http", Host: fmt.Sprintf("%s:%d", publicAddress, port), Path: "/"} + if options.PublicOrigin != "" { + publicBaseURL, err = publicHTTPSOrigin(options.PublicOrigin) + if err != nil { + return Config{}, err + } + } + + allowedOrigins := map[string]struct{}{ + origin("localhost"): {}, + origin("127.0.0.1"): {}, + } + for _, address := range allowedAddresses { + allowedOrigins[origin(address)] = struct{}{} + } + allowedOrigins[Origin(publicBaseURL)] = struct{}{} + + return Config{ + Env: EnvironmentProduction, + Port: port, + ListenHost: "0.0.0.0", + PublicBaseURL: publicBaseURL, + AllowedOrigins: allowedOrigins, + SiteAccessPassword: siteAccessPassword, + RoomLeaseMs: localRoomLeaseMs, + MaxViewersPerRoom: protocol.MaxViewersPerRoomLimit, + EndpointMediaCopyCapacity: protocol.DefaultEndpointMediaCopyCapacity, + STUNURLs: stunURLs, + NATPredictionEnabled: len(natPredictionSTUNURLs) == protocol.MaxNatPredictionAuxiliaryStunURLs, + NATPredictionSTUNURLs: natPredictionSTUNURLs, + }, nil +} + +func allValidStunURLs(values []string) bool { + for _, value := range values { + if !protocol.ValidStunURLValue(value) { + return false + } + } + return true +} + +// publicHTTPSOrigin ports publicHTTPSOrigin: every failure shares one message. +func publicHTTPSOrigin(value string) (*url.URL, error) { + parsed, err := url.Parse(value) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || + hasUserinfo(parsed) || (parsed.Path != "" && parsed.Path != "/") || + parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, errors.New("Local public origin must be an HTTPS origin") + } + return &url.URL{Scheme: "https", Host: normalizedHost(parsed), Path: "/"}, nil +} + +// localIPv4 ports localIPv4. Node's isIP(value) === 4 accepts only a dotted +// quad, so an IPv4-mapped IPv6 literal such as "::ffff:192.168.1.10" — which +// net.ParseIP(...).To4() does resolve — is rejected here as well. +func localIPv4(value string, name string) (string, error) { + address := strings.TrimFunc(value, protocol.IsJSWhitespace) + parsed := net.ParseIP(address) + if parsed == nil || parsed.To4() == nil || strings.ContainsRune(address, ':') { + return "", fmt.Errorf("Local server %s must be an IPv4 address", name) + } + return address, nil +} diff --git a/internal/server/config/local_test.go b/internal/server/config/local_test.go new file mode 100644 index 0000000..6c7251d --- /dev/null +++ b/internal/server/config/local_test.go @@ -0,0 +1,210 @@ +package config + +import ( + "reflect" + "slices" + "strings" + "testing" +) + +// Ported from tests/local-server-config.test.ts. The environment form, +// loadLocalServerConfig, went away with the Node child process, so its scenario +// is expressed through LocalOptions here. + +func mustLocal(t *testing.T, options LocalOptions) Config { + t.Helper() + config, err := Local(options) + if err != nil { + t.Fatalf("Local: unexpected error %v", err) + } + return config +} + +func TestLocalMapsClientInputs(t *testing.T) { + config := mustLocal(t, LocalOptions{ + Port: 9123, + PublicAddress: "192.168.1.10", + AllowedAddresses: []string{"10.0.0.8", "192.168.1.10"}, + SiteAccessPassword: "local-access-password", + }) + + if config.Env != EnvironmentProduction { + t.Errorf("Env = %q", config.Env) + } + if config.Port != 9123 { + t.Errorf("Port = %d", config.Port) + } + if config.ListenHost != "0.0.0.0" { + t.Errorf("ListenHost = %q", config.ListenHost) + } + if config.SiteAccessPassword != "local-access-password" { + t.Errorf("SiteAccessPassword = %q", config.SiteAccessPassword) + } + if config.RoomLeaseMs != 86_400_000 { + t.Errorf("RoomLeaseMs = %d", config.RoomLeaseMs) + } + if config.MaxViewersPerRoom != 20 { + t.Errorf("MaxViewersPerRoom = %d", config.MaxViewersPerRoom) + } + if config.EndpointMediaCopyCapacity != 2 { + t.Errorf("EndpointMediaCopyCapacity = %d", config.EndpointMediaCopyCapacity) + } + if len(config.STUNURLs) != 0 { + t.Errorf("STUNURLs = %v", config.STUNURLs) + } + if config.NATPredictionEnabled { + t.Error("NATPredictionEnabled = true") + } + if got := config.PublicBaseURL.String(); got != "http://192.168.1.10:9123/" { + t.Errorf("PublicBaseURL = %q", got) + } + want := map[string]struct{}{ + "http://localhost:9123": {}, + "http://127.0.0.1:9123": {}, + "http://192.168.1.10:9123": {}, + "http://10.0.0.8:9123": {}, + } + if !reflect.DeepEqual(config.AllowedOrigins, want) { + t.Errorf("AllowedOrigins = %v, want %v", config.AllowedOrigins, want) + } + if config.LiveKit != nil { + t.Errorf("LiveKit = %+v", config.LiveKit) + } + if config.RoomDatabasePath != "" { + t.Errorf("RoomDatabasePath = %q", config.RoomDatabasePath) + } +} + +func TestLocalTunnelledClient(t *testing.T) { + config := mustLocal(t, LocalOptions{ + Port: 9234, + PublicAddress: "192.168.50.4", + AllowedAddresses: []string{"192.168.50.4", "10.10.0.4"}, + SiteAccessPassword: "persistent-local-password", + PublicOrigin: "https://small-bright-room.trycloudflare.com", + STUNURLs: []string{"stun:stun.example:3478", "stun:stun.example:3479"}, + NATPredictionSTUNURLs: []string{"stun:survey-a.example:3478", "stun:survey-b.example:3478"}, + }) + + if config.Port != 9234 { + t.Errorf("Port = %d", config.Port) + } + if got := Origin(config.PublicBaseURL); got != "https://small-bright-room.trycloudflare.com" { + t.Errorf("public origin = %q", got) + } + for _, origin := range []string{ + "https://small-bright-room.trycloudflare.com", "http://10.10.0.4:9234", + } { + if _, ok := config.AllowedOrigins[origin]; !ok { + t.Errorf("AllowedOrigins is missing %q: %v", origin, config.AllowedOrigins) + } + } + if !slices.Equal(config.STUNURLs, []string{"stun:stun.example:3478", "stun:stun.example:3479"}) { + t.Errorf("STUNURLs = %v", config.STUNURLs) + } + if !config.NATPredictionEnabled { + t.Error("NATPredictionEnabled = false") + } + if !slices.Equal(config.NATPredictionSTUNURLs, + []string{"stun:survey-a.example:3478", "stun:survey-b.example:3478"}) { + t.Errorf("NATPredictionSTUNURLs = %v", config.NATPredictionSTUNURLs) + } +} + +func TestLocalDefaults(t *testing.T) { + config := mustLocal(t, LocalOptions{PublicAddress: "192.168.1.10"}) + + if config.SiteAccessPassword != "" { + t.Errorf("SiteAccessPassword = %q, want the open site", config.SiteAccessPassword) + } + if config.Port != 8787 { + t.Errorf("Port = %d, want the 8787 default", config.Port) + } + if got := Origin(config.PublicBaseURL); got != "http://192.168.1.10:8787" { + t.Errorf("public origin = %q", got) + } + // A configured (empty) prediction list stays non-nil so IceConfig does not + // derive one, which is TS's `?? []` spread. + if config.NATPredictionSTUNURLs == nil { + t.Error("NATPredictionSTUNURLs = nil, want an empty configured list") + } +} + +func TestLocalRejects(t *testing.T) { + cases := []struct { + name string + options LocalOptions + want string + }{ + {"hostname public address", LocalOptions{PublicAddress: "localhost", + SiteAccessPassword: "valid-password"}, + "Local server public address must be an IPv4 address"}, + {"IPv6 public address", LocalOptions{PublicAddress: "::ffff:192.168.1.10"}, + "Local server public address must be an IPv4 address"}, + {"loopback public address", LocalOptions{PublicAddress: "127.0.0.1", + SiteAccessPassword: "valid-password"}, + "Local server public address must be reachable from the LAN"}, + {"unspecified public address", LocalOptions{PublicAddress: "0.0.0.0", + SiteAccessPassword: "valid-password"}, + "Local server public address must be reachable from the LAN"}, + {"short password", LocalOptions{PublicAddress: "192.168.1.2", SiteAccessPassword: "short"}, + "Local access password must contain 8 to 128 visible ASCII bytes"}, + {"password outside ASCII", LocalOptions{PublicAddress: "192.168.1.2", + SiteAccessPassword: "密码密码密码密码"}, + "Local access password must contain 8 to 128 visible ASCII bytes"}, + {"long password", LocalOptions{PublicAddress: "192.168.1.2", + SiteAccessPassword: strings.Repeat("x", 129)}, + "Local access password must contain 8 to 128 visible ASCII bytes"}, + {"port above the TCP ceiling", LocalOptions{Port: 70_000, PublicAddress: "192.168.1.2"}, + "Local server port must be an integer between 1 and 65535"}, + {"negative port", LocalOptions{Port: -1, PublicAddress: "192.168.1.2"}, + "Local server port must be an integer between 1 and 65535"}, + {"hostname allowed address", LocalOptions{PublicAddress: "192.168.1.2", + AllowedAddresses: []string{"share.local"}}, + "Local server allowed address must be an IPv4 address"}, + {"plaintext public origin", LocalOptions{PublicAddress: "192.168.1.2", + PublicOrigin: "http://public.example", SiteAccessPassword: "valid-password"}, + "Local public origin must be an HTTPS origin"}, + {"public origin with a path", LocalOptions{PublicAddress: "192.168.1.2", + PublicOrigin: "https://public.example/path", SiteAccessPassword: "valid-password"}, + "Local public origin must be an HTTPS origin"}, + {"public origin with userinfo", LocalOptions{PublicAddress: "192.168.1.2", + PublicOrigin: "https://user@public.example", SiteAccessPassword: "valid-password"}, + "Local public origin must be an HTTPS origin"}, + {"unparsable public origin", LocalOptions{PublicAddress: "192.168.1.2", + PublicOrigin: "public.example"}, + "Local public origin must be an HTTPS origin"}, + {"invalid STUN URL", LocalOptions{PublicAddress: "192.168.1.2", + STUNURLs: []string{"turn:turn.test:3478"}}, + "Local STUN URLs are invalid"}, + {"invalid prediction STUN URL", LocalOptions{PublicAddress: "192.168.1.2", + STUNURLs: []string{"stun:a.test:3478"}, + NATPredictionSTUNURLs: []string{"stun:a.test:3479", "stuns:b.test:3480"}}, + "Local STUN URLs are invalid"}, + {"single prediction STUN URL", LocalOptions{PublicAddress: "192.168.1.2", + STUNURLs: []string{"stun:a.test:3478"}, NATPredictionSTUNURLs: []string{"stun:a.test:3479"}}, + "Local STUN URLs are invalid"}, + {"prediction without ordinary STUN", LocalOptions{PublicAddress: "192.168.1.2", + NATPredictionSTUNURLs: []string{"stun:a.test:3479", "stun:a.test:3480"}}, + "Local STUN URLs are invalid"}, + {"more ICE URLs than the wire carries", LocalOptions{PublicAddress: "192.168.1.2", + STUNURLs: []string{ + "stun:a.test:3478", "stun:b.test:3478", "stun:c.test:3478", "stun:d.test:3478", + "stun:e.test:3478", "stun:f.test:3478", "stun:g.test:3478", + }, + NATPredictionSTUNURLs: []string{"stun:a.test:3479", "stun:a.test:3480"}}, + "Local STUN URLs are invalid"}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + _, err := Local(testCase.options) + if err == nil { + t.Fatalf("Local: want error containing %q", testCase.want) + } + if !strings.Contains(err.Error(), testCase.want) { + t.Errorf("Local: error %q, want it to contain %q", err, testCase.want) + } + }) + } +} diff --git a/internal/server/ordered/map.go b/internal/server/ordered/map.go new file mode 100644 index 0000000..78033cc --- /dev/null +++ b/internal/server/ordered/map.go @@ -0,0 +1,154 @@ +// Package ordered provides an insertion-ordered map with the iteration +// semantics of a JavaScript Map. The TypeScript server relied on those +// semantics wherever it iterated a Map or Set: wire-visible participant order, +// candidate ordering, round-robin state, and delete-during-iteration loops. +package ordered + +import "iter" + +type entry[K comparable, V any] struct { + key K + value V + deleted bool +} + +// Map keeps keys in first-insertion order. Set on an existing key keeps its +// position; MoveToBack reproduces the JavaScript delete-then-set idiom. +// Iteration is live: an entry deleted before it is visited is skipped and an +// entry appended during iteration is visited, exactly like a JavaScript Map. +// The zero value is ready to use. +type Map[K comparable, V any] struct { + entries []entry[K, V] + index map[K]int + deleted int + iterating int +} + +// Len reports the number of live entries. +func (m *Map[K, V]) Len() int { return len(m.index) } + +// Get returns the value for key and whether it is present. +func (m *Map[K, V]) Get(key K) (V, bool) { + if position, ok := m.index[key]; ok { + return m.entries[position].value, true + } + var zero V + return zero, false +} + +// Has reports whether key is present. +func (m *Map[K, V]) Has(key K) bool { + _, ok := m.index[key] + return ok +} + +// Set stores value under key, keeping the existing position when the key is +// already present and appending otherwise. +func (m *Map[K, V]) Set(key K, value V) { + if position, ok := m.index[key]; ok { + m.entries[position].value = value + return + } + if m.index == nil { + m.index = make(map[K]int) + } + m.index[key] = len(m.entries) + m.entries = append(m.entries, entry[K, V]{key: key, value: value}) +} + +// MoveToBack re-inserts key at the end, as JavaScript `map.delete(k); +// map.set(k, v)` does. It is a plain Set when the key is absent. +func (m *Map[K, V]) MoveToBack(key K, value V) { + m.Delete(key) + m.Set(key, value) +} + +// Delete removes key and reports whether it was present. +func (m *Map[K, V]) Delete(key K) bool { + position, ok := m.index[key] + if !ok { + return false + } + delete(m.index, key) + var zero V + m.entries[position].value = zero + m.entries[position].deleted = true + m.deleted++ + m.compact() + return true +} + +// Clear removes every entry. Dropping the index before compacting is safe +// because every entry is now a tombstone, so compact writes no index entry. +func (m *Map[K, V]) Clear() { + for position := range m.entries { + if !m.entries[position].deleted { + var zero V + m.entries[position].value = zero + m.entries[position].deleted = true + m.deleted++ + } + } + m.index = nil + m.compact() +} + +// Keys returns a snapshot of the live keys in order. +func (m *Map[K, V]) Keys() []K { + keys := make([]K, 0, len(m.index)) + for _, current := range m.entries { + if !current.deleted { + keys = append(keys, current.key) + } + } + return keys +} + +// Values returns a snapshot of the live values in order. +func (m *Map[K, V]) Values() []V { + values := make([]V, 0, len(m.index)) + for _, current := range m.entries { + if !current.deleted { + values = append(values, current.value) + } + } + return values +} + +// All iterates live entries in order with JavaScript Map semantics. Mutating +// the map inside the loop is allowed. +func (m *Map[K, V]) All() iter.Seq2[K, V] { + return func(yield func(K, V) bool) { + m.iterating++ + defer func() { + m.iterating-- + m.compact() + }() + for position := 0; position < len(m.entries); position++ { + current := m.entries[position] + if current.deleted { + continue + } + if !yield(current.key, current.value) { + return + } + } + } +} + +// compact drops tombstones once they dominate, but never while an iteration +// is walking the entry slice. +func (m *Map[K, V]) compact() { + if m.iterating > 0 || m.deleted == 0 || m.deleted*2 < len(m.entries) { + return + } + live := make([]entry[K, V], 0, len(m.index)) + for _, current := range m.entries { + if !current.deleted { + m.index[current.key] = len(live) + live = append(live, current) + } + } + m.entries = live + m.deleted = 0 +} diff --git a/internal/server/ordered/map_test.go b/internal/server/ordered/map_test.go new file mode 100644 index 0000000..c149cc6 --- /dev/null +++ b/internal/server/ordered/map_test.go @@ -0,0 +1,79 @@ +package ordered + +import ( + "slices" + "testing" +) + +func TestMapKeepsInsertionOrderAndPositionOnSet(t *testing.T) { + var m Map[string, int] + m.Set("a", 1) + m.Set("b", 2) + m.Set("c", 3) + m.Set("a", 10) + if got := m.Keys(); !slices.Equal(got, []string{"a", "b", "c"}) { + t.Fatalf("keys = %v", got) + } + if got := m.Values(); !slices.Equal(got, []int{10, 2, 3}) { + t.Fatalf("values = %v", got) + } + m.MoveToBack("a", 11) + if got := m.Keys(); !slices.Equal(got, []string{"b", "c", "a"}) { + t.Fatalf("keys after MoveToBack = %v", got) + } + if value, ok := m.Get("a"); !ok || value != 11 || m.Len() != 3 { + t.Fatalf("get a = %v %v len %d", value, ok, m.Len()) + } +} + +func TestMapLiveIterationSkipsDeletedAndVisitsAppended(t *testing.T) { + var m Map[string, int] + for _, key := range []string{"a", "b", "c", "d"} { + m.Set(key, len(key)) + } + var visited []string + for key := range m.All() { + visited = append(visited, key) + switch key { + case "a": + m.Delete("c") // deleted before its turn: skipped, like a JS Map + case "b": + m.Set("e", 1) // appended during iteration: visited + } + } + if !slices.Equal(visited, []string{"a", "b", "d", "e"}) { + t.Fatalf("visited = %v", visited) + } + if got := m.Keys(); !slices.Equal(got, []string{"a", "b", "d", "e"}) { + t.Fatalf("keys = %v", got) + } +} + +func TestMapCompactionPreservesOrderAndIndex(t *testing.T) { + var m Map[int, int] + for index := 0; index < 100; index++ { + m.Set(index, index) + } + for index := 0; index < 100; index += 2 { + m.Delete(index) + } + if m.Len() != 50 || len(m.entries) > 60 { + t.Fatalf("len %d entries %d", m.Len(), len(m.entries)) + } + for index := 1; index < 100; index += 2 { + if value, ok := m.Get(index); !ok || value != index { + t.Fatalf("get %d = %v %v", index, value, ok) + } + } + keys := m.Keys() + if keys[0] != 1 || keys[49] != 99 || !slices.IsSorted(keys) { + t.Fatalf("keys = %v", keys) + } + if m.Delete(1000) { + t.Fatal("deleting an absent key must report false") + } + m.Clear() + if m.Len() != 0 || len(m.Keys()) != 0 { + t.Fatal("clear must empty the map") + } +} diff --git a/internal/server/protocol/client.go b/internal/server/protocol/client.go new file mode 100644 index 0000000..b53f307 --- /dev/null +++ b/internal/server/protocol/client.go @@ -0,0 +1,698 @@ +package protocol + +import ( + "errors" + "fmt" +) + +// ClientMessage is one member of clientMessageSchema. The interface is sealed: +// only the message structs in this file implement it. +type ClientMessage interface { + isClientMessage() +} + +// AuthenticateHostMessage is the host member of authenticateMessageSchema. +type AuthenticateHostMessage struct { + Type string `json:"type"` + Protocol string `json:"protocol"` + RoomID string `json:"roomId"` + Role Role `json:"role"` + Token string `json:"token"` + ClientID string `json:"clientId"` + ShareGeneration string `json:"shareGeneration,omitempty"` + SharingPaused *bool `json:"sharingPaused,omitempty"` + QualitySettings *QualitySettings `json:"qualitySettings,omitempty"` + RoutePolicy RoutePolicy `json:"routePolicy"` + ViewerPresence bool `json:"viewerPresence,omitempty"` + DisplayName *DisplayName `json:"displayName,omitempty"` +} + +// AuthenticateViewerMessage is the viewer member of authenticateMessageSchema. +type AuthenticateViewerMessage struct { + Type string `json:"type"` + Protocol string `json:"protocol"` + RoomID string `json:"roomId"` + Role Role `json:"role"` + ClientID string `json:"clientId"` + ViewerGrant string `json:"viewerGrant,omitempty"` + ViewerPassword string `json:"viewerPassword,omitempty"` + DisplayName *DisplayName `json:"displayName,omitempty"` + ViewerPresence bool `json:"viewerPresence,omitempty"` +} + +// SignalingChallengeMessage is { type: "signaling-challenge", sequence }. +type SignalingChallengeMessage struct { + Type string `json:"type"` + Sequence Int `json:"sequence"` +} + +// ClientSignalMessage is the client "signal" message. +type ClientSignalMessage struct { + Type string `json:"type"` + TargetPeerID string `json:"targetPeerId,omitempty"` + Payload SignalPayload `json:"payload"` +} + +// ClientRestartRequestMessage is the client "restart-request" message. +type ClientRestartRequestMessage struct { + Type string `json:"type"` + TargetPeerID string `json:"targetPeerId,omitempty"` + ConnectionID string `json:"connectionId"` + Rebuild bool `json:"rebuild"` +} + +// SetQualitySettingsMessage is { type: "set-quality-settings", qualitySettings }. +type SetQualitySettingsMessage struct { + Type string `json:"type"` + QualitySettings QualitySettings `json:"qualitySettings"` +} + +// RelayCapacityMessage is { type: "relay-capacity", downstreamEdges }. +type RelayCapacityMessage struct { + Type string `json:"type"` + DownstreamEdges Int `json:"downstreamEdges"` +} + +// RouteReadyMessage is { type: "route-ready", revision, phase, qualityApproved? }. +type RouteReadyMessage struct { + Type string `json:"type"` + Revision Int `json:"revision"` + Phase string `json:"phase"` + QualityApproved bool `json:"qualityApproved,omitempty"` +} + +// RouteTransportConnectedMessage is the "route-transport-connected" message. +type RouteTransportConnectedMessage struct { + Type string `json:"type"` + Revision Int `json:"revision"` + ConnectionID string `json:"connectionId"` +} + +// RouteMediaUnavailableMessage is the "route-media-unavailable" message. +type RouteMediaUnavailableMessage struct { + Type string `json:"type"` + Revision Int `json:"revision"` +} + +// RouteFailedMessage is { type: "route-failed", revision, phase, connectionId }. +type RouteFailedMessage struct { + Type string `json:"type"` + Revision Int `json:"revision"` + Phase string `json:"phase"` + ConnectionID *string `json:"connectionId"` +} + +// RefreshSfuMessage is { type: "refresh-sfu", revision }. +type RefreshSfuMessage struct { + Type string `json:"type"` + Revision Int `json:"revision"` +} + +// RequestRouteDiagnosticMessage is { type: "request-route-diagnostic" }. +type RequestRouteDiagnosticMessage struct { + Type string `json:"type"` +} + +// ViewerQualityEvidenceMessage is viewerQualityEvidenceMessageSchema. +type ViewerQualityEvidenceMessage struct { + Type string `json:"type"` + Guard ViewerQualityEvidenceGuard `json:"guard"` + Sequence Int `json:"sequence"` + WindowMs Int `json:"windowMs"` + Metrics ViewerQualityEvidenceMetrics `json:"metrics"` +} + +// SenderQualityEvidenceMessage is senderQualityEvidenceMessageSchema. +type SenderQualityEvidenceMessage struct { + Type string `json:"type"` + ChildPeerID string `json:"childPeerId"` + ConnectionID string `json:"connectionId"` + RtpStatsID *string `json:"rtpStatsId"` + TrackIdentifier *string `json:"trackIdentifier"` + SampleTimestampMs *Num `json:"sampleTimestampMs"` + RouteRevision Int `json:"routeRevision"` + State string `json:"state"` + Diagnostics SenderQualityDiagnostics `json:"diagnostics"` +} + +// SfuPublisherQualityEvidenceMessage is +// sfuPublisherQualityEvidenceMessageSchema. +type SfuPublisherQualityEvidenceMessage struct { + Type string `json:"type"` + PublicationGeneration string `json:"publicationGeneration"` + RouteRevision Int `json:"routeRevision"` + State string `json:"state"` + SampleTimestampMs *Num `json:"sampleTimestampMs"` + Diagnostics SenderQualityDiagnostics `json:"diagnostics"` +} + +// ResetSenderQualityMessage is { type: "reset-sender-quality" }. +type ResetSenderQualityMessage struct { + Type string `json:"type"` +} + +// SetDisplayNameMessage is { type: "set-display-name", displayName }. +type SetDisplayNameMessage struct { + Type string `json:"type"` + DisplayName DisplayName `json:"displayName"` +} + +// SetSharingPausedMessage is { type: "set-sharing-paused", shareGeneration, paused }. +type SetSharingPausedMessage struct { + Type string `json:"type"` + ShareGeneration string `json:"shareGeneration"` + Paused bool `json:"paused"` +} + +// StopSharingMessage is { type: "stop-sharing", shareGeneration? }. +type StopSharingMessage struct { + Type string `json:"type"` + ShareGeneration string `json:"shareGeneration,omitempty"` +} + +// AbandonRoomMessage is { type: "abandon-room" }. +type AbandonRoomMessage struct { + Type string `json:"type"` +} + +func (AuthenticateHostMessage) isClientMessage() {} +func (AuthenticateViewerMessage) isClientMessage() {} +func (SignalingChallengeMessage) isClientMessage() {} +func (ClientSignalMessage) isClientMessage() {} +func (ClientRestartRequestMessage) isClientMessage() {} +func (SetQualitySettingsMessage) isClientMessage() {} +func (RelayCapacityMessage) isClientMessage() {} +func (RouteReadyMessage) isClientMessage() {} +func (RouteTransportConnectedMessage) isClientMessage() {} +func (RouteMediaUnavailableMessage) isClientMessage() {} +func (RouteFailedMessage) isClientMessage() {} +func (RefreshSfuMessage) isClientMessage() {} +func (RequestRouteDiagnosticMessage) isClientMessage() {} +func (ViewerQualityEvidenceMessage) isClientMessage() {} +func (SenderQualityEvidenceMessage) isClientMessage() {} +func (SfuPublisherQualityEvidenceMessage) isClientMessage() {} +func (ResetSenderQualityMessage) isClientMessage() {} +func (SetDisplayNameMessage) isClientMessage() {} +func (SetSharingPausedMessage) isClientMessage() {} +func (StopSharingMessage) isClientMessage() {} +func (AbandonRoomMessage) isClientMessage() {} + +// DecodeClientMessage ports decodeClientMessage: JSON.parse followed by +// clientMessageSchema.parse. Every member of the union carries a distinct +// `type` literal, so the union is resolved by that key. +func DecodeClientMessage(data []byte) (ClientMessage, error) { + messageType, err := typeOf(data, "type") + if err != nil { + return nil, err + } + switch messageType { + case "authenticate": + return decodeAuthenticate(data) + case "signaling-challenge": + return decodeSignalingChallenge(data) + case "signal": + return decodeClientSignal(data) + case "restart-request": + return decodeClientRestartRequest(data) + case "set-quality-settings": + return decodeSetQualitySettings(data) + case "relay-capacity": + return decodeRelayCapacity(data) + case "route-ready": + return decodeRouteReady(data) + case "route-transport-connected": + return decodeRouteTransportConnected(data) + case "route-media-unavailable": + return decodeRouteMediaUnavailable(data) + case "route-failed": + return decodeRouteFailed(data) + case "refresh-sfu": + return decodeRefreshSfu(data) + case "request-route-diagnostic": + return decodeEmptyClientMessage(data, RequestRouteDiagnosticMessage{Type: messageType}) + case "viewer-quality-evidence": + return decodeViewerQualityEvidence(data) + case "sender-quality-evidence": + return decodeSenderQualityEvidence(data) + case "sfu-publisher-quality-evidence": + return decodeSfuPublisherQualityEvidence(data) + case "reset-sender-quality": + return decodeEmptyClientMessage(data, ResetSenderQualityMessage{Type: messageType}) + case "set-display-name": + return decodeSetDisplayName(data) + case "set-sharing-paused": + return decodeSetSharingPaused(data) + case "stop-sharing": + return decodeStopSharing(data) + case "abandon-room": + return decodeEmptyClientMessage(data, AbandonRoomMessage{Type: messageType}) + } + return nil, fmt.Errorf("unknown client message type %q", messageType) +} + +func decodeEmptyClientMessage(data []byte, message ClientMessage) (ClientMessage, error) { + var body struct { + Type string `json:"type"` + } + present, err := decodeObject(data, &body) + if err != nil { + return nil, err + } + if err := present.require("type"); err != nil { + return nil, err + } + return message, nil +} + +func decodeAuthenticate(data []byte) (ClientMessage, error) { + role, err := typeOf(data, "role") + if err != nil { + return nil, err + } + switch role { + case RoleHost: + var message AuthenticateHostMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "protocol", "roomId", "role", "token", "clientId"); err != nil { + return nil, err + } + if err := present.optional("shareGeneration", "sharingPaused", "qualitySettings", + "routePolicy", "viewerPresence", "displayName"); err != nil { + return nil, err + } + if message.Protocol != SignalingProtocol { + return nil, errors.New("protocol is not the current signaling generation") + } + if !ValidRoomCode(message.RoomID) { + return nil, errors.New("roomId is not a room code") + } + if !ValidToken(message.Token) { + return nil, errors.New("token is malformed") + } + if !ValidOpaqueID(message.ClientID) { + return nil, errors.New("clientId is not an opaque id") + } + if present.has("shareGeneration") && !ValidOpaqueID(message.ShareGeneration) { + return nil, errors.New("shareGeneration is not an opaque id") + } + if present.has("viewerPresence") && !message.ViewerPresence { + return nil, errors.New("viewerPresence must be true when present") + } + if !present.has("routePolicy") { + message.RoutePolicy = DefaultRoutePolicy + } + return message, nil + case RoleViewer: + var message AuthenticateViewerMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "protocol", "roomId", "role", "clientId"); err != nil { + return nil, err + } + if err := present.optional( + "viewerGrant", "viewerPassword", "displayName", "viewerPresence"); err != nil { + return nil, err + } + if message.Protocol != SignalingProtocol { + return nil, errors.New("protocol is not the current signaling generation") + } + if !ValidRoomCode(message.RoomID) { + return nil, errors.New("roomId is not a room code") + } + if !ValidOpaqueID(message.ClientID) { + return nil, errors.New("clientId is not an opaque id") + } + if present.has("viewerGrant") && !ValidViewerGrant(message.ViewerGrant) { + return nil, errors.New("viewerGrant is malformed") + } + if present.has("viewerPassword") && !ValidViewerPassword(message.ViewerPassword) { + return nil, errors.New("viewerPassword is malformed") + } + if present.has("viewerPresence") && !message.ViewerPresence { + return nil, errors.New("viewerPresence must be true when present") + } + return message, nil + } + return nil, fmt.Errorf("unknown authenticate role %q", role) +} + +func decodeSignalingChallenge(data []byte) (ClientMessage, error) { + var message SignalingChallengeMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "sequence"); err != nil { + return nil, err + } + if !inRangeInt(message.Sequence, 0, MaxSafeInteger) { + return nil, errors.New("sequence is out of range") + } + return message, nil +} + +func decodeClientSignal(data []byte) (ClientMessage, error) { + var message ClientSignalMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "payload"); err != nil { + return nil, err + } + if err := present.optional("targetPeerId"); err != nil { + return nil, err + } + if present.has("targetPeerId") && !ValidOpaqueID(message.TargetPeerID) { + return nil, errors.New("targetPeerId is not an opaque id") + } + return message, nil +} + +func decodeClientRestartRequest(data []byte) (ClientMessage, error) { + var message ClientRestartRequestMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "connectionId", "rebuild"); err != nil { + return nil, err + } + if err := present.optional("targetPeerId"); err != nil { + return nil, err + } + if present.has("targetPeerId") && !ValidOpaqueID(message.TargetPeerID) { + return nil, errors.New("targetPeerId is not an opaque id") + } + if !ValidOpaqueID(message.ConnectionID) { + return nil, errors.New("connectionId is not an opaque id") + } + return message, nil +} + +func decodeSetQualitySettings(data []byte) (ClientMessage, error) { + var message SetQualitySettingsMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "qualitySettings"); err != nil { + return nil, err + } + return message, nil +} + +func decodeRelayCapacity(data []byte) (ClientMessage, error) { + var message RelayCapacityMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "downstreamEdges"); err != nil { + return nil, err + } + if !inRangeInt(message.DownstreamEdges, 0, MaxEndpointMediaCopyCapacity) { + return nil, errors.New("downstreamEdges is out of range") + } + return message, nil +} + +func decodeRouteReady(data []byte) (ClientMessage, error) { + var message RouteReadyMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "revision", "phase"); err != nil { + return nil, err + } + if err := present.optional("qualityApproved"); err != nil { + return nil, err + } + if !validRevision(message.Revision) { + return nil, errors.New("revision is out of range") + } + if !enumOf(message.Phase, mediaRoutePhases...) { + return nil, fmt.Errorf("unknown phase %q", message.Phase) + } + if present.has("qualityApproved") && !message.QualityApproved { + return nil, errors.New("qualityApproved must be true when present") + } + return message, nil +} + +func decodeRouteTransportConnected(data []byte) (ClientMessage, error) { + var message RouteTransportConnectedMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "revision", "connectionId"); err != nil { + return nil, err + } + if !validRevision(message.Revision) { + return nil, errors.New("revision is out of range") + } + if !ValidOpaqueID(message.ConnectionID) { + return nil, errors.New("connectionId is not an opaque id") + } + return message, nil +} + +func decodeRouteMediaUnavailable(data []byte) (ClientMessage, error) { + var message RouteMediaUnavailableMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "revision"); err != nil { + return nil, err + } + if !validRevision(message.Revision) { + return nil, errors.New("revision is out of range") + } + return message, nil +} + +func decodeRouteFailed(data []byte) (ClientMessage, error) { + var message RouteFailedMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "revision", "phase"); err != nil { + return nil, err + } + if err := present.requireNullable("connectionId"); err != nil { + return nil, err + } + if !validRevision(message.Revision) { + return nil, errors.New("revision is out of range") + } + if !enumOf(message.Phase, mediaRoutePhases...) { + return nil, fmt.Errorf("unknown phase %q", message.Phase) + } + if message.ConnectionID != nil && !ValidOpaqueID(*message.ConnectionID) { + return nil, errors.New("connectionId is not an opaque id") + } + return message, nil +} + +func decodeRefreshSfu(data []byte) (ClientMessage, error) { + var message RefreshSfuMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "revision"); err != nil { + return nil, err + } + if !validRevision(message.Revision) { + return nil, errors.New("revision is out of range") + } + return message, nil +} + +func decodeViewerQualityEvidence(data []byte) (ClientMessage, error) { + var message ViewerQualityEvidenceMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "guard", "sequence", "windowMs", "metrics"); err != nil { + return nil, err + } + if err := validateEvidenceWindow(message.Sequence, message.WindowMs); err != nil { + return nil, err + } + return message, nil +} + +func validateEvidenceWindow(sequence, windowMs Int) error { + if !inRangeInt(sequence, 0, MaxSafeInteger) { + return errors.New("sequence is out of range") + } + if !inRangeInt(windowMs, 1_000, 5_000) { + return errors.New("windowMs is out of range") + } + return nil +} + +func decodeSenderQualityEvidence(data []byte) (ClientMessage, error) { + var message SenderQualityEvidenceMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "childPeerId", "connectionId", "routeRevision", + "state", "diagnostics"); err != nil { + return nil, err + } + if err := present.requireNullable( + "rtpStatsId", "trackIdentifier", "sampleTimestampMs"); err != nil { + return nil, err + } + if !ValidOpaqueID(message.ChildPeerID) || !ValidOpaqueID(message.ConnectionID) { + return nil, errors.New("sender quality evidence ids are not opaque ids") + } + if err := boundedIdentity(message.RtpStatsID, "rtpStatsId"); err != nil { + return nil, err + } + if err := boundedIdentity(message.TrackIdentifier, "trackIdentifier"); err != nil { + return nil, err + } + if message.SampleTimestampMs != nil && + !inRangeNum(*message.SampleTimestampMs, 0, MaxSafeInteger) { + return nil, errors.New("sampleTimestampMs is out of range") + } + if !validRevision(message.RouteRevision) { + return nil, errors.New("routeRevision is out of range") + } + if !enumOf(message.State, senderQualityStates...) { + return nil, fmt.Errorf("unknown sender quality state %q", message.State) + } + // superRefine: known sender quality needs exact RTP identity. + if message.State != "unknown" && + (message.RtpStatsID == nil || message.TrackIdentifier == nil || + message.SampleTimestampMs == nil) { + return nil, errors.New("Known sender quality needs exact RTP identity") + } + if err := senderQualityReasonMatchesState( + message.State, message.Diagnostics.Reason, + "Sender quality reason must match its native state"); err != nil { + return nil, err + } + return message, nil +} + +func decodeSfuPublisherQualityEvidence(data []byte) (ClientMessage, error) { + var message SfuPublisherQualityEvidenceMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require( + "type", "publicationGeneration", "routeRevision", "state", "diagnostics"); err != nil { + return nil, err + } + if err := present.requireNullable("sampleTimestampMs"); err != nil { + return nil, err + } + if !ValidOpaqueID(message.PublicationGeneration) { + return nil, errors.New("publicationGeneration is not an opaque id") + } + if !validRevision(message.RouteRevision) { + return nil, errors.New("routeRevision is out of range") + } + if !enumOf(message.State, senderQualityStates...) { + return nil, fmt.Errorf("unknown sfu publisher quality state %q", message.State) + } + if message.SampleTimestampMs != nil && + !inRangeNum(*message.SampleTimestampMs, 0, MaxSafeInteger) { + return nil, errors.New("sampleTimestampMs is out of range") + } + // superRefine: known SFU publisher quality needs an exact sample. + if message.State != "unknown" && message.SampleTimestampMs == nil { + return nil, errors.New("Known SFU publisher quality needs an exact sample") + } + if err := senderQualityReasonMatchesState( + message.State, message.Diagnostics.Reason, + "SFU publisher reason must match its native state"); err != nil { + return nil, err + } + return message, nil +} + +func senderQualityReasonMatchesState(state string, reason *string, message string) error { + value := "" + if reason != nil { + value = *reason + } + invalid := (state == "healthy" && value != "none") || + (state == "degraded" && value != "bandwidth" && value != "cpu") || + (state == "unknown" && reason != nil) + if invalid { + return errors.New(message) + } + return nil +} + +func boundedIdentity(value *string, key string) error { + if value == nil { + return nil + } + if length := UTF16Length(*value); length < 1 || length > 256 { + return fmt.Errorf("%s length is out of range", key) + } + return nil +} + +func decodeSetDisplayName(data []byte) (ClientMessage, error) { + var message SetDisplayNameMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "displayName"); err != nil { + return nil, err + } + return message, nil +} + +func decodeSetSharingPaused(data []byte) (ClientMessage, error) { + var message SetSharingPausedMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "shareGeneration", "paused"); err != nil { + return nil, err + } + if !ValidOpaqueID(message.ShareGeneration) { + return nil, errors.New("shareGeneration is not an opaque id") + } + return message, nil +} + +func decodeStopSharing(data []byte) (ClientMessage, error) { + var message StopSharingMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type"); err != nil { + return nil, err + } + if err := present.optional("shareGeneration"); err != nil { + return nil, err + } + if present.has("shareGeneration") && !ValidOpaqueID(message.ShareGeneration) { + return nil, errors.New("shareGeneration is not an opaque id") + } + return message, nil +} diff --git a/internal/server/protocol/codec.go b/internal/server/protocol/codec.go new file mode 100644 index 0000000..60381ca --- /dev/null +++ b/internal/server/protocol/codec.go @@ -0,0 +1,206 @@ +package protocol + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "math" +) + +// fields holds the raw members of one JSON object. Validation reads it to tell +// "absent" from "null" the way zod's .optional() / .nullable() pair does, which +// encoding/json alone cannot express. Duplicate keys collapse to the last +// occurrence, matching JSON.parse. +type fields map[string]json.RawMessage + +// decodeObject decodes one strict (zod `.strict()`) object: unknown keys are +// rejected and the raw member map is returned for presence checks. +func decodeObject(data []byte, target any) (fields, error) { + var raw fields + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + // JSON.parse keeps only the last occurrence of a duplicate key, so the + // earlier ones must never reach the strict decode. Re-encoding the member + // map drops them. + // ponytail: one extra marshal per object; skip it behind a duplicate-key + // scan if signaling ever shows up in a profile. + canonical, err := json.Marshal(raw) + if err != nil { + return nil, err + } + decoder := json.NewDecoder(bytes.NewReader(canonical)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return nil, err + } + return raw, nil +} + +// marshalJSON encodes without HTML escaping, because JSON.stringify leaves +// "<", ">" and "&" alone and display names may carry them. The encoder's +// trailing newline is dropped. +func marshalJSON(value any) ([]byte, error) { + var buffer bytes.Buffer + encoder := json.NewEncoder(&buffer) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(value); err != nil { + return nil, err + } + return bytes.TrimSuffix(buffer.Bytes(), []byte("\n")), nil +} + +func (f fields) has(key string) bool { + _, ok := f[key] + return ok +} + +func (f fields) isNull(key string) bool { + raw, ok := f[key] + return ok && string(bytes.TrimSpace(raw)) == "null" +} + +// require mirrors a plain required zod field: present and not null. +func (f fields) require(keys ...string) error { + for _, key := range keys { + if !f.has(key) { + return fmt.Errorf("%s is required", key) + } + if f.isNull(key) { + return fmt.Errorf("%s must not be null", key) + } + } + return nil +} + +// requireNullable mirrors `.nullable()` without `.optional()`: the key must be +// present but may be null. +func (f fields) requireNullable(keys ...string) error { + for _, key := range keys { + if !f.has(key) { + return fmt.Errorf("%s is required", key) + } + } + return nil +} + +// optional mirrors `.optional()` without `.nullable()`: null is rejected, an +// absent key is not. +func (f fields) optional(keys ...string) error { + for _, key := range keys { + if f.isNull(key) { + return fmt.Errorf("%s must not be null", key) + } + } + return nil +} + +// Int is a JSON number restricted to an integral value, mirroring zod's +// z.number().int(): 5.0 is an integer, 5.5 is not, and values outside the safe +// integer range are rejected (zod 4 rejects them too, and every `.int()` field +// in the contract is bounded by MAX_SAFE_INTEGER anyway). +type Int int64 + +// UnmarshalJSON implements json.Unmarshaler. +func (v *Int) UnmarshalJSON(data []byte) error { + var number float64 + if err := json.Unmarshal(data, &number); err != nil { + return err + } + if math.IsNaN(number) || math.IsInf(number, 0) || + number != math.Trunc(number) || + number < -MaxSafeInteger || number > MaxSafeInteger { + return fmt.Errorf("%s is not a safe integer", string(data)) + } + *v = Int(number) + return nil +} + +// Num is a JSON number restricted to a finite value, mirroring zod's +// z.number().finite(). +type Num float64 + +// UnmarshalJSON implements json.Unmarshaler. +func (v *Num) UnmarshalJSON(data []byte) error { + var number float64 + if err := json.Unmarshal(data, &number); err != nil { + return err + } + if math.IsNaN(number) || math.IsInf(number, 0) { + return fmt.Errorf("%s is not finite", string(data)) + } + *v = Num(number) + return nil +} + +// Nullable carries a value that is both `.nullable()` and `.optional()`, so the +// three wire states (absent, null, value) survive a decode/encode round trip. +type Nullable[T any] struct { + Set bool + Value *T +} + +// IsZero reports whether the key was absent, so `omitzero` drops it. +func (n Nullable[T]) IsZero() bool { return !n.Set } + +// MarshalJSON implements json.Marshaler. +func (n Nullable[T]) MarshalJSON() ([]byte, error) { + if n.Value == nil { + return []byte("null"), nil + } + return marshalJSON(*n.Value) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (n *Nullable[T]) UnmarshalJSON(data []byte) error { + n.Set = true + n.Value = nil + if string(bytes.TrimSpace(data)) == "null" { + return nil + } + var value T + if err := json.Unmarshal(data, &value); err != nil { + return err + } + n.Value = &value + return nil +} + +// errUnknownVariant is the shared "no union member matched" failure. +var errUnknownVariant = errors.New("no matching message variant") + +func enumOf(value string, allowed ...string) bool { + for _, candidate := range allowed { + if value == candidate { + return true + } + } + return false +} + +func inRangeInt(value Int, minimum, maximum int64) bool { + return int64(value) >= minimum && int64(value) <= maximum +} + +func inRangeNum(value Num, minimum, maximum float64) bool { + return float64(value) >= minimum && float64(value) <= maximum +} + +// typeOf reads the union discriminator without imposing strictness, the way +// zod tries each member of z.union / z.discriminatedUnion. +func typeOf(data []byte, key string) (string, error) { + var probe map[string]json.RawMessage + if err := json.Unmarshal(data, &probe); err != nil { + return "", err + } + raw, ok := probe[key] + if !ok { + return "", errUnknownVariant + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", errUnknownVariant + } + return value, nil +} diff --git a/internal/server/protocol/constants.go b/internal/server/protocol/constants.go new file mode 100644 index 0000000..9315642 --- /dev/null +++ b/internal/server/protocol/constants.go @@ -0,0 +1,75 @@ +// Package protocol is the Go owner of the Screener wire contract. It mirrors +// src/shared/protocol.ts (zod schemas) plus the four shared scalar helpers +// (media-copy-accounting.ts, nat-candidate.ts, packet-loss.ts, +// video-codec-evidence.ts). The package performs no I/O and imports nothing +// from other internal packages. +package protocol + +// MaxSafeInteger is JavaScript's Number.MAX_SAFE_INTEGER. +const MaxSafeInteger = 9007199254740991 + +// Limits and identifiers mirrored from src/shared/protocol.ts. +const ( + MaxViewersPerRoomLimit = 20 + MaxParticipantsPerRoomLimit = MaxViewersPerRoomLimit + 1 + MaxSignalBytes = 64 * 1024 + SignalingProtocol = "screener-v21" + RoomCodeLength = 4 + MaxMediaRouteRevision = MaxSafeInteger + MaxSfuTokenLength = 8 * 1024 + MaxIceServerURLs = 8 + + MaxNatPredictionAuxiliaryStunURLs = 2 + + MaxViewerQualityEvidenceBytes = 2 * 1024 + ViewerQualityEvidenceIntervalMs = 2_000 + ViewerQualityEvidenceExpiryMs = 5_000 + PersistentNativeEdgeDegradedWindows = 3 + + MaxDisplayNameCodePoints = 24 + DefaultViewerDisplayName = "观众" + DefaultHostDisplayNamePrefix = "分享者" + + MinViewerPasswordLength = 1 + MaxViewerPasswordLength = 64 + + // MaxStunURLLength is stunUrlSchema's .max(512), in UTF-16 code units. + MaxStunURLLength = 512 +) + +// Endpoint media copy accounting bounds, from src/shared/media-copy-accounting.ts. +const ( + DefaultEndpointMediaCopyCapacity = 2 + MaxEndpointMediaCopyCapacity = 3 +) + +// SignalCloseCode is one of SIGNAL_CLOSE_CODES. +type SignalCloseCode int + +// SIGNAL_CLOSE_CODES. +const ( + SignalCloseServiceRestart SignalCloseCode = 1012 + SignalCloseSessionReplaced SignalCloseCode = 4001 + SignalCloseClientReconnect SignalCloseCode = 4002 + SignalCloseAuthenticationFailed SignalCloseCode = 4003 + SignalCloseViewerAccessRevoked SignalCloseCode = 4004 +) + +// NatTraversalPaths mirrors NAT_TRAVERSAL_PATHS in src/shared/nat-candidate.ts. +var NatTraversalPaths = [...]string{"unknown", "ordinary", "predicted"} + +// DefaultQualitySettings mirrors DEFAULT_QUALITY_SETTINGS. +var DefaultQualitySettings = QualitySettings{ + Resolution: "1080p", + MaxFramerate: 30, + MaxBitrate: 5_000_000, + DegradationPreference: "balanced", + ScreenAudioQuality: "music", +} + +// DefaultRoutePolicy mirrors DEFAULT_ROUTE_POLICY. +var DefaultRoutePolicy = RoutePolicy{ + PeerOnly: false, + TopologyOptimization: true, + NatPrediction: false, +} diff --git a/internal/server/protocol/contract_test.go b/internal/server/protocol/contract_test.go new file mode 100644 index 0000000..b72438f --- /dev/null +++ b/internal/server/protocol/contract_test.go @@ -0,0 +1,659 @@ +package protocol + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "testing" +) + +// member is one JSON object key with its raw encoded value, so the tests can +// reproduce the exact wire shapes from tests/protocol.test.ts (including key +// presence, which a Go map cannot express). +type member struct { + key string + raw string +} + +func object(members []member) []byte { + var builder strings.Builder + builder.WriteByte('{') + for index, entry := range members { + if index > 0 { + builder.WriteByte(',') + } + fmt.Fprintf(&builder, "%q:%s", entry.key, entry.raw) + } + builder.WriteByte('}') + return []byte(builder.String()) +} + +func with(base []member, changes ...member) []member { + out := append([]member(nil), base...) + for _, change := range changes { + replaced := false + for index := range out { + if out[index].key == change.key { + out[index] = change + replaced = true + break + } + } + if !replaced { + out = append(out, change) + } + } + return out +} + +func drop(base []member, keys ...string) []member { + out := make([]member, 0, len(base)) + for _, entry := range base { + remove := false + for _, key := range keys { + if entry.key == key { + remove = true + } + } + if !remove { + out = append(out, entry) + } + } + return out +} + +const ( + testAssignmentNone = `{"upstream":{"kind":"none"},"childPeerIds":[],` + + `"sfuPublicationGeneration":null}` + testQualitySettings = `{"resolution":"1080p","maxFramerate":60,"maxBitrate":8000000,` + + `"degradationPreference":"maintain-resolution"}` + testRoutePolicy = `{"peerOnly":false,"topologyOptimization":true,"natPrediction":false}` + testIceConfig = `{"iceServers":[],"natPredictionStunUrls":[]}` +) + +func authenticatedHost() []member { + return []member{ + {"type", `"authenticated"`}, + {"protocol", `"screener-v21"`}, + {"peerId", `"host_12345678"`}, + {"roomExpiresAt", `"2026-08-18T18:00:00.000Z"`}, + {"maxViewers", `8`}, + {"endpointMediaCopyCapacity", `2`}, + {"hostOnline", `true`}, + {"connectionId", `null`}, + {"iceConfig", testIceConfig}, + {"routePolicy", testRoutePolicy}, + {"codeEntryPolicy", `"open"`}, + {"viewerAuthorizationGeneration", `"viewer_generation_12345678"`}, + {"mediaMode", `"peer-assisted"`}, + {"shareGeneration", `"share_generation_12345678"`}, + {"routeRevision", `0`}, + {"routeAssignment", testAssignmentNone}, + {"qualitySettings", testQualitySettings}, + {"role", `"host"`}, + {"viewerPasswordEnabled", `false`}, + } +} + +func assertServerMessage(t *testing.T, name string, data []byte, want bool) { + t.Helper() + t.Run(name, func(t *testing.T) { + _, err := DecodeServerMessage(data) + if want && err != nil { + t.Fatalf("expected %s to decode, got %v", data, err) + } + if !want && err == nil { + t.Fatalf("expected %s to be rejected", data) + } + }) +} + +func assertClientMessage(t *testing.T, name string, data []byte, want bool) { + t.Helper() + t.Run(name, func(t *testing.T) { + _, err := DecodeClientMessage(data) + if want && err != nil { + t.Fatalf("expected %s to decode, got %v", data, err) + } + if !want && err == nil { + t.Fatalf("expected %s to be rejected", data) + } + }) +} + +// "accepts dynamic viewer limits within the protocol boundary" and +// "rejects viewer limit %s outside the protocol boundary". +func TestAuthenticatedViewerLimits(t *testing.T) { + for _, sample := range []struct { + raw string + want bool + }{{`1`, true}, {`8`, true}, {`20`, true}, {`0`, false}, {`1.5`, false}, {`21`, false}} { + assertServerMessage(t, "maxViewers "+sample.raw, + object(with(authenticatedHost(), member{"maxViewers", sample.raw})), sample.want) + } +} + +// "requires the authenticated endpoint capacity to be 1, 2, or 3". +func TestAuthenticatedEndpointCapacity(t *testing.T) { + for _, sample := range []struct { + raw string + want bool + }{{`1`, true}, {`2`, true}, {`3`, true}, {`0`, false}, {`4`, false}, {`1.5`, false}} { + assertServerMessage(t, "capacity "+sample.raw, + object(with(authenticatedHost(), + member{"endpointMediaCopyCapacity", sample.raw})), sample.want) + } + assertServerMessage(t, "capacity absent", + object(drop(authenticatedHost(), "endpointMediaCopyCapacity")), false) +} + +// "scopes password configuration state to authenticated Hosts". +func TestAuthenticatedPasswordStateIsHostScoped(t *testing.T) { + assertServerMessage(t, "host carries the flag", object(authenticatedHost()), true) + assertServerMessage(t, "host without the flag", + object(drop(authenticatedHost(), "viewerPasswordEnabled")), false) + + viewer := drop(with(authenticatedHost(), + member{"role", `"viewer"`}, member{"peerId", `"viewer_12345678"`}), + "viewerPasswordEnabled") + assertServerMessage(t, "viewer without the flag", object(viewer), true) + assertServerMessage(t, "viewer with the flag", + object(with(viewer, member{"viewerPasswordEnabled", `true`})), false) + assertServerMessage(t, "viewer with hostPaused", + object(with(viewer, member{"hostPaused", `true`})), true) +} + +// "represents persistent rooms without a room expiry" and the revision bound. +func TestAuthenticatedRoomLifetimeAndRevision(t *testing.T) { + assertServerMessage(t, "no expiry", + object(with(authenticatedHost(), member{"roomExpiresAt", `null`})), true) + assertServerMessage(t, "revision at the ceiling", + object(with(authenticatedHost(), member{"routeRevision", `9007199254740991`})), true) + assertServerMessage(t, "revision past the ceiling", + object(with(authenticatedHost(), member{"routeRevision", `9007199254740992`})), false) + assertServerMessage(t, "absent route assignment", + object(drop(authenticatedHost(), "routeAssignment")), false) + assertServerMessage(t, "absent media mode", + object(drop(authenticatedHost(), "mediaMode")), false) + assertServerMessage(t, "sfu standby url", + object(with(authenticatedHost(), + member{"sfuStandbyUrl", `"wss://sfu.example.test"`})), true) + assertServerMessage(t, "sfu standby url over https", + object(with(authenticatedHost(), + member{"sfuStandbyUrl", `"https://sfu.example.test"`})), false) + assertServerMessage(t, "quality settings past the bitrate ceiling", + object(with(authenticatedHost(), member{"qualitySettings", + `{"resolution":"1080p","maxFramerate":60,"maxBitrate":20000000,` + + `"degradationPreference":"maintain-resolution"}`})), false) +} + +// "accepts only STUN URLs in the authenticated peer ICE config" and +// "requires the v20 NAT observation configuration". +func TestAuthenticatedIceConfig(t *testing.T) { + for _, sample := range []struct { + name string + raw string + want bool + }{ + {"empty", `{"iceServers":[],"natPredictionStunUrls":[]}`, true}, + {"one stun url", `{"iceServers":[{"urls":"stun:stun.example.test:3478"}],` + + `"natPredictionStunUrls":[]}`, true}, + {"a list of stun urls", `{"iceServers":[{"urls":["stun:a.example.test:3478",` + + `"stun:b.example.test:3478"]}],"natPredictionStunUrls":[]}`, true}, + {"missing nat prediction urls", `{"iceServers":[]}`, false}, + {"turn url", `{"iceServers":[{"urls":"turn:relay.example.test:3478?transport=udp"}],` + + `"natPredictionStunUrls":[]}`, false}, + {"turn credentials", `{"iceServers":[{"urls":"turn:relay.example.test:3478",` + + `"username":"1787076000","credential":"credential"}],` + + `"natPredictionStunUrls":[]}`, false}, + {"stuns url", `{"iceServers":[{"urls":"stuns:stun.example.test:5349"}],` + + `"natPredictionStunUrls":[]}`, false}, + {"too many nat prediction urls", `{"iceServers":[],"natPredictionStunUrls":` + + `["stun:a.example.test:3478","stun:b.example.test:3478",` + + `"stun:c.example.test:3478"]}`, false}, + } { + assertServerMessage(t, "iceConfig "+sample.name, + object(with(authenticatedHost(), member{"iceConfig", sample.raw})), sample.want) + } +} + +// "keeps Viewer route status as a strict crossed union". +func TestRouteStatusCrossedUnion(t *testing.T) { + for _, sample := range []struct { + name string + raw string + want bool + }{ + {"waiting on admission", + `{"type":"route-status","revision":3,"state":"waiting","reason":"sfu-admission"}`, true}, + {"failed on exhaustion", + `{"type":"route-status","revision":4,"state":"failed","reason":"route-exhausted"}`, true}, + {"waiting on exhaustion", + `{"type":"route-status","revision":3,"state":"waiting","reason":"route-exhausted"}`, false}, + {"failed on admission", + `{"type":"route-status","revision":3,"state":"failed","reason":"sfu-admission"}`, false}, + {"with a retry hint", + `{"type":"route-status","revision":3,"state":"waiting","reason":"sfu-admission",` + + `"retryAfterMs":1000}`, false}, + {"with a peer id", + `{"type":"route-status","revision":3,"state":"waiting","reason":"sfu-admission",` + + `"peerId":"private-peer-id"}`, false}, + } { + assertServerMessage(t, sample.name, []byte(sample.raw), sample.want) + } +} + +// "accepts a strict, unique and bounded Viewer presence snapshot". +func TestViewerPresence(t *testing.T) { + const viewer = `{"role":"viewer","peerId":"viewer_12345678","displayName":"小明",` + + `"upstream":{"kind":"peer","peerId":"viewer_parent_12345678"}}` + for _, sample := range []struct { + name string + raw string + want bool + }{ + {"sfu upstream with mediaReady", + `{"type":"viewer-presence","viewers":[{"role":"viewer","peerId":"viewer_12345678",` + + `"displayName":"小明","upstream":{"kind":"sfu"},"mediaReady":true}]}`, true}, + {"mediaReady false", `{"type":"viewer-presence","viewers":[{"role":"viewer",` + + `"peerId":"viewer_12345678","displayName":"小明",` + + `"upstream":{"kind":"peer","peerId":"viewer_parent_12345678"},` + + `"mediaReady":false}]}`, false}, + {"duplicate peer ids", + `{"type":"viewer-presence","viewers":[` + viewer + `,` + viewer + `]}`, false}, + {"leaked topology", `{"type":"viewer-presence","viewers":[{"role":"viewer",` + + `"peerId":"viewer_12345678","displayName":"小明",` + + `"upstream":{"kind":"none"},"mediaTopology":"peer-relay"}]}`, false}, + {"leaked address", `{"type":"viewer-presence","viewers":[{"role":"viewer",` + + `"peerId":"viewer_12345678","displayName":"小明",` + + `"upstream":{"kind":"none"},"ip":"203.0.113.1"}]}`, false}, + {"host entry", `{"type":"viewer-presence","viewers":[` + viewer + `,` + + `{"role":"host","peerId":"host_12345678","displayName":"分享者",` + + `"upstream":{"kind":"none"}}]}`, true}, + {"host entry with an upstream", `{"type":"viewer-presence","viewers":[` + + `{"role":"host","peerId":"host_12345678","displayName":"分享者",` + + `"upstream":{"kind":"sfu"}}]}`, false}, + {"host entry with mediaReady", `{"type":"viewer-presence","viewers":[` + + `{"role":"host","peerId":"host_12345678","displayName":"分享者",` + + `"upstream":{"kind":"none"},"mediaReady":true}]}`, false}, + } { + assertServerMessage(t, sample.name, []byte(sample.raw), sample.want) + } +} + +func TestViewerPresenceConstructors(t *testing.T) { + host := HostPresenceEntry("host_12345678", "分享者") + if host.Upstream != NoUpstream() || host.MediaReady { + t.Fatalf("host presence entry must carry {kind:\"none\"} and no mediaReady: %+v", host) + } + encoded, err := marshalJSON(NewViewerPresenceEntry( + "viewer_12345678", "小明", SfuUpstream(), false)) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(encoded, []byte("mediaReady")) { + t.Fatalf("mediaReady must be omitted when false: %s", encoded) + } + encoded, err = marshalJSON(NewViewerPresenceEntry( + "viewer_12345678", "小明", SfuUpstream(), true)) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(encoded, []byte(`"mediaReady":true`)) { + t.Fatalf("mediaReady must be emitted when true: %s", encoded) + } +} + +// "accepts only the privacy-safe route snapshot payload" plus the superRefine. +func TestRouteDiagnosticSnapshot(t *testing.T) { + const childOne = `{"ordinal":1,"parent":{"kind":"host"},"effectiveCapacity":2,` + + `"childCount":1,"demandAgeMs":100,"queueWaitMs":10,"candidateStartMs":20,` + + `"firstDecodedFrameMs":80,"finalMs":80,"finalRoute":"direct","rejectionBucket":"none",` + + `"quality":{"eligibleWindows":3,"eligibleDurationMs":6000,"freezeWindows":1,` + + `"freezeCount":1,"freezeDurationMs":250,"pauseCount":0,"pauseDurationMs":0}}` + childTwo := func(parent string) string { + return `{"ordinal":2,"parent":` + parent + `,"effectiveCapacity":0,"childCount":0,` + + `"demandAgeMs":90,"queueWaitMs":null,"candidateStartMs":null,` + + `"firstDecodedFrameMs":null,"finalMs":null,"finalRoute":"waiting",` + + `"rejectionBucket":"sfu-admission","quality":null}` + } + snapshot := func(children, operation string) []byte { + return []byte(`{"type":"route-diagnostic-snapshot","snapshot":{"children":` + + children + `,"operation":` + operation + `}}`) + } + const operation = `{"childOrdinal":2,"reason":"join","stage":"admission",` + + `"cursor":1,"candidateCount":2}` + + assertServerMessage(t, "full snapshot", + snapshot(`[`+childOne+`,`+childTwo(`{"kind":"viewer","ordinal":1}`)+`]`, operation), true) + assertServerMessage(t, "empty snapshot", snapshot(`[]`, `null`), true) + assertServerMessage(t, "leaked peer id", + []byte(`{"type":"route-diagnostic-snapshot","snapshot":{"children":[],`+ + `"operation":null,"peerId":"viewer_private_12345678"}}`), false) + assertServerMessage(t, "leaked connection id", + snapshot(`[{"ordinal":1,"parent":{"kind":"host"},"effectiveCapacity":2,"childCount":1,`+ + `"demandAgeMs":100,"queueWaitMs":10,"candidateStartMs":20,`+ + `"firstDecodedFrameMs":80,"finalMs":80,"finalRoute":"direct",`+ + `"rejectionBucket":"none","quality":null,`+ + `"connectionId":"connection_private_12345678"}]`, `null`), false) + assertServerMessage(t, "negative freeze count", + snapshot(`[{"ordinal":1,"parent":{"kind":"host"},"effectiveCapacity":2,"childCount":1,`+ + `"demandAgeMs":100,"queueWaitMs":10,"candidateStartMs":20,`+ + `"firstDecodedFrameMs":80,"finalMs":80,"finalRoute":"direct",`+ + `"rejectionBucket":"none","quality":{"eligibleWindows":3,`+ + `"eligibleDurationMs":6000,"freezeWindows":1,"freezeCount":-1,`+ + `"freezeDurationMs":250,"pauseCount":0,"pauseDurationMs":0}}]`, `null`), false) + assertServerMessage(t, "self parent", + snapshot(`[`+childOne+`,`+childTwo(`{"kind":"viewer","ordinal":2}`)+`]`, `null`), false) + assertServerMessage(t, "unknown parent ordinal", + snapshot(`[`+childOne+`,`+childTwo(`{"kind":"viewer","ordinal":7}`)+`]`, `null`), false) + assertServerMessage(t, "duplicate ordinals", + snapshot(`[`+childOne+`,`+childOne+`]`, `null`), false) + assertServerMessage(t, "operation names an absent child", + snapshot(`[`+childOne+`]`, operation), false) + assertServerMessage(t, "cursor past the candidate count", + snapshot(`[`+childOne+`,`+childTwo(`{"kind":"viewer","ordinal":1}`)+`]`, + `{"childOrdinal":2,"reason":"join","stage":"admission","cursor":2,`+ + `"candidateCount":2}`), false) + assertServerMessage(t, "raw rejection bucket on the operation", + snapshot(`[`+childOne+`,`+childTwo(`{"kind":"viewer","ordinal":1}`)+`]`, + `{"childOrdinal":2,"reason":"join","stage":"admission","cursor":1,`+ + `"candidateCount":2,"rejectionBucket":"raw-error"}`), false) + assertServerMessage(t, "parent ordinal on a host parent", + snapshot(`[{"ordinal":1,"parent":{"kind":"host","ordinal":1},"effectiveCapacity":2,`+ + `"childCount":1,"demandAgeMs":null,"queueWaitMs":null,"candidateStartMs":null,`+ + `"firstDecodedFrameMs":null,"finalMs":null,"finalRoute":"direct",`+ + `"rejectionBucket":"none","quality":null}]`, `null`), false) +} + +// "keeps route messages strict alongside the single authenticated mode" and +// "accepts every bounded hybrid upstream shape". +func TestParticipantRouteAssignment(t *testing.T) { + routeUpdate := func(assignment string) []byte { + return []byte(`{"type":"route-update","revision":9,"phase":"active","assignment":` + + assignment + `}`) + } + for _, sample := range []struct { + name string + assignment string + want bool + }{ + {"no upstream", testAssignmentNone, true}, + {"peer upstream", `{"upstream":{"kind":"peer","peerId":"parent_12345678"},` + + `"childPeerIds":["child_12345678","child_87654321"],` + + `"sfuPublicationGeneration":null}`, true}, + {"sfu upstream", `{"upstream":{"kind":"sfu"},"childPeerIds":[],` + + `"sfuPublicationGeneration":"generation_12345678"}`, true}, + {"sfu upstream without a generation", `{"upstream":{"kind":"sfu"},` + + `"childPeerIds":[],"sfuPublicationGeneration":null}`, false}, + {"peer upstream owning a generation", + `{"upstream":{"kind":"peer","peerId":"parent_12345678"},"childPeerIds":[],` + + `"sfuPublicationGeneration":"generation_12345678"}`, false}, + {"too many children", `{"upstream":{"kind":"none"},"childPeerIds":` + + `["child_12345678","child_87654321","child_third_1234","child_overflow_1"],` + + `"sfuPublicationGeneration":null}`, false}, + {"duplicate children", `{"upstream":{"kind":"none"},` + + `"childPeerIds":["child_12345678","child_12345678"],` + + `"sfuPublicationGeneration":null}`, false}, + {"absent generation key", `{"upstream":{"kind":"none"},"childPeerIds":[]}`, false}, + {"peer id on a none upstream", `{"upstream":{"kind":"none",` + + `"peerId":"parent_12345678"},"childPeerIds":[],` + + `"sfuPublicationGeneration":null}`, false}, + } { + assertServerMessage(t, "assignment "+sample.name, + routeUpdate(sample.assignment), sample.want) + } + + assertServerMessage(t, "prepare without a candidate", + []byte(`{"type":"route-update","revision":9,"phase":"prepare","assignment":`+ + testAssignmentNone+`}`), false) + assertServerMessage(t, "active with a candidate", + []byte(`{"type":"route-update","revision":9,"phase":"active","assignment":`+ + testAssignmentNone+`,"candidate":{"childPeerId":"child_12345678",`+ + `"connectionId":"connection_12345678","transport":"direct","qualityProbe":false}}`), + false) + assertServerMessage(t, "prepare with a room-wide field", + []byte(`{"type":"route-update","revision":9,"phase":"prepare","assignment":`+ + testAssignmentNone+`,"roomAssignments":[]}`), false) +} + +// "accepts only bounded connection-attempt progress on a prepared route". +func TestPreparedRouteCandidate(t *testing.T) { + prepared := func(candidate string) []byte { + return []byte(`{"type":"route-update","revision":9,"phase":"prepare","assignment":` + + testAssignmentNone + `,"candidate":` + candidate + `}`) + } + const base = `"childPeerId":"child_12345678","connectionId":"connection_12345678",` + + `"transport":"direct","qualityProbe":false` + for _, sample := range []struct { + attempt string + want bool + }{ + {`{"current":1,"total":3}`, true}, + {`{"current":2,"total":3}`, true}, + {`{"current":3,"total":3}`, true}, + {`{"current":0,"total":3}`, false}, + {`{"current":4,"total":3}`, false}, + {`{"current":1,"total":4}`, false}, + {`{"current":1.5,"total":3}`, false}, + } { + assertServerMessage(t, "connectionAttempt "+sample.attempt, + prepared(`{`+base+`,"connectionAttempt":`+sample.attempt+`}`), sample.want) + } + assertServerMessage(t, "candidate without an attempt", prepared(`{`+base+`}`), true) + assertServerMessage(t, "candidate with an unknown key", + prepared(`{`+base+`,"unexpected":null}`), false) + assertServerMessage(t, "candidate with a null attempt", + prepared(`{`+base+`,"connectionAttempt":null}`), false) +} + +// "accepts only canonical server-derived viewer evidence envelopes" and +// "keeps route messages strict" for sfu-config. +func TestForwardedEvidenceAndSfuConfig(t *testing.T) { + const metrics = `{"natTraversalPath":"ordinary","width":null,"height":null,` + + `"framesPerSecond":null,"bitrateKbps":null,"packetsReceivedDelta":null,` + + `"packetsLostDelta":null,"rttMs":null,"jitterMs":null,"framesDecodedDelta":null,` + + `"framesDroppedDelta":null,"decodeMsPerFrame":null,"freezeCountDelta":null,` + + `"freezeDurationMsDelta":null,"pauseCountDelta":null,"pauseDurationMsDelta":null,` + + `"codec":null,"codecProfile":null,"codecParameters":null,"audioBitrateKbps":null,` + + `"audioPacketLossPercent":null,"audioJitterMs":null,"audioVideoPlayoutDeltaMs":null,` + + `"videoJitterBufferDelayMs":null,"audioJitterBufferDelayMs":null,` + + `"audioConcealedSamplesPercent":null,"audioConcealmentEventsDelta":null,` + + `"audioCodec":null}` + forwarded := func(extra string, upstream string) []byte { + return []byte(`{"type":"viewer-quality-evidence","viewerPeerId":"viewer_12345678",` + + `"upstream":` + upstream + `,"guard":{"connectionId":"connection_12345678",` + + `"routeRevision":0,"presentationEpoch":0},"sequence":0,"windowMs":2000,` + + `"metrics":` + metrics + extra + `}`) + } + assertServerMessage(t, "peer upstream", + forwarded("", `{"kind":"peer","peerId":"host_12345678"}`), true) + assertServerMessage(t, "sfu upstream", forwarded("", `{"kind":"sfu"}`), true) + assertServerMessage(t, "no upstream", forwarded("", `{"kind":"none"}`), false) + assertServerMessage(t, "leaked room id", + forwarded(`,"roomId":"1234"`, `{"kind":"sfu"}`), false) + assertServerMessage(t, "missing upstream", + []byte(`{"type":"viewer-quality-evidence","viewerPeerId":"viewer_12345678",`+ + `"guard":{"connectionId":"connection_12345678","routeRevision":0,`+ + `"presentationEpoch":0},"sequence":0,"windowMs":2000,"metrics":`+metrics+`}`), false) + + assertServerMessage(t, "sfu config", + []byte(`{"type":"sfu-config","revision":9,"url":"wss://sfu.example.test",`+ + `"token":"header.payload.signature"}`), true) + assertServerMessage(t, "sfu config with an oversized token", + []byte(`{"type":"sfu-config","revision":9,"url":"wss://sfu.example.test","token":"`+ + repeat("x", MaxSfuTokenLength+1)+`"}`), false) +} + +// zod's z.number().int() accepts an integral JSON number in any spelling. +func TestIntAcceptsIntegralNumberForms(t *testing.T) { + for _, sample := range []struct { + raw string + want bool + }{ + {`7`, true}, {`7.0`, true}, {`7e0`, true}, {`1e3`, true}, + {`7.5`, false}, {`"7"`, false}, {`true`, false}, + } { + assertClientMessage(t, "revision "+sample.raw, + []byte(`{"type":"route-media-unavailable","revision":`+sample.raw+`}`), sample.want) + } +} + +// JSON.parse keeps the last occurrence of a duplicate key. +func TestDuplicateKeysKeepTheLastOccurrence(t *testing.T) { + message, err := DecodeClientMessage( + []byte(`{"type":"relay-capacity","downstreamEdges":9,"downstreamEdges":2}`)) + if err != nil { + t.Fatalf("the last occurrence must win: %v", err) + } + if capacity, ok := message.(RelayCapacityMessage); !ok || capacity.DownstreamEdges != 2 { + t.Fatalf("decoded %#v, want downstreamEdges 2", message) + } + if _, err := DecodeClientMessage( + []byte(`{"type":"relay-capacity","downstreamEdges":2,"downstreamEdges":9}`)); err == nil { + t.Fatal("the last occurrence must be the one validated") + } + // A duplicated object is replaced, not merged. + if _, err := DecodeClientMessage( + []byte(`{"type":"set-quality-settings","qualitySettings":{"resolution":"480p"},` + + `"qualitySettings":` + testQualitySettings + `}`)); err != nil { + t.Fatalf("a duplicated object must be replaced wholesale: %v", err) + } +} + +// JSON.stringify does not escape "<", ">" or "&"; display names may hold them. +func TestEncodeServerMessageDoesNotEscapeHTML(t *testing.T) { + encoded, err := EncodeServerMessage(ViewerPresenceMessage{ + Type: "viewer-presence", + Viewers: []ParticipantPresenceEntry{ + HostPresenceEntry("host_12345678", "a&c"), + }, + }) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(encoded, []byte(`"displayName":"a&c"`)) { + t.Fatalf("display name must survive unescaped: %s", encoded) + } + if bytes.HasSuffix(encoded, []byte("\n")) { + t.Fatalf("the encoder newline must be dropped: %q", encoded) + } +} + +// Empty collections encode as [] the way the TypeScript literals do. +func TestEncodeServerMessageEmitsEmptyArrays(t *testing.T) { + encoded, err := EncodeServerMessage(ViewerPresenceMessage{Type: "viewer-presence"}) + if err != nil { + t.Fatal(err) + } + if string(encoded) != `{"type":"viewer-presence","viewers":[]}` { + t.Fatalf("got %s", encoded) + } + encoded, err = EncodeServerMessage(RouteDiagnosticSnapshotMessage{ + Type: "route-diagnostic-snapshot"}) + if err != nil { + t.Fatal(err) + } + if string(encoded) != + `{"type":"route-diagnostic-snapshot","snapshot":{"children":[],"operation":null}}` { + t.Fatalf("got %s", encoded) + } +} + +// The authenticated key order follows the object literal in signaling.ts. +func TestAuthenticatedKeyOrder(t *testing.T) { + standby := "wss://sfu.example.test" + paused := true + encoded, err := EncodeServerMessage(AuthenticatedViewerMessage{ + Type: "authenticated", Protocol: SignalingProtocol, PeerID: "viewer_12345678", + MaxViewers: 8, EndpointMediaCopyCapacity: 2, HostOnline: true, HostPaused: &paused, + IceConfig: IceConfig{}, RoutePolicy: DefaultRoutePolicy, CodeEntryPolicy: CodeEntryOpen, + ViewerAuthorizationGeneration: "viewer_generation_12345678", + MediaMode: "peer-assisted", RouteRevision: 0, + QualitySettings: DefaultQualitySettings, SfuStandbyURL: &standby, Role: RoleViewer, + }) + if err != nil { + t.Fatal(err) + } + var order []string + decoder := json.NewDecoder(bytes.NewReader(encoded)) + if _, err := decoder.Token(); err != nil { + t.Fatal(err) + } + for decoder.More() { + key, err := decoder.Token() + if err != nil { + t.Fatal(err) + } + order = append(order, key.(string)) + var skip json.RawMessage + if err := decoder.Decode(&skip); err != nil { + t.Fatal(err) + } + } + want := []string{ + "type", "protocol", "peerId", "roomExpiresAt", "maxViewers", + "endpointMediaCopyCapacity", "hostOnline", "hostPaused", "connectionId", + "iceConfig", "routePolicy", "codeEntryPolicy", "viewerAuthorizationGeneration", + "mediaMode", "shareGeneration", "routeRevision", "routeAssignment", + "qualitySettings", "sfuStandbyUrl", "role", + } + if strings.Join(order, ",") != strings.Join(want, ",") { + t.Fatalf("key order\n got: %v\nwant: %v", order, want) + } +} + +// Optional keys reject an explicit null; nullable keys reject absence. +func TestOptionalAndNullableAreExact(t *testing.T) { + for _, sample := range []struct { + name string + raw string + want bool + }{ + {"null optional shareGeneration", + `{"type":"stop-sharing","shareGeneration":null}`, false}, + {"absent optional shareGeneration", `{"type":"stop-sharing"}`, true}, + {"null optional targetPeerId", + `{"type":"restart-request","targetPeerId":null,` + + `"connectionId":"connection_12345678","rebuild":true}`, false}, + {"null optional qualityApproved", + `{"type":"route-ready","revision":1,"phase":"prepare","qualityApproved":null}`, false}, + {"null nullable connectionId", + `{"type":"route-failed","revision":1,"phase":"active","connectionId":null}`, true}, + {"absent nullable connectionId", + `{"type":"route-failed","revision":1,"phase":"active"}`, false}, + } { + assertClientMessage(t, sample.name, []byte(sample.raw), sample.want) + } +} + +// A host authenticate without routePolicy takes DEFAULT_ROUTE_POLICY. +func TestAuthenticateAppliesTheRoutePolicyDefault(t *testing.T) { + message, err := DecodeClientMessage([]byte( + `{"type":"authenticate","protocol":"screener-v21","roomId":"1234","role":"host",` + + `"token":"` + repeat("a", 43) + `","clientId":"client_12345678"}`)) + if err != nil { + t.Fatal(err) + } + host, ok := message.(AuthenticateHostMessage) + if !ok { + t.Fatalf("decoded %#v, want AuthenticateHostMessage", message) + } + if host.RoutePolicy != DefaultRoutePolicy { + t.Fatalf("routePolicy = %+v, want %+v", host.RoutePolicy, DefaultRoutePolicy) + } +} + +// The sealed unions accept nothing outside their own message structs. +func TestDecodeRejectsUnknownEnvelopes(t *testing.T) { + for _, raw := range []string{`{}`, `[]`, `null`, `"authenticate"`, `{"type":42}`, + `{"type":"unknown"}`, `{"kind":"signal"}`} { + if _, err := DecodeClientMessage([]byte(raw)); err == nil { + t.Errorf("client decode accepted %s", raw) + } + if _, err := DecodeServerMessage([]byte(raw)); err == nil { + t.Errorf("server decode accepted %s", raw) + } + } +} diff --git a/internal/server/protocol/fixture_test.go b/internal/server/protocol/fixture_test.go new file mode 100644 index 0000000..caade3d --- /dev/null +++ b/internal/server/protocol/fixture_test.go @@ -0,0 +1,149 @@ +package protocol + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" +) + +// The fixture is the single owner of the cross-language wire samples; +// tests/protocol-fixture.test.ts replays the same file against the zod schemas. +const fixturePath = "../../../tests/fixtures/wire-samples.json" + +type wireFixture struct { + Constants map[string]any `json:"constants"` + ClientMessages []struct { + Name string `json:"name"` + Schema string `json:"schema"` + JSON string `json:"json"` + Valid bool `json:"valid"` + } `json:"clientMessages"` + ServerMessages []struct { + Name string `json:"name"` + JSON string `json:"json"` + } `json:"serverMessages"` +} + +func loadFixture(t *testing.T) wireFixture { + t.Helper() + raw, err := os.ReadFile(filepath.FromSlash(fixturePath)) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + var fixture wireFixture + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatalf("parse fixture: %v", err) + } + return fixture +} + +func TestFixtureConstants(t *testing.T) { + fixture := loadFixture(t) + expected := map[string]any{ + "signalingProtocol": SignalingProtocol, + "maxViewersPerRoomLimit": float64(MaxViewersPerRoomLimit), + "maxParticipantsPerRoomLimit": float64(MaxParticipantsPerRoomLimit), + "maxSignalBytes": float64(MaxSignalBytes), + "roomCodeLength": float64(RoomCodeLength), + "maxMediaRouteRevision": float64(MaxMediaRouteRevision), + "maxSfuTokenLength": float64(MaxSfuTokenLength), + "maxIceServerUrls": float64(MaxIceServerURLs), + "maxNatPredictionAuxiliaryStunUrls": float64(MaxNatPredictionAuxiliaryStunURLs), + "maxViewerQualityEvidenceBytes": float64(MaxViewerQualityEvidenceBytes), + "viewerQualityEvidenceIntervalMs": float64(ViewerQualityEvidenceIntervalMs), + "viewerQualityEvidenceExpiryMs": float64(ViewerQualityEvidenceExpiryMs), + "persistentNativeEdgeDegradedWindows": float64(PersistentNativeEdgeDegradedWindows), + "maxDisplayNameCodePoints": float64(MaxDisplayNameCodePoints), + "defaultViewerDisplayName": DefaultViewerDisplayName, + "defaultHostDisplayNamePrefix": DefaultHostDisplayNamePrefix, + "minViewerPasswordLength": float64(MinViewerPasswordLength), + "maxViewerPasswordLength": float64(MaxViewerPasswordLength), + "defaultEndpointMediaCopyCapacity": float64(DefaultEndpointMediaCopyCapacity), + "maxEndpointMediaCopyCapacity": float64(MaxEndpointMediaCopyCapacity), + "signalCloseCodeServiceRestart": float64(SignalCloseServiceRestart), + "signalCloseCodeSessionReplaced": float64(SignalCloseSessionReplaced), + "signalCloseCodeClientReconnect": float64(SignalCloseClientReconnect), + "signalCloseCodeAuthenticationFailed": float64(SignalCloseAuthenticationFailed), + "signalCloseCodeViewerAccessRevoked": float64(SignalCloseViewerAccessRevoked), + "defaultQualitySettings": jsonValue(t, DefaultQualitySettings), + "defaultRoutePolicy": jsonValue(t, DefaultRoutePolicy), + } + if !reflect.DeepEqual(fixture.Constants, expected) { + t.Fatalf("constants mismatch\n got: %#v\nwant: %#v", fixture.Constants, expected) + } +} + +func TestFixtureClientSamples(t *testing.T) { + fixture := loadFixture(t) + if len(fixture.ClientMessages) == 0 { + t.Fatal("fixture carries no client samples") + } + for _, sample := range fixture.ClientMessages { + t.Run(sample.Name, func(t *testing.T) { + var err error + switch sample.Schema { + case "client": + _, err = DecodeClientMessage([]byte(sample.JSON)) + case "createRoomRequest": + _, err = DecodeCreateRoomRequest([]byte(sample.JSON)) + case "replaceRoomRequest": + _, err = DecodeReplaceRoomRequest([]byte(sample.JSON)) + case "roomAccessUpdateRequest": + _, err = DecodeRoomAccessUpdateRequest([]byte(sample.JSON)) + default: + t.Fatalf("unknown fixture schema %q", sample.Schema) + } + if sample.Valid && err != nil { + t.Fatalf("expected the sample to decode, got %v", err) + } + if !sample.Valid && err == nil { + t.Fatal("expected the sample to be rejected") + } + }) + } +} + +func TestFixtureServerSamplesRoundTrip(t *testing.T) { + fixture := loadFixture(t) + if len(fixture.ServerMessages) == 0 { + t.Fatal("fixture carries no server samples") + } + for _, sample := range fixture.ServerMessages { + t.Run(sample.Name, func(t *testing.T) { + message, err := DecodeServerMessage([]byte(sample.JSON)) + if err != nil { + t.Fatalf("decode: %v", err) + } + encoded, err := EncodeServerMessage(message) + if err != nil { + t.Fatalf("encode: %v", err) + } + var want, got any + if err := json.Unmarshal([]byte(sample.JSON), &want); err != nil { + t.Fatalf("parse sample: %v", err) + } + if err := json.Unmarshal(encoded, &got); err != nil { + t.Fatalf("parse re-encoded message: %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("round trip changed the message\n got: %s\nwant: %s", + encoded, sample.JSON) + } + }) + } +} + +func jsonValue(t *testing.T, value any) any { + t.Helper() + encoded, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded any + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return decoded +} diff --git a/internal/server/protocol/helpers.go b/internal/server/protocol/helpers.go new file mode 100644 index 0000000..923172a --- /dev/null +++ b/internal/server/protocol/helpers.go @@ -0,0 +1,201 @@ +package protocol + +import ( + "errors" + "regexp" + "slices" + "strings" +) + +// --------------------------------------------------------------------------- +// src/shared/media-copy-accounting.ts +// --------------------------------------------------------------------------- + +// EndpointMediaCopyPhase is EndpointMediaCopyPhase. +type EndpointMediaCopyPhase string + +// Endpoint media copy phases. +const ( + EndpointMediaCopySteady EndpointMediaCopyPhase = "steady" + EndpointMediaCopyTransition EndpointMediaCopyPhase = "transition" +) + +// ErrEndpointMediaCopyCapacity is the assertEndpointMediaCopyCapacity throw. +var ErrEndpointMediaCopyCapacity = errors.New( + "Endpoint media copy capacity must be 1, 2, or 3") + +// EndpointMediaCopyLimit ports endpointMediaCopyLimit. It panics on an invalid +// capacity, which is where the TypeScript throws. +func EndpointMediaCopyLimit(capacity int, phase EndpointMediaCopyPhase) int { + if err := AssertEndpointMediaCopyCapacity(capacity); err != nil { + panic(err) + } + if phase == EndpointMediaCopySteady { + return capacity + } + return min(capacity+1, MaxEndpointMediaCopyCapacity) +} + +// EndpointMediaCopyCountFits ports endpointMediaCopyCountFits. +func EndpointMediaCopyCountFits(count, capacity int, phase EndpointMediaCopyPhase) bool { + return count >= 0 && + IsEndpointMediaCopyCapacity(capacity) && + count <= EndpointMediaCopyLimit(capacity, phase) +} + +// IsEndpointMediaCopyCapacity ports isEndpointMediaCopyCapacity. The +// TypeScript Number.isSafeInteger guard is implicit in Go's int. +func IsEndpointMediaCopyCapacity(capacity int) bool { + return capacity >= 1 && capacity <= MaxEndpointMediaCopyCapacity +} + +// AssertEndpointMediaCopyCapacity ports assertEndpointMediaCopyCapacity. +func AssertEndpointMediaCopyCapacity(capacity int) error { + if !IsEndpointMediaCopyCapacity(capacity) { + return ErrEndpointMediaCopyCapacity + } + return nil +} + +// --------------------------------------------------------------------------- +// src/shared/nat-candidate.ts +// --------------------------------------------------------------------------- + +// CandidateSignalOrigin is CandidateSignalOrigin. +type CandidateSignalOrigin string + +// Candidate signal origins. +const ( + CandidateOriginEnd CandidateSignalOrigin = "end" + CandidateOriginOrdinary CandidateSignalOrigin = "ordinary" + CandidateOriginPredicted CandidateSignalOrigin = "predicted" +) + +// The JavaScript \s class, spelled out because Go's \s is ASCII only. +var predictedCandidatePattern = regexp.MustCompile( + `(?i)^candidate:s[pm][0-9]+[\t\n\v\f\r \x{00a0}\x{1680}\x{2000}-\x{200a}` + + `\x{2028}\x{2029}\x{202f}\x{205f}\x{3000}\x{feff}]`) + +// CandidateSignalOriginOf ports candidateSignalOrigin. An empty string stands +// in for the TypeScript null (both yield "end"). +func CandidateSignalOriginOf(candidate string) CandidateSignalOrigin { + if strings.TrimFunc(candidate, IsJSWhitespace) == "" { + return CandidateOriginEnd + } + if predictedCandidatePattern.MatchString(candidate) { + return CandidateOriginPredicted + } + return CandidateOriginOrdinary +} + +// --------------------------------------------------------------------------- +// src/shared/packet-loss.ts +// --------------------------------------------------------------------------- + +// PacketLossPercentFromDeltas ports packetLossPercentFromDeltas. A nil +// argument is the TypeScript null; ok is false where it returns null. +func PacketLossPercentFromDeltas(packetsReceivedDelta, packetsLostDelta *float64) (float64, bool) { + if packetsReceivedDelta == nil || packetsLostDelta == nil || + *packetsReceivedDelta < 0 || *packetsLostDelta < 0 { + return 0, false + } + packetDelta := *packetsReceivedDelta + *packetsLostDelta + if packetDelta <= 0 { + return 0, false + } + return (*packetsLostDelta / packetDelta) * 100, true +} + +// --------------------------------------------------------------------------- +// src/shared/video-codec-evidence.ts +// --------------------------------------------------------------------------- + +type codecParameterRule struct { + profileKey string + parameterKeys []string + validators map[string]*regexp.Regexp +} + +var videoCodecParameterRules = map[string]codecParameterRule{ + "video/h264": { + profileKey: "profile-level-id", + parameterKeys: []string{"packetization-mode", "level-asymmetry-allowed"}, + validators: map[string]*regexp.Regexp{ + "profile-level-id": regexp.MustCompile(`^[0-9a-f]{6}$`), + "packetization-mode": regexp.MustCompile(`^[0-2]$`), + "level-asymmetry-allowed": regexp.MustCompile(`^[01]$`), + }, + }, + "video/vp9": { + profileKey: "profile-id", + parameterKeys: []string{"max-fr", "max-fs"}, + validators: map[string]*regexp.Regexp{ + "profile-id": regexp.MustCompile(`^[0-3]$`), + "max-fr": regexp.MustCompile(`^[1-9][0-9]{0,9}$`), + "max-fs": regexp.MustCompile(`^[1-9][0-9]{0,9}$`), + }, + }, + "video/vp8": { + profileKey: "", + parameterKeys: []string{"max-fr", "max-fs"}, + validators: map[string]*regexp.Regexp{ + "max-fr": regexp.MustCompile(`^[1-9][0-9]{0,9}$`), + "max-fs": regexp.MustCompile(`^[1-9][0-9]{0,9}$`), + }, + }, + "video/av1": { + profileKey: "profile", + parameterKeys: []string{"level-idx", "tier"}, + validators: map[string]*regexp.Regexp{ + "profile": regexp.MustCompile(`^[0-2]$`), + "level-idx": regexp.MustCompile(`^(?:[0-9]|[12][0-9]|3[01])$`), + "tier": regexp.MustCompile(`^[01]$`), + }, + }, +} + +var videoMimeTypePattern = regexp.MustCompile(`(?i)^video/[A-Za-z0-9.+-]{1,32}$`) + +// IsCanonicalVideoCodecEvidence ports isCanonicalVideoCodecEvidence. Empty +// strings are the TypeScript nulls; the schema rejects a genuinely empty codec +// string before this refine runs. +func IsCanonicalVideoCodecEvidence(codec, codecProfile, codecParameters string) bool { + rule, ok := videoCodecParameterRules[strings.ToLower(codec)] + if codec == "" || !ok { + return codecProfile == "" && codecParameters == "" + } + profileKeys := []string{} + if rule.profileKey != "" { + profileKeys = append(profileKeys, rule.profileKey) + } + return canonicalValuesMatch(codecProfile, profileKeys, rule.validators) && + canonicalValuesMatch(codecParameters, rule.parameterKeys, rule.validators) +} + +func canonicalValuesMatch( + encoded string, orderedKeys []string, validators map[string]*regexp.Regexp, +) bool { + if encoded == "" { + return true + } + segments := strings.Split(encoded, "; ") + if len(segments) > len(orderedKeys) { + return false + } + previousKeyIndex := -1 + for _, segment := range segments { + separator := strings.Index(segment, "=") + key, candidate := "", "" + if separator >= 1 { + key, candidate = segment[:separator], segment[separator+1:] + } + keyIndex := slices.Index(orderedKeys, key) + validator, known := validators[key] + if keyIndex <= previousKeyIndex || keyIndex < 0 || + !known || !validator.MatchString(candidate) { + return false + } + previousKeyIndex = keyIndex + } + return true +} diff --git a/internal/server/protocol/helpers_test.go b/internal/server/protocol/helpers_test.go new file mode 100644 index 0000000..2dcdc76 --- /dev/null +++ b/internal/server/protocol/helpers_test.go @@ -0,0 +1,108 @@ +package protocol + +import "testing" + +func TestEndpointMediaCopyLimits(t *testing.T) { + for _, sample := range []struct { + capacity, steady, transition int + }{{1, 1, 2}, {2, 2, 3}, {3, 3, 3}} { + if got := EndpointMediaCopyLimit(sample.capacity, EndpointMediaCopySteady); got != sample.steady { + t.Errorf("steady limit for %d = %d, want %d", sample.capacity, got, sample.steady) + } + if got := EndpointMediaCopyLimit(sample.capacity, EndpointMediaCopyTransition); got != sample.transition { + t.Errorf("transition limit for %d = %d, want %d", sample.capacity, got, sample.transition) + } + if !EndpointMediaCopyCountFits(sample.transition, sample.capacity, EndpointMediaCopyTransition) { + t.Errorf("%d copies must fit capacity %d in transition", sample.transition, sample.capacity) + } + if EndpointMediaCopyCountFits(sample.transition+1, sample.capacity, EndpointMediaCopyTransition) { + t.Errorf("%d copies must not fit capacity %d in transition", + sample.transition+1, sample.capacity) + } + } +} + +func TestEndpointMediaCopyCapacityGuards(t *testing.T) { + for _, capacity := range []int{0, 4, -1} { + if err := AssertEndpointMediaCopyCapacity(capacity); err == nil { + t.Errorf("capacity %d must be rejected", capacity) + } + } + // An unavailable runtime capacity returns false instead of throwing. + if EndpointMediaCopyCountFits(0, 0, EndpointMediaCopySteady) { + t.Error("a zero capacity must never fit") + } +} + +// Ported from tests/nat-candidate.test.ts. +func TestNatCandidateDiagnostics(t *testing.T) { + for _, sample := range []struct { + candidate string + want CandidateSignalOrigin + }{ + {"", CandidateOriginEnd}, + {" ", CandidateOriginEnd}, + {"candidate:sp2 1 udp 1 203.0.113.1 50000 typ srflx", CandidateOriginPredicted}, + {"candidate:base 1 udp 1 203.0.113.1 50000 typ srflx", CandidateOriginOrdinary}, + } { + if got := CandidateSignalOriginOf(sample.candidate); got != sample.want { + t.Errorf("CandidateSignalOriginOf(%q) = %q, want %q", + sample.candidate, got, sample.want) + } + } +} + +// Ported from tests/packet-loss.test.ts ("keeps receive-side delta validation"). +func TestPacketLossPercentFromDeltas(t *testing.T) { + number := func(value float64) *float64 { return &value } + for _, sample := range []struct { + name string + received, lost *float64 + want float64 + ok bool + }{ + {"ninety received and ten lost", number(90), number(10), 10, true}, + {"no packets at all", number(0), number(0), 0, false}, + {"missing received delta", nil, number(1), 0, false}, + {"negative lost delta", number(10), number(-1), 0, false}, + } { + t.Run(sample.name, func(t *testing.T) { + got, ok := PacketLossPercentFromDeltas(sample.received, sample.lost) + if ok != sample.ok || (ok && got != sample.want) { + t.Fatalf("PacketLossPercentFromDeltas = (%v, %v), want (%v, %v)", + got, ok, sample.want, sample.ok) + } + }) + } +} + +// Ported from the codec cases in tests/protocol.test.ts. +func TestIsCanonicalVideoCodecEvidence(t *testing.T) { + for _, sample := range []struct { + codec, profile, parameters string + want bool + }{ + {"video/H264", "profile-level-id=42e01f", + "packetization-mode=1; level-asymmetry-allowed=1", true}, + {"video/VP8", "", "max-fr=60; max-fs=8160", true}, + {"video/VP9", "profile-id=2", "max-fs=8160", true}, + {"video/AV1", "profile=1", "level-idx=31; tier=0", true}, + {"video/unknown", "", "", true}, + {"", "", "", true}, + {"video/unknown", "profile=1", "", false}, + {"video/H264", "device-id=deadbeef", "", false}, + {"video/H264", "", "sprop-parameter-sets=deadbeef", false}, + // Order is part of the canonical form. + {"video/H264", "", "level-asymmetry-allowed=1; packetization-mode=1", false}, + // A duplicate key exceeds the ordered key budget. + {"video/VP8", "", "max-fr=60; max-fr=60", false}, + {"video/AV1", "profile=3", "", false}, + } { + t.Run(sample.codec+"|"+sample.profile+"|"+sample.parameters, func(t *testing.T) { + got := IsCanonicalVideoCodecEvidence(sample.codec, sample.profile, sample.parameters) + if got != sample.want { + t.Fatalf("IsCanonicalVideoCodecEvidence = %v, want %v", got, sample.want) + } + }) + } +} diff --git a/internal/server/protocol/http.go b/internal/server/protocol/http.go new file mode 100644 index 0000000..37b1b6b --- /dev/null +++ b/internal/server/protocol/http.go @@ -0,0 +1,197 @@ +package protocol + +import ( + "errors" + "fmt" +) + +// CreateRoomRequest mirrors createRoomRequestSchema. +type CreateRoomRequest struct { + CodeEntryPolicy CodeEntryPolicy `json:"codeEntryPolicy"` + RoomPassword Nullable[string] `json:"roomPassword,omitzero"` + PreferredRoomID string `json:"preferredRoomId,omitempty"` +} + +// DecodeCreateRoomRequest parses createRoomRequestSchema. +func DecodeCreateRoomRequest(data []byte) (CreateRoomRequest, error) { + var request CreateRoomRequest + present, err := decodeObject(data, &request) + if err != nil { + return CreateRoomRequest{}, err + } + if err := present.require("codeEntryPolicy"); err != nil { + return CreateRoomRequest{}, err + } + if err := present.optional("preferredRoomId"); err != nil { + return CreateRoomRequest{}, err + } + if !enumOf(request.CodeEntryPolicy, CodeEntryOpen, CodeEntryPrivate) { + return CreateRoomRequest{}, fmt.Errorf( + "unknown codeEntryPolicy %q", request.CodeEntryPolicy) + } + if request.RoomPassword.Value != nil && !ValidViewerPassword(*request.RoomPassword.Value) { + return CreateRoomRequest{}, errors.New("roomPassword is malformed") + } + if present.has("preferredRoomId") && !ValidRoomCode(request.PreferredRoomID) { + return CreateRoomRequest{}, errors.New("preferredRoomId is not a room code") + } + return request, nil +} + +// ReplaceRoomRequest mirrors replaceRoomRequestSchema. +type ReplaceRoomRequest struct { + CodeEntryPolicy CodeEntryPolicy `json:"codeEntryPolicy"` + RoomPassword Nullable[string] `json:"roomPassword,omitzero"` +} + +// DecodeReplaceRoomRequest parses replaceRoomRequestSchema. +func DecodeReplaceRoomRequest(data []byte) (ReplaceRoomRequest, error) { + var request ReplaceRoomRequest + present, err := decodeObject(data, &request) + if err != nil { + return ReplaceRoomRequest{}, err + } + if err := present.require("codeEntryPolicy"); err != nil { + return ReplaceRoomRequest{}, err + } + if !enumOf(request.CodeEntryPolicy, CodeEntryOpen, CodeEntryPrivate) { + return ReplaceRoomRequest{}, fmt.Errorf( + "unknown codeEntryPolicy %q", request.CodeEntryPolicy) + } + if request.RoomPassword.Value != nil && !ValidViewerPassword(*request.RoomPassword.Value) { + return ReplaceRoomRequest{}, errors.New("roomPassword is malformed") + } + return request, nil +} + +// RoomAccessUpdateRequest is one member of roomAccessUpdateRequestSchema. +type RoomAccessUpdateRequest interface { + isRoomAccessUpdateRequest() +} + +// SetCodeEntryPolicyRequest is action "set-code-entry-policy". +type SetCodeEntryPolicyRequest struct { + Action string `json:"action"` + Policy CodeEntryPolicy `json:"policy"` +} + +// RotateViewerGrantRequest is action "rotate-viewer-grant". +type RotateViewerGrantRequest struct { + Action string `json:"action"` +} + +// RevokeViewerGrantRequest is action "revoke-viewer-grant". +type RevokeViewerGrantRequest struct { + Action string `json:"action"` +} + +// SetViewerPasswordRequest is action "set-viewer-password". +type SetViewerPasswordRequest struct { + Action string `json:"action"` + Password *string `json:"password"` +} + +func (SetCodeEntryPolicyRequest) isRoomAccessUpdateRequest() {} +func (RotateViewerGrantRequest) isRoomAccessUpdateRequest() {} +func (RevokeViewerGrantRequest) isRoomAccessUpdateRequest() {} +func (SetViewerPasswordRequest) isRoomAccessUpdateRequest() {} + +// DecodeRoomAccessUpdateRequest parses roomAccessUpdateRequestSchema. +func DecodeRoomAccessUpdateRequest(data []byte) (RoomAccessUpdateRequest, error) { + action, err := typeOf(data, "action") + if err != nil { + return nil, err + } + switch action { + case "set-code-entry-policy": + var request SetCodeEntryPolicyRequest + present, err := decodeObject(data, &request) + if err != nil { + return nil, err + } + if err := present.require("action", "policy"); err != nil { + return nil, err + } + if !enumOf(request.Policy, CodeEntryOpen, CodeEntryPrivate) { + return nil, fmt.Errorf("unknown policy %q", request.Policy) + } + return request, nil + case "rotate-viewer-grant": + var request RotateViewerGrantRequest + present, err := decodeObject(data, &request) + if err != nil { + return nil, err + } + if err := present.require("action"); err != nil { + return nil, err + } + return request, nil + case "revoke-viewer-grant": + var request RevokeViewerGrantRequest + present, err := decodeObject(data, &request) + if err != nil { + return nil, err + } + if err := present.require("action"); err != nil { + return nil, err + } + return request, nil + case "set-viewer-password": + var request SetViewerPasswordRequest + present, err := decodeObject(data, &request) + if err != nil { + return nil, err + } + if err := present.require("action"); err != nil { + return nil, err + } + if err := present.requireNullable("password"); err != nil { + return nil, err + } + if request.Password != nil && !ValidViewerPassword(*request.Password) { + return nil, errors.New("password is malformed") + } + return request, nil + } + return nil, fmt.Errorf("unknown room access action %q", action) +} + +// CreateRoomResponse mirrors createRoomResponseSchema. +type CreateRoomResponse struct { + RoomID string `json:"roomId"` + HostToken string `json:"hostToken"` + InviteURL string `json:"inviteUrl"` + CodeEntryPolicy CodeEntryPolicy `json:"codeEntryPolicy"` + ExpiresAt *string `json:"expiresAt"` + RoomLeaseSeconds Int `json:"roomLeaseSeconds"` +} + +// RoomAccessUpdateResponse is one member of roomAccessUpdateResponseSchema. +type RoomAccessUpdateResponse interface { + isRoomAccessUpdateResponse() +} + +// CodeEntryPolicyUpdatedResponse is type "code-entry-policy-updated". +type CodeEntryPolicyUpdatedResponse struct { + Type string `json:"type"` + CodeEntryPolicy CodeEntryPolicy `json:"codeEntryPolicy"` + ViewerPasswordEnabled bool `json:"viewerPasswordEnabled"` +} + +// ViewerGrantUpdatedResponse is type "viewer-grant-updated". InviteURL is nil +// for the explicit null a revoke returns. +type ViewerGrantUpdatedResponse struct { + Type string `json:"type"` + ViewerAuthorizationGeneration string `json:"viewerAuthorizationGeneration"` + InviteURL *string `json:"inviteUrl"` +} + +// ViewerPasswordUpdatedResponse is type "viewer-password-updated". +type ViewerPasswordUpdatedResponse struct { + Type string `json:"type"` + Enabled bool `json:"enabled"` +} + +func (CodeEntryPolicyUpdatedResponse) isRoomAccessUpdateResponse() {} +func (ViewerGrantUpdatedResponse) isRoomAccessUpdateResponse() {} +func (ViewerPasswordUpdatedResponse) isRoomAccessUpdateResponse() {} diff --git a/internal/server/protocol/scalars.go b/internal/server/protocol/scalars.go new file mode 100644 index 0000000..0f16624 --- /dev/null +++ b/internal/server/protocol/scalars.go @@ -0,0 +1,401 @@ +package protocol + +import ( + "encoding/json" + "errors" + "net/url" + "regexp" + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "golang.org/x/text/unicode/norm" +) + +// UTF16Length counts UTF-16 code units, which is what zod's string .min/.max/ +// .length and String.prototype.length measure: not bytes, not code points. +// It is the single owner of that measurement for the server packages. +func UTF16Length(value string) int { + length := 0 + for _, character := range value { + if character > 0xffff { + length += 2 + } else { + length++ + } + } + return length +} + +// IsJSWhitespace matches the JavaScript regular-expression \s class, which is +// also what String.prototype.trim and Number(string) strip. It is the single +// owner of that predicate for the server packages. +func IsJSWhitespace(character rune) bool { + switch character { + case '\t', '\n', '\v', '\f', '\r', ' ', + 0x00a0, 0x1680, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000, 0xfeff: + return true + } + return character >= 0x2000 && character <= 0x200a +} + +var ( + opaqueIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + roomCodePattern = regexp.MustCompile(`^[1-9][0-9]*$`) + viewerGrantPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{21}[AQgw]$`) + viewerPasswordPattern = regexp.MustCompile(`^[\x21-\x7e]+$`) +) + +// ValidOpaqueID mirrors opaqueIdSchema: 8..128 code units of [A-Za-z0-9_-]. +func ValidOpaqueID(value string) bool { + length := UTF16Length(value) + return length >= 8 && length <= 128 && opaqueIDPattern.MatchString(value) +} + +// ValidToken mirrors tokenSchema: 32..128 code units of [A-Za-z0-9_-]. +func ValidToken(value string) bool { + length := UTF16Length(value) + return length >= 32 && length <= 128 && opaqueIDPattern.MatchString(value) +} + +// ValidRoomCode mirrors roomCodeSchema. +func ValidRoomCode(value string) bool { + return UTF16Length(value) == RoomCodeLength && roomCodePattern.MatchString(value) +} + +// ValidViewerGrant mirrors viewerGrantSchema. +func ValidViewerGrant(value string) bool { + return UTF16Length(value) == 22 && viewerGrantPattern.MatchString(value) +} + +// ValidViewerPassword mirrors viewerPasswordSchema. +func ValidViewerPassword(value string) bool { + length := UTF16Length(value) + return length >= MinViewerPasswordLength && + length <= MaxViewerPasswordLength && + viewerPasswordPattern.MatchString(value) +} + +// ValidLiveKitWebSocketURL mirrors liveKitWebSocketUrlSchema. +func ValidLiveKitWebSocketURL(value string) bool { + if UTF16Length(value) > 2048 { + return false + } + parsed, err := url.Parse(value) + if err != nil || parsed.Host == "" { + return false + } + scheme := strings.ToLower(parsed.Scheme) + return scheme == "ws" || scheme == "wss" +} + +// isoDateTimePattern mirrors z.string().datetime(): an RFC 3339 UTC instant +// with a "Z" designator and no offset. +var isoDateTimePattern = regexp.MustCompile( + `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$`) + +// ValidISODateTime reports whether value is a zod `.datetime()` string. +func ValidISODateTime(value string) bool { + return isoDateTimePattern.MatchString(value) +} + +// forbiddenDisplayNameCharacters is FORBIDDEN_DISPLAY_NAME_CHARACTERS from +// src/shared/protocol.ts. +var forbiddenDisplayNameCharacters = regexp.MustCompile( + `[\p{Cc}\p{Zl}\p{Zp}\x{061c}\x{200b}\x{200e}\x{200f}\x{202a}-\x{202e}` + + `\x{2060}\x{2066}-\x{2069}\x{feff}]`) + +// NormalizeDisplayName ports normalizeDisplayName. The second result is false +// where the TypeScript returns null. +func NormalizeDisplayName(value string) (string, bool) { + if forbiddenDisplayNameCharacters.MatchString(value) { + return "", false + } + // The TypeScript rejects code points in D800..DFFF, which a JavaScript + // string can hold as an unpaired surrogate. A Go string can only carry one + // as invalid UTF-8 (WTF-8), so utf8.ValidString is the exact analogue. + if !utf8.ValidString(value) { + return "", false + } + + normalized := collapseUnicodeSpaces( + strings.TrimFunc(norm.NFC.String(value), isUnicodeSpaceSeparator)) + count := utf8.RuneCountInString(normalized) + if count < 1 || count > MaxDisplayNameCodePoints { + return "", false + } + return normalized, true +} + +// isUnicodeSpaceSeparator matches \p{Zs}. Every other character trimmed by +// JavaScript's String.prototype.trim (Cc, Zl, Zp, FEFF) is already forbidden +// above, so trimming Zs alone is equivalent here. +func isUnicodeSpaceSeparator(character rune) bool { + return unicode.Is(unicode.Zs, character) +} + +// collapseUnicodeSpaces implements .replace(/\p{Zs}+/gu, " "). +func collapseUnicodeSpaces(value string) string { + var builder strings.Builder + pendingSpace := false + for _, character := range value { + if isUnicodeSpaceSeparator(character) { + pendingSpace = true + continue + } + if pendingSpace { + builder.WriteByte(' ') + pendingSpace = false + } + builder.WriteRune(character) + } + if pendingSpace { + builder.WriteByte(' ') + } + return builder.String() +} + +// DisplayName mirrors displayNameSchema: 1..96 UTF-16 code units that +// normalizeDisplayName leaves unchanged. +type DisplayName string + +// UnmarshalJSON implements json.Unmarshaler. +func (d *DisplayName) UnmarshalJSON(data []byte) error { + // encoding/json folds an unpaired \uD800-\uDFFF escape to U+FFFD, so the + // raw token is the only place the TypeScript surrogate rejection is still + // observable. + if hasUnpairedSurrogateEscape(data) { + return errors.New("displayName contains an unpaired surrogate") + } + var value string + if err := json.Unmarshal(data, &value); err != nil { + return err + } + if !ValidDisplayName(value) { + return errors.New("display name must be canonical") + } + *d = DisplayName(value) + return nil +} + +// ValidDisplayName mirrors displayNameSchema's bounds and canonical refine. +func ValidDisplayName(value string) bool { + length := UTF16Length(value) + if length < 1 || length > MaxDisplayNameCodePoints*4 { + return false + } + normalized, ok := NormalizeDisplayName(value) + return ok && normalized == value +} + +func hasUnpairedSurrogateEscape(token []byte) bool { + text := string(token) + for index := 0; index+6 <= len(text); index++ { + if text[index] != '\\' || text[index+1] != 'u' { + continue + } + unit, err := strconv.ParseUint(text[index+2:index+6], 16, 32) + if err != nil { + continue + } + switch { + case unit >= 0xd800 && unit <= 0xdbff: + if index+12 > len(text) || text[index+6] != '\\' || text[index+7] != 'u' { + return true + } + low, err := strconv.ParseUint(text[index+8:index+12], 16, 32) + if err != nil || low < 0xdc00 || low > 0xdfff { + return true + } + index += 11 + case unit >= 0xdc00 && unit <= 0xdfff: + return true + } + } + return false +} + +// ValidStunURL ports isValidStunUrl from src/shared/protocol.ts. The +// TypeScript feeds "http://" to the WHATWG URL parser; net/url is +// more permissive in several places, so the extra checks below restore the +// WHATWG answers (see stun_test.go for the recorded comparisons). +func ValidStunURL(value string) bool { + separator := strings.IndexByte(value, ':') + if separator <= 0 || !asciiEqualFold(value[:separator], "stun") { + return false + } + + authority := value[separator+1:] + if authority == "" || strings.HasSuffix(authority, ":") { + return false + } + for _, character := range authority { + if character == '\\' || character == '/' || character == '?' || + character == '#' || IsJSWhitespace(character) { + return false + } + } + + parsed, err := url.Parse("http://" + authority) + if err != nil { + return false + } + // WHATWG reports an empty username/password for "@host" and ":@host"; + // net/url reports a non-nil Userinfo, so compare the parts, not the pointer. + if parsed.User != nil { + if parsed.User.Username() != "" { + return false + } + if password, _ := parsed.User.Password(); password != "" { + return false + } + } + if hostname := parsed.Hostname(); hostname == "" || !validWHATWGHost(parsed.Host, hostname) { + return false + } + if port := parsed.Port(); port != "" { + // net/url accepts any digit run; WHATWG fails above 65535. + number, err := strconv.Atoi(port) + if err != nil || number <= 0 || number > 65535 { + return false + } + } + // net/url leaves Path empty where WHATWG reports "/". The authority scan + // above already rejected "/", "?" and "#", so these are belt and braces. + return (parsed.Path == "" || parsed.Path == "/") && + parsed.RawQuery == "" && parsed.Fragment == "" +} + +func asciiEqualFold(left, right string) bool { + if len(left) != len(right) { + return false + } + for index := 0; index < len(left); index++ { + if asciiLower(left[index]) != asciiLower(right[index]) { + return false + } + } + return true +} + +func asciiLower(character byte) byte { + if character >= 'A' && character <= 'Z' { + return character + 'a' - 'A' + } + return character +} + +// forbiddenDomainCodePoints is the WHATWG "forbidden domain code point" set +// minus the characters the authority scan already rejected. +const forbiddenDomainCodePoints = " #/:<>?@[\\]^|%" + +func validWHATWGHost(rawHost, hostname string) bool { + if strings.HasPrefix(rawHost, "[") { + // net/url accepts "[1.2.3.4]" and IPv6 zone identifiers; the WHATWG + // IPv6 parser accepts neither. + return strings.Contains(hostname, ":") && !strings.Contains(hostname, "%") + } + for _, character := range hostname { + if character <= 0x1f || character == 0x7f || + strings.ContainsRune(forbiddenDomainCodePoints, character) { + return false + } + } + if hostEndsInNumber(hostname) { + return validIPv4Host(hostname) + } + return true +} + +// hostEndsInNumber implements the WHATWG "ends in a number" checker. +func hostEndsInNumber(host string) bool { + parts := strings.Split(host, ".") + if len(parts) > 1 && parts[len(parts)-1] == "" { + parts = parts[:len(parts)-1] + } + last := parts[len(parts)-1] + if last != "" && isASCIIDigits(last) { + return true + } + _, ok := parseIPv4Number(last) + return ok +} + +// validIPv4Host implements the WHATWG IPv4 parser's failure conditions. +func validIPv4Host(host string) bool { + parts := strings.Split(host, ".") + if len(parts) > 1 && parts[len(parts)-1] == "" { + parts = parts[:len(parts)-1] + } + if len(parts) > 4 { + return false + } + numbers := make([]uint64, 0, len(parts)) + for _, part := range parts { + number, ok := parseIPv4Number(part) + if !ok { + return false + } + numbers = append(numbers, number) + } + for _, number := range numbers[:len(numbers)-1] { + if number > 255 { + return false + } + } + limit := uint64(1) + for index := 0; index < 5-len(numbers); index++ { + limit *= 256 + } + return numbers[len(numbers)-1] < limit +} + +// parseIPv4Number implements the WHATWG IPv4 number parser (decimal, 0-octal +// and 0x-hexadecimal forms). +func parseIPv4Number(input string) (uint64, bool) { + if input == "" { + return 0, false + } + radix := 10 + switch { + case len(input) >= 2 && (strings.HasPrefix(input, "0x") || strings.HasPrefix(input, "0X")): + input, radix = input[2:], 16 + case len(input) >= 2 && input[0] == '0': + input, radix = input[1:], 8 + } + if input == "" { + return 0, true + } + for index := 0; index < len(input); index++ { + if radixDigit(input[index]) >= radix { + return 0, false + } + } + number, err := strconv.ParseUint(input, radix, 64) + if err != nil { + return 0, false + } + return number, true +} + +func radixDigit(character byte) int { + switch { + case character >= '0' && character <= '9': + return int(character - '0') + case character >= 'a' && character <= 'f': + return int(character-'a') + 10 + case character >= 'A' && character <= 'F': + return int(character-'A') + 10 + } + return 99 +} + +func isASCIIDigits(value string) bool { + for index := 0; index < len(value); index++ { + if value[index] < '0' || value[index] > '9' { + return false + } + } + return true +} diff --git a/internal/server/protocol/scalars_test.go b/internal/server/protocol/scalars_test.go new file mode 100644 index 0000000..2fb3346 --- /dev/null +++ b/internal/server/protocol/scalars_test.go @@ -0,0 +1,207 @@ +package protocol + +import "testing" + +func TestUTF16Len(t *testing.T) { + // zod's string bounds count UTF-16 code units, so an astral code point + // counts as two. + for _, sample := range []struct { + value string + want int + }{ + {"", 0}, + {"abc", 3}, + {"名", 1}, + {"\U0001f469", 2}, + {"\U0001f469\u200d\U0001f4bb", 5}, + } { + if got := UTF16Length(sample.value); got != sample.want { + t.Errorf("UTF16Length(%q) = %d, want %d", sample.value, got, sample.want) + } + } +} + +func TestNormalizeDisplayName(t *testing.T) { + for _, sample := range []struct { + name string + value string + want string + ok bool + }{ + {"composes and collapses", " Café 朋友 ", + "Café 朋友", true}, + {"keeps a zero width joiner sequence", + "玩家 \U0001f469\u200d\U0001f4bb", + "玩家 \U0001f469\u200d\U0001f4bb", true}, + {"accepts the code point limit", + repeat("名", MaxDisplayNameCodePoints), + repeat("名", MaxDisplayNameCodePoints), true}, + {"rejects one code point past the limit", + repeat("名", MaxDisplayNameCodePoints+1), "", false}, + {"rejects a control character", "a\nb", "", false}, + {"rejects a bidi override", "a\u202eb", "", false}, + {"rejects a byte order mark", "a\ufeffb", "", false}, + {"rejects a zero width space", "a\u200bb", "", false}, + {"rejects an unpaired surrogate", "a\xed\xa0\x80b", "", false}, + {"rejects an all-space name", " ", "", false}, + } { + t.Run(sample.name, func(t *testing.T) { + got, ok := NormalizeDisplayName(sample.value) + if ok != sample.ok || got != sample.want { + t.Fatalf("NormalizeDisplayName(%q) = (%q, %v), want (%q, %v)", + sample.value, got, ok, sample.want, sample.ok) + } + }) + } +} + +func TestValidDisplayNameRequiresCanonicalForm(t *testing.T) { + if ValidDisplayName(" Café ") { + t.Error("a non-canonical display name must not validate") + } + if !ValidDisplayName("Café 朋友") { + t.Error("a canonical display name must validate") + } + if ValidDisplayName("") { + t.Error("an empty display name must not validate") + } +} + +func TestDisplayNameRejectsSurrogateEscapes(t *testing.T) { + // encoding/json folds an unpaired escape to U+FFFD, so the raw token is the + // only place the TypeScript surrogate rejection stays observable. + var name DisplayName + if err := name.UnmarshalJSON([]byte(`"a\ud800b"`)); err == nil { + t.Fatal("an unpaired high surrogate escape must be rejected") + } + if err := name.UnmarshalJSON([]byte(`"a\udc00b"`)); err == nil { + t.Fatal("an unpaired low surrogate escape must be rejected") + } + if err := name.UnmarshalJSON([]byte(`"a😀b"`)); err != nil { + t.Fatalf("a surrogate pair escape must be accepted: %v", err) + } +} + +func TestScalarValidators(t *testing.T) { + for _, sample := range []struct { + name string + valid func(string) bool + value string + want bool + }{ + {"opaque id at floor", ValidOpaqueID, "abcdefgh", true}, + {"opaque id below floor", ValidOpaqueID, "abcdefg", false}, + {"opaque id with punctuation", ValidOpaqueID, "abcdefg.", false}, + {"opaque id at ceiling", ValidOpaqueID, repeat("a", 128), true}, + {"opaque id past ceiling", ValidOpaqueID, repeat("a", 129), false}, + {"token at floor", ValidToken, repeat("a", 32), true}, + {"token below floor", ValidToken, repeat("a", 31), false}, + {"token past ceiling", ValidToken, repeat("a", 129), false}, + {"room code", ValidRoomCode, "1234", true}, + {"room code with leading zero", ValidRoomCode, "0123", false}, + {"room code too long", ValidRoomCode, "12345", false}, + {"room code too short", ValidRoomCode, "123", false}, + {"viewer grant", ValidViewerGrant, repeat("b", 21) + "g", true}, + {"viewer grant short", ValidViewerGrant, repeat("b", 21), false}, + {"viewer grant long", ValidViewerGrant, repeat("b", 23), false}, + {"viewer grant padded", ValidViewerGrant, repeat("b", 21) + "=", false}, + {"viewer grant off alphabet tail", ValidViewerGrant, repeat("b", 21) + "b", false}, + {"viewer password minimal", ValidViewerPassword, "x", true}, + {"viewer password simple", ValidViewerPassword, "simple-password", true}, + {"viewer password empty", ValidViewerPassword, "", false}, + {"viewer password with space", ValidViewerPassword, "contains space", false}, + {"viewer password with newline", ValidViewerPassword, "line\nbreak", false}, + {"viewer password past ceiling", ValidViewerPassword, + repeat("x", MaxViewerPasswordLength+1), false}, + {"livekit url", ValidLiveKitWebSocketURL, "wss://sfu.example.test", true}, + {"livekit url plain ws", ValidLiveKitWebSocketURL, "ws://127.0.0.1:7880", true}, + {"livekit url https", ValidLiveKitWebSocketURL, "https://sfu.example.test", false}, + {"livekit url relative", ValidLiveKitWebSocketURL, "/signal", false}, + } { + t.Run(sample.name, func(t *testing.T) { + if got := sample.valid(sample.value); got != sample.want { + t.Fatalf("validator(%q) = %v, want %v", sample.value, got, sample.want) + } + }) + } +} + +// TestValidStunURL records the WHATWG answers isValidStunUrl produces in the +// browser. The cases marked "net/url divergence" are the ones where Go's +// parser alone would disagree. +func TestValidStunURL(t *testing.T) { + for _, sample := range []struct { + value string + want bool + }{ + // tests/server-config.test.ts and tests/protocol.test.ts. + {"stun:stun.test:3478", true}, + {"stun:share.test:5349", true}, + {"STUN:[2001:db8::1]:3478", true}, + {"stun:stun.test", true}, + {"stun:stun.test/path", false}, + {"stun:stun.test?transport=udp", false}, + {"stun:stun.test#fragment", false}, + {"stun:", false}, + {"stun:stun.test:", false}, + {"stun:stun.test:0", false}, + {"stun:user@stun.test:3478", false}, + {"turn:turn.test:3478", false}, + {"stuns:stun.test:5349", false}, + {"stun:relay.example.test:3478?transport=udp", false}, + + // net/url divergence: it accepts ports above 65535. + {"stun:stun.test:65535", true}, + {"stun:stun.test:65536", false}, + {"stun:stun.test:99999", false}, + // net/url divergence: it accepts "<", ">" and "]" in a host name. + {"stun:ab:3478", false}, + {"stun:a]b:3478", false}, + {"stun:a|b:3478", false}, + {"stun:a^b:3478", false}, + // net/url divergence: it does not run the WHATWG IPv4 parser. + {"stun:999.999.999.999", false}, + {"stun:1.2.3.4.5", false}, + {"stun:09.1", false}, + {"stun:1.2.3.256", false}, + {"stun:1.2.3.4", true}, + {"stun:1.2.3.4.", true}, + {"stun:0300.0250.0.1", true}, + {"stun:0x7f.1", true}, + {"stun:123", true}, + // net/url divergence: it accepts "[1.2.3.4]" and IPv6 zone ids. + {"stun:[1.2.3.4]", false}, + {"stun:[notanip]:3478", false}, + {"stun:[2001:db8::1", false}, + {"stun:[::1]", true}, + // WHATWG treats empty userinfo as absent. + {"stun:@stun.test:3478", true}, + {"stun::@stun.test:3478", true}, + {"stun::pass@stun.test:3478", false}, + {"stun:user:@stun.test:3478", false}, + // A host is mandatory and the authority scan rejects the framing + // characters before net/url ever sees them. + {"stun::3478", false}, + {"stun:stun.test:+3478", false}, + {"stun:stun.test:34a78", false}, + {"stun:stun test:3478", false}, + {"stun:stun.test\\path", false}, + {"stun:a_b.test", true}, + {"stun:stun.test:03478", true}, + } { + t.Run(sample.value, func(t *testing.T) { + if got := ValidStunURL(sample.value); got != sample.want { + t.Fatalf("ValidStunURL(%q) = %v, want %v", sample.value, got, sample.want) + } + }) + } +} + +func repeat(value string, count int) string { + out := make([]byte, 0, len(value)*count) + for index := 0; index < count; index++ { + out = append(out, value...) + } + return string(out) +} diff --git a/internal/server/protocol/server.go b/internal/server/protocol/server.go new file mode 100644 index 0000000..c54c1dd --- /dev/null +++ b/internal/server/protocol/server.go @@ -0,0 +1,691 @@ +package protocol + +import ( + "errors" + "fmt" +) + +// ServerMessage is one member of serverMessageSchema. The interface is sealed: +// only the message structs in this file implement it. +type ServerMessage interface { + isServerMessage() +} + +// AuthenticatedHostMessage is the host member of authenticatedMessageSchema. +// The field order is the emission order of the object literal in +// src/server/signaling.ts (`role` and `viewerPasswordEnabled` are appended +// after the shared base spread). +type AuthenticatedHostMessage struct { + Type string `json:"type"` + Protocol string `json:"protocol"` + PeerID string `json:"peerId"` + RoomExpiresAt *string `json:"roomExpiresAt"` + MaxViewers Int `json:"maxViewers"` + EndpointMediaCopyCapacity Int `json:"endpointMediaCopyCapacity"` + HostOnline bool `json:"hostOnline"` + HostPaused *bool `json:"hostPaused,omitempty"` + ConnectionID *string `json:"connectionId"` + IceConfig IceConfig `json:"iceConfig"` + RoutePolicy RoutePolicy `json:"routePolicy"` + CodeEntryPolicy CodeEntryPolicy `json:"codeEntryPolicy"` + ViewerAuthorizationGeneration string `json:"viewerAuthorizationGeneration"` + MediaMode string `json:"mediaMode"` + ShareGeneration *string `json:"shareGeneration"` + RouteRevision Int `json:"routeRevision"` + RouteAssignment ParticipantRouteAssignment `json:"routeAssignment"` + QualitySettings QualitySettings `json:"qualitySettings"` + SfuStandbyURL *string `json:"sfuStandbyUrl,omitempty"` + Role Role `json:"role"` + ViewerPasswordEnabled bool `json:"viewerPasswordEnabled"` +} + +// AuthenticatedViewerMessage is the viewer member of +// authenticatedMessageSchema. +type AuthenticatedViewerMessage struct { + Type string `json:"type"` + Protocol string `json:"protocol"` + PeerID string `json:"peerId"` + RoomExpiresAt *string `json:"roomExpiresAt"` + MaxViewers Int `json:"maxViewers"` + EndpointMediaCopyCapacity Int `json:"endpointMediaCopyCapacity"` + HostOnline bool `json:"hostOnline"` + HostPaused *bool `json:"hostPaused,omitempty"` + ConnectionID *string `json:"connectionId"` + IceConfig IceConfig `json:"iceConfig"` + RoutePolicy RoutePolicy `json:"routePolicy"` + CodeEntryPolicy CodeEntryPolicy `json:"codeEntryPolicy"` + ViewerAuthorizationGeneration string `json:"viewerAuthorizationGeneration"` + MediaMode string `json:"mediaMode"` + ShareGeneration *string `json:"shareGeneration"` + RouteRevision Int `json:"routeRevision"` + RouteAssignment ParticipantRouteAssignment `json:"routeAssignment"` + QualitySettings QualitySettings `json:"qualitySettings"` + SfuStandbyURL *string `json:"sfuStandbyUrl,omitempty"` + Role Role `json:"role"` +} + +// SignalingChallengeResponseMessage is the challenge echo. +type SignalingChallengeResponseMessage struct { + Type string `json:"type"` + Sequence Int `json:"sequence"` +} + +// ServerSignalMessage is the forwarded "signal" message. +type ServerSignalMessage struct { + Type string `json:"type"` + FromPeerID string `json:"fromPeerId"` + Payload SignalPayload `json:"payload"` +} + +// ServerRestartRequestMessage is the forwarded "restart-request" message. +type ServerRestartRequestMessage struct { + Type string `json:"type"` + FromPeerID string `json:"fromPeerId"` + ConnectionID string `json:"connectionId"` + Rebuild bool `json:"rebuild"` +} + +// RouteUpdatePrepareMessage is the phase "prepare" member of route-update. +type RouteUpdatePrepareMessage struct { + Type string `json:"type"` + Revision Int `json:"revision"` + Phase string `json:"phase"` + Assignment ParticipantRouteAssignment `json:"assignment"` + Candidate PreparedRouteCandidate `json:"candidate"` +} + +// RouteUpdateActiveMessage is the phase "active" member of route-update. +type RouteUpdateActiveMessage struct { + Type string `json:"type"` + Revision Int `json:"revision"` + Phase string `json:"phase"` + Assignment ParticipantRouteAssignment `json:"assignment"` +} + +// RouteStatusMessage is the crossed (state, reason) union: "waiting" pairs +// with "sfu-admission" and "failed" pairs with "route-exhausted". +type RouteStatusMessage struct { + Type string `json:"type"` + Revision Int `json:"revision"` + State string `json:"state"` + Reason string `json:"reason"` +} + +// RouteDiagnosticSnapshotMessage carries the privacy-safe route snapshot. +type RouteDiagnosticSnapshotMessage struct { + Type string `json:"type"` + Snapshot RouteDiagnosticSnapshot `json:"snapshot"` +} + +// SfuConfigMessage carries a LiveKit URL and a short-lived token. +type SfuConfigMessage struct { + Type string `json:"type"` + Revision Int `json:"revision"` + URL string `json:"url"` + Token string `json:"token"` +} + +// QualitySettingsMessage broadcasts the host's quality settings. +type QualitySettingsMessage struct { + Type string `json:"type"` + QualitySettings QualitySettings `json:"qualitySettings"` +} + +// RoutePolicyMessage broadcasts the host's route policy. +type RoutePolicyMessage struct { + Type string `json:"type"` + ShareGeneration string `json:"shareGeneration"` + RoutePolicy RoutePolicy `json:"routePolicy"` + SfuStandbyURL *string `json:"sfuStandbyUrl,omitempty"` +} + +// PauseSharingSourceMessage asks the host to pause its capture source. +type PauseSharingSourceMessage struct { + Type string `json:"type"` + ShareGeneration string `json:"shareGeneration"` +} + +// ServerViewerQualityEvidenceMessage is the forwarded evidence envelope. +type ServerViewerQualityEvidenceMessage struct { + Type string `json:"type"` + ViewerPeerID string `json:"viewerPeerId"` + Upstream MediaRouteUpstream `json:"upstream"` + Guard ViewerQualityEvidenceGuard `json:"guard"` + Sequence Int `json:"sequence"` + WindowMs Int `json:"windowMs"` + Metrics ViewerQualityEvidenceMetrics `json:"metrics"` +} + +// HostStatusMessage reports host presence and pause state. +type HostStatusMessage struct { + Type string `json:"type"` + Online bool `json:"online"` + Paused bool `json:"paused"` +} + +// ViewerPresenceMessage carries the ordered participant roster. +type ViewerPresenceMessage struct { + Type string `json:"type"` + Viewers []ParticipantPresenceEntry `json:"viewers"` +} + +// MarshalJSON implements json.Marshaler. +func (v ViewerPresenceMessage) MarshalJSON() ([]byte, error) { + type raw ViewerPresenceMessage + value := raw(v) + if value.Viewers == nil { + value.Viewers = []ParticipantPresenceEntry{} + } + return marshalJSON(value) +} + +// ViewerGrantRevokedMessage announces a superseded viewer authorization. +type ViewerGrantRevokedMessage struct { + Type string `json:"type"` + ViewerAuthorizationGeneration string `json:"viewerAuthorizationGeneration"` +} + +// SharingStoppedMessage is { type: "sharing-stopped" }. +type SharingStoppedMessage struct { + Type string `json:"type"` +} + +// RoomClosedMessage is { type: "room-closed", reason }. +type RoomClosedMessage struct { + Type string `json:"type"` + Reason string `json:"reason"` +} + +// ErrorMessage is { type: "error", code, message }. +type ErrorMessage struct { + Type string `json:"type"` + Code string `json:"code"` + Message string `json:"message"` +} + +func (AuthenticatedHostMessage) isServerMessage() {} +func (AuthenticatedViewerMessage) isServerMessage() {} +func (SignalingChallengeResponseMessage) isServerMessage() {} +func (ServerSignalMessage) isServerMessage() {} +func (ServerRestartRequestMessage) isServerMessage() {} +func (RouteUpdatePrepareMessage) isServerMessage() {} +func (RouteUpdateActiveMessage) isServerMessage() {} +func (RouteStatusMessage) isServerMessage() {} +func (RouteDiagnosticSnapshotMessage) isServerMessage() {} +func (SfuConfigMessage) isServerMessage() {} +func (QualitySettingsMessage) isServerMessage() {} +func (RoutePolicyMessage) isServerMessage() {} +func (PauseSharingSourceMessage) isServerMessage() {} +func (ServerViewerQualityEvidenceMessage) isServerMessage() {} +func (HostStatusMessage) isServerMessage() {} +func (ViewerPresenceMessage) isServerMessage() {} +func (ViewerGrantRevokedMessage) isServerMessage() {} +func (SharingStoppedMessage) isServerMessage() {} +func (RoomClosedMessage) isServerMessage() {} +func (ErrorMessage) isServerMessage() {} + +// EncodeServerMessage serializes one server message with the key order of the +// TypeScript object literal that produces it. +func EncodeServerMessage(message ServerMessage) ([]byte, error) { + return marshalJSON(message) +} + +// DecodeServerMessage ports decodeServerMessage. The server never reads its +// own messages in production; this exists so the shared wire fixture can be +// replayed from Go. +func DecodeServerMessage(data []byte) (ServerMessage, error) { + messageType, err := typeOf(data, "type") + if err != nil { + return nil, err + } + switch messageType { + case "authenticated": + return decodeAuthenticated(data) + case "signaling-challenge-response": + return decodeChallengeResponse(data) + case "signal": + return decodeServerSignal(data) + case "restart-request": + return decodeServerRestartRequest(data) + case "route-update": + return decodeRouteUpdate(data) + case "route-status": + return decodeRouteStatus(data) + case "route-diagnostic-snapshot": + return decodeRouteDiagnosticSnapshotMessage(data) + case "sfu-config": + return decodeSfuConfig(data) + case "quality-settings": + return decodeQualitySettingsMessage(data) + case "route-policy": + return decodeRoutePolicyMessage(data) + case "pause-sharing-source": + return decodePauseSharingSource(data) + case "viewer-quality-evidence": + return decodeServerViewerQualityEvidence(data) + case "host-status": + return decodeHostStatus(data) + case "viewer-presence": + return decodeViewerPresence(data) + case "viewer-grant-revoked": + return decodeViewerGrantRevoked(data) + case "sharing-stopped": + return decodeEmptyServerMessage(data, SharingStoppedMessage{Type: messageType}) + case "room-closed": + return decodeRoomClosed(data) + case "error": + return decodeErrorMessage(data) + } + return nil, fmt.Errorf("unknown server message type %q", messageType) +} + +func decodeEmptyServerMessage(data []byte, message ServerMessage) (ServerMessage, error) { + var body struct { + Type string `json:"type"` + } + present, err := decodeObject(data, &body) + if err != nil { + return nil, err + } + if err := present.require("type"); err != nil { + return nil, err + } + return message, nil +} + +func validateAuthenticatedBase( + present fields, + protocol string, peerID string, roomExpiresAt *string, + maxViewers, endpointCapacity Int, connectionID *string, + codeEntryPolicy, viewerAuthorizationGeneration, mediaMode string, + shareGeneration *string, routeRevision Int, sfuStandbyURL *string, +) error { + if err := present.require("type", "protocol", "peerId", "maxViewers", + "endpointMediaCopyCapacity", "hostOnline", "iceConfig", "codeEntryPolicy", + "viewerAuthorizationGeneration", "mediaMode", "routeRevision", + "routeAssignment", "qualitySettings", "role"); err != nil { + return err + } + if err := present.requireNullable( + "roomExpiresAt", "connectionId", "shareGeneration"); err != nil { + return err + } + if err := present.optional("hostPaused", "routePolicy", "sfuStandbyUrl"); err != nil { + return err + } + if protocol != SignalingProtocol { + return errors.New("protocol is not the current signaling generation") + } + if !ValidOpaqueID(peerID) { + return errors.New("peerId is not an opaque id") + } + if roomExpiresAt != nil && !ValidISODateTime(*roomExpiresAt) { + return errors.New("roomExpiresAt is not an ISO instant") + } + if !inRangeInt(maxViewers, 1, MaxViewersPerRoomLimit) { + return errors.New("maxViewers is out of range") + } + if !inRangeInt(endpointCapacity, 1, MaxEndpointMediaCopyCapacity) { + return errors.New("endpointMediaCopyCapacity is out of range") + } + if connectionID != nil && !ValidOpaqueID(*connectionID) { + return errors.New("connectionId is not an opaque id") + } + if !enumOf(codeEntryPolicy, CodeEntryOpen, CodeEntryPrivate) { + return fmt.Errorf("unknown codeEntryPolicy %q", codeEntryPolicy) + } + if !ValidOpaqueID(viewerAuthorizationGeneration) { + return errors.New("viewerAuthorizationGeneration is not an opaque id") + } + if mediaMode != "peer-assisted" { + return errors.New("mediaMode must be peer-assisted") + } + if shareGeneration != nil && !ValidOpaqueID(*shareGeneration) { + return errors.New("shareGeneration is not an opaque id") + } + if !validRevision(routeRevision) { + return errors.New("routeRevision is out of range") + } + if sfuStandbyURL != nil && !ValidLiveKitWebSocketURL(*sfuStandbyURL) { + return errors.New("sfuStandbyUrl is not a LiveKit WebSocket URL") + } + return nil +} + +func decodeAuthenticated(data []byte) (ServerMessage, error) { + role, err := typeOf(data, "role") + if err != nil { + return nil, err + } + switch role { + case RoleHost: + var message AuthenticatedHostMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("viewerPasswordEnabled"); err != nil { + return nil, err + } + if err := validateAuthenticatedBase(present, message.Protocol, message.PeerID, + message.RoomExpiresAt, message.MaxViewers, message.EndpointMediaCopyCapacity, + message.ConnectionID, message.CodeEntryPolicy, + message.ViewerAuthorizationGeneration, message.MediaMode, + message.ShareGeneration, message.RouteRevision, message.SfuStandbyURL); err != nil { + return nil, err + } + if !present.has("routePolicy") { + message.RoutePolicy = DefaultRoutePolicy + } + return message, nil + case RoleViewer: + var message AuthenticatedViewerMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := validateAuthenticatedBase(present, message.Protocol, message.PeerID, + message.RoomExpiresAt, message.MaxViewers, message.EndpointMediaCopyCapacity, + message.ConnectionID, message.CodeEntryPolicy, + message.ViewerAuthorizationGeneration, message.MediaMode, + message.ShareGeneration, message.RouteRevision, message.SfuStandbyURL); err != nil { + return nil, err + } + if !present.has("routePolicy") { + message.RoutePolicy = DefaultRoutePolicy + } + return message, nil + } + return nil, fmt.Errorf("unknown authenticated role %q", role) +} + +func decodeChallengeResponse(data []byte) (ServerMessage, error) { + var message SignalingChallengeResponseMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "sequence"); err != nil { + return nil, err + } + if !inRangeInt(message.Sequence, 0, MaxSafeInteger) { + return nil, errors.New("sequence is out of range") + } + return message, nil +} + +func decodeServerSignal(data []byte) (ServerMessage, error) { + var message ServerSignalMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "fromPeerId", "payload"); err != nil { + return nil, err + } + if !ValidOpaqueID(message.FromPeerID) { + return nil, errors.New("fromPeerId is not an opaque id") + } + return message, nil +} + +func decodeServerRestartRequest(data []byte) (ServerMessage, error) { + var message ServerRestartRequestMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "fromPeerId", "connectionId", "rebuild"); err != nil { + return nil, err + } + if !ValidOpaqueID(message.FromPeerID) || !ValidOpaqueID(message.ConnectionID) { + return nil, errors.New("restart-request ids are not opaque ids") + } + return message, nil +} + +func decodeRouteUpdate(data []byte) (ServerMessage, error) { + phase, err := typeOf(data, "phase") + if err != nil { + return nil, err + } + switch phase { + case "prepare": + var message RouteUpdatePrepareMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require( + "type", "revision", "phase", "assignment", "candidate"); err != nil { + return nil, err + } + if !validRevision(message.Revision) { + return nil, errors.New("revision is out of range") + } + return message, nil + case "active": + var message RouteUpdateActiveMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "revision", "phase", "assignment"); err != nil { + return nil, err + } + if !validRevision(message.Revision) { + return nil, errors.New("revision is out of range") + } + return message, nil + } + return nil, fmt.Errorf("unknown route-update phase %q", phase) +} + +func decodeRouteStatus(data []byte) (ServerMessage, error) { + var message RouteStatusMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "revision", "state", "reason"); err != nil { + return nil, err + } + if !validRevision(message.Revision) { + return nil, errors.New("revision is out of range") + } + switch { + case message.State == "waiting" && message.Reason == "sfu-admission": + case message.State == "failed" && message.Reason == "route-exhausted": + default: + return nil, fmt.Errorf( + "route-status pairs state %q with reason %q", message.State, message.Reason) + } + return message, nil +} + +func decodeRouteDiagnosticSnapshotMessage(data []byte) (ServerMessage, error) { + var message RouteDiagnosticSnapshotMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "snapshot"); err != nil { + return nil, err + } + return message, nil +} + +func decodeSfuConfig(data []byte) (ServerMessage, error) { + var message SfuConfigMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "revision", "url", "token"); err != nil { + return nil, err + } + if !validRevision(message.Revision) { + return nil, errors.New("revision is out of range") + } + if !ValidLiveKitWebSocketURL(message.URL) { + return nil, errors.New("url is not a LiveKit WebSocket URL") + } + if length := UTF16Length(message.Token); length < 1 || length > MaxSfuTokenLength { + return nil, errors.New("token length is out of range") + } + return message, nil +} + +func decodeQualitySettingsMessage(data []byte) (ServerMessage, error) { + var message QualitySettingsMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "qualitySettings"); err != nil { + return nil, err + } + return message, nil +} + +func decodeRoutePolicyMessage(data []byte) (ServerMessage, error) { + var message RoutePolicyMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "shareGeneration", "routePolicy"); err != nil { + return nil, err + } + if err := present.optional("sfuStandbyUrl"); err != nil { + return nil, err + } + if !ValidOpaqueID(message.ShareGeneration) { + return nil, errors.New("shareGeneration is not an opaque id") + } + if message.SfuStandbyURL != nil && !ValidLiveKitWebSocketURL(*message.SfuStandbyURL) { + return nil, errors.New("sfuStandbyUrl is not a LiveKit WebSocket URL") + } + return message, nil +} + +func decodePauseSharingSource(data []byte) (ServerMessage, error) { + var message PauseSharingSourceMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "shareGeneration"); err != nil { + return nil, err + } + if !ValidOpaqueID(message.ShareGeneration) { + return nil, errors.New("shareGeneration is not an opaque id") + } + return message, nil +} + +func decodeServerViewerQualityEvidence(data []byte) (ServerMessage, error) { + var message ServerViewerQualityEvidenceMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "viewerPeerId", "upstream", "guard", + "sequence", "windowMs", "metrics"); err != nil { + return nil, err + } + if !ValidOpaqueID(message.ViewerPeerID) { + return nil, errors.New("viewerPeerId is not an opaque id") + } + // The forwarded envelope narrows the upstream to the active variants. + if message.Upstream.Kind != "peer" && message.Upstream.Kind != "sfu" { + return nil, errors.New("forwarded evidence upstream must be peer or sfu") + } + if err := validateEvidenceWindow(message.Sequence, message.WindowMs); err != nil { + return nil, err + } + return message, nil +} + +func decodeHostStatus(data []byte) (ServerMessage, error) { + var message HostStatusMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "online", "paused"); err != nil { + return nil, err + } + return message, nil +} + +func decodeViewerPresence(data []byte) (ServerMessage, error) { + var message ViewerPresenceMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "viewers"); err != nil { + return nil, err + } + if len(message.Viewers) > MaxParticipantsPerRoomLimit { + return nil, errors.New("viewer presence holds too many entries") + } + // superRefine: viewer presence peer IDs must be unique. + seen := make(map[string]struct{}, len(message.Viewers)) + for _, viewer := range message.Viewers { + if _, duplicate := seen[viewer.PeerID]; duplicate { + return nil, errors.New("Viewer presence peer IDs must be unique") + } + seen[viewer.PeerID] = struct{}{} + } + return message, nil +} + +func decodeViewerGrantRevoked(data []byte) (ServerMessage, error) { + var message ViewerGrantRevokedMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "viewerAuthorizationGeneration"); err != nil { + return nil, err + } + if !ValidOpaqueID(message.ViewerAuthorizationGeneration) { + return nil, errors.New("viewerAuthorizationGeneration is not an opaque id") + } + return message, nil +} + +func decodeRoomClosed(data []byte) (ServerMessage, error) { + var message RoomClosedMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "reason"); err != nil { + return nil, err + } + if !enumOf(message.Reason, "host-ended", "expired") { + return nil, fmt.Errorf("unknown room-closed reason %q", message.Reason) + } + return message, nil +} + +func decodeErrorMessage(data []byte) (ServerMessage, error) { + var message ErrorMessage + present, err := decodeObject(data, &message) + if err != nil { + return nil, err + } + if err := present.require("type", "code", "message"); err != nil { + return nil, err + } + if !enumOf(message.Code, errorCodes...) { + return nil, fmt.Errorf("unknown error code %q", message.Code) + } + if length := UTF16Length(message.Message); length < 1 || length > 256 { + return nil, errors.New("error message length is out of range") + } + return message, nil +} diff --git a/internal/server/protocol/types.go b/internal/server/protocol/types.go new file mode 100644 index 0000000..25a5f07 --- /dev/null +++ b/internal/server/protocol/types.go @@ -0,0 +1,1089 @@ +package protocol + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" +) + +// Role mirrors roleSchema. +type Role = string + +// Roles. +const ( + RoleHost Role = "host" + RoleViewer Role = "viewer" +) + +// CodeEntryPolicy mirrors codeEntryPolicySchema. +type CodeEntryPolicy = string + +// Code entry policies. +const ( + CodeEntryOpen CodeEntryPolicy = "open" + CodeEntryPrivate CodeEntryPolicy = "private" +) + +// Codec evidence patterns from viewerQualityEvidenceMetricsSchema. +var ( + codecProfilePattern = regexp.MustCompile(`^[a-z0-9-]+=[a-z0-9]+$`) + codecParametersPattern = regexp.MustCompile( + `^[a-z0-9-]+=[a-z0-9]+(?:; [a-z0-9-]+=[a-z0-9]+)*$`) + audioMimeTypePattern = regexp.MustCompile(`(?i)^audio/[A-Za-z0-9.+-]{1,32}$`) +) + +// Enumerations mirrored from src/shared/protocol.ts. +var ( + qualityResolutions = []string{"480p", "720p", "1080p", "1440p"} + degradationPreferences = []string{"maintain-resolution", "balanced", "maintain-framerate"} + screenAudioQualities = []string{"saver", "music", "very-high"} + mediaRoutePhases = []string{"prepare", "active"} + routeDemandReasons = []string{"join", "edge-unavailable", "parent-departed", "capacity-reduction", "sfu-bootstrap", "direct-convergence", "quality-convergence", "root-convergence"} + routeDiagnosticFinalRoutes = []string{"direct", "sfu", "waiting", "failed"} + routeDiagnosticBuckets = []string{"none", "stale", "endpoint-capacity", "sfu-admission", "candidate-failed", "first-frame-timeout", "operation-deadline", "aborted"} + routeDiagnosticStages = []string{"admission", "first-frame", "quality-proof"} + senderQualityStates = []string{"unknown", "healthy", "degraded"} + senderQualityDegradedReasons = []string{"none", "bandwidth", "cpu"} + errorCodes = []string{"AUTH_REQUIRED", "INVALID_MESSAGE", "INVALID_TOKEN", "ROOM_ACCESS_DENIED", "ROOM_NOT_FOUND", "ROOM_EXPIRED", "ROOM_FULL", "HOST_ALREADY_CONNECTED", "PEER_NOT_FOUND", "FORBIDDEN", "SERVER_ERROR"} +) + +func validRevision(revision Int) bool { + return inRangeInt(revision, 0, MaxMediaRouteRevision) +} + +// --------------------------------------------------------------------------- +// mediaRouteUpstreamSchema +// --------------------------------------------------------------------------- + +// MediaRouteUpstream mirrors mediaRouteUpstreamSchema. +type MediaRouteUpstream struct { + Kind string `json:"kind"` + PeerID string `json:"peerId,omitempty"` +} + +// NoUpstream builds { kind: "none" }. +func NoUpstream() MediaRouteUpstream { return MediaRouteUpstream{Kind: "none"} } + +// PeerUpstream builds { kind: "peer", peerId }. +func PeerUpstream(peerID string) MediaRouteUpstream { + return MediaRouteUpstream{Kind: "peer", PeerID: peerID} +} + +// SfuUpstream builds { kind: "sfu" }. +func SfuUpstream() MediaRouteUpstream { return MediaRouteUpstream{Kind: "sfu"} } + +// UnmarshalJSON implements json.Unmarshaler. +func (v *MediaRouteUpstream) UnmarshalJSON(data []byte) error { + *v = MediaRouteUpstream{} + type raw MediaRouteUpstream + present, err := decodeObject(data, (*raw)(v)) + if err != nil { + return err + } + if err := present.require("kind"); err != nil { + return err + } + switch v.Kind { + case "none", "sfu": + if present.has("peerId") { + return fmt.Errorf("upstream %q must not carry peerId", v.Kind) + } + case "peer": + if err := present.require("peerId"); err != nil { + return err + } + if !ValidOpaqueID(v.PeerID) { + return errors.New("upstream peerId is not an opaque id") + } + default: + return fmt.Errorf("unknown upstream kind %q", v.Kind) + } + return nil +} + +// --------------------------------------------------------------------------- +// qualitySettingsSchema / routePolicySchema / runtimeCapabilitiesSchema +// --------------------------------------------------------------------------- + +// QualitySettings mirrors qualitySettingsSchema. +type QualitySettings struct { + Resolution string `json:"resolution"` + MaxFramerate Int `json:"maxFramerate"` + MaxBitrate Int `json:"maxBitrate"` + DegradationPreference string `json:"degradationPreference"` + ScreenAudioQuality string `json:"screenAudioQuality,omitempty"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *QualitySettings) UnmarshalJSON(data []byte) error { + *v = QualitySettings{} + type raw QualitySettings + present, err := decodeObject(data, (*raw)(v)) + if err != nil { + return err + } + if err := present.require( + "resolution", "maxFramerate", "maxBitrate", "degradationPreference"); err != nil { + return err + } + if err := present.optional("screenAudioQuality"); err != nil { + return err + } + if !enumOf(v.Resolution, qualityResolutions...) { + return fmt.Errorf("unknown resolution %q", v.Resolution) + } + if !inRangeInt(v.MaxFramerate, 15, 60) { + return errors.New("maxFramerate is out of range") + } + if !inRangeInt(v.MaxBitrate, 2_000_000, 12_000_000) { + return errors.New("maxBitrate is out of range") + } + if !enumOf(v.DegradationPreference, degradationPreferences...) { + return fmt.Errorf("unknown degradationPreference %q", v.DegradationPreference) + } + if present.has("screenAudioQuality") && !enumOf(v.ScreenAudioQuality, screenAudioQualities...) { + return fmt.Errorf("unknown screenAudioQuality %q", v.ScreenAudioQuality) + } + return nil +} + +// RoutePolicy mirrors routePolicySchema. +type RoutePolicy struct { + PeerOnly bool `json:"peerOnly"` + TopologyOptimization bool `json:"topologyOptimization"` + NatPrediction bool `json:"natPrediction"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *RoutePolicy) UnmarshalJSON(data []byte) error { + *v = RoutePolicy{} + type raw RoutePolicy + present, err := decodeObject(data, (*raw)(v)) + if err != nil { + return err + } + return present.require("peerOnly", "topologyOptimization", "natPrediction") +} + +// RuntimeCapabilities mirrors runtimeCapabilitiesSchema. +type RuntimeCapabilities struct { + NatPrediction bool `json:"natPrediction"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *RuntimeCapabilities) UnmarshalJSON(data []byte) error { + *v = RuntimeCapabilities{} + type raw RuntimeCapabilities + present, err := decodeObject(data, (*raw)(v)) + if err != nil { + return err + } + return present.require("natPrediction") +} + +// --------------------------------------------------------------------------- +// iceConfigSchema +// --------------------------------------------------------------------------- + +// IceServerURLs mirrors z.union([stunUrlSchema, z.array(stunUrlSchema)]). +type IceServerURLs struct { + Single string + List []string +} + +// IceServerURLList builds the array form. +func IceServerURLList(urls ...string) IceServerURLs { + if urls == nil { + urls = []string{} + } + return IceServerURLs{List: urls} +} + +// MarshalJSON implements json.Marshaler. +func (v IceServerURLs) MarshalJSON() ([]byte, error) { + if v.List != nil { + return marshalJSON(v.List) + } + return marshalJSON(v.Single) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *IceServerURLs) UnmarshalJSON(data []byte) error { + *v = IceServerURLs{} + if len(data) > 0 && data[0] == '[' { + var list []string + if err := json.Unmarshal(data, &list); err != nil { + return err + } + if len(list) < 1 || len(list) > MaxIceServerURLs { + return errors.New("iceServers[].urls length is out of range") + } + for _, url := range list { + if !ValidStunURLValue(url) { + return errors.New("Invalid STUN URL") + } + } + v.List = list + return nil + } + var single string + if err := json.Unmarshal(data, &single); err != nil { + return err + } + if !ValidStunURLValue(single) { + return errors.New("Invalid STUN URL") + } + v.Single = single + return nil +} + +// ValidStunURLValue mirrors stunUrlSchema: 1..512 UTF-16 code units plus +// isValidStunUrl. It is the single owner of that bound; config validates +// STUN_URLS and the Client's local STUN list through it. +func ValidStunURLValue(value string) bool { + length := UTF16Length(value) + return length >= 1 && length <= MaxStunURLLength && ValidStunURL(value) +} + +// IceServer mirrors iceServerSchema. +type IceServer struct { + URLs IceServerURLs `json:"urls"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *IceServer) UnmarshalJSON(data []byte) error { + *v = IceServer{} + type raw IceServer + present, err := decodeObject(data, (*raw)(v)) + if err != nil { + return err + } + return present.require("urls") +} + +// IceConfig mirrors iceConfigSchema. +type IceConfig struct { + IceServers []IceServer `json:"iceServers"` + NatPredictionStunURLs []string `json:"natPredictionStunUrls"` +} + +// MarshalJSON implements json.Marshaler. +func (v IceConfig) MarshalJSON() ([]byte, error) { + type raw IceConfig + value := raw(v) + if value.IceServers == nil { + value.IceServers = []IceServer{} + } + if value.NatPredictionStunURLs == nil { + value.NatPredictionStunURLs = []string{} + } + return marshalJSON(value) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *IceConfig) UnmarshalJSON(data []byte) error { + *v = IceConfig{} + type raw IceConfig + present, err := decodeObject(data, (*raw)(v)) + if err != nil { + return err + } + if err := present.require("iceServers", "natPredictionStunUrls"); err != nil { + return err + } + if len(v.IceServers) > 8 { + return errors.New("iceServers holds too many entries") + } + if len(v.NatPredictionStunURLs) > MaxNatPredictionAuxiliaryStunURLs { + return errors.New("natPredictionStunUrls holds too many entries") + } + for _, url := range v.NatPredictionStunURLs { + if !ValidStunURLValue(url) { + return errors.New("Invalid STUN URL") + } + } + return nil +} + +// --------------------------------------------------------------------------- +// signalPayloadSchema +// --------------------------------------------------------------------------- + +// SessionDescription mirrors sessionDescriptionSchema. +type SessionDescription struct { + Type string `json:"type"` + SDP string `json:"sdp"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *SessionDescription) UnmarshalJSON(data []byte) error { + *v = SessionDescription{} + type raw SessionDescription + present, err := decodeObject(data, (*raw)(v)) + if err != nil { + return err + } + if err := present.require("type", "sdp"); err != nil { + return err + } + if !enumOf(v.Type, "offer", "answer") { + return fmt.Errorf("unknown description type %q", v.Type) + } + if length := UTF16Length(v.SDP); length < 1 || length > 48*1024 { + return errors.New("sdp length is out of range") + } + return nil +} + +// IceCandidate mirrors iceCandidateSchema. +type IceCandidate struct { + Candidate string `json:"candidate"` + SDPMid Nullable[string] `json:"sdpMid,omitzero"` + SDPMLineIndex Nullable[Int] `json:"sdpMLineIndex,omitzero"` + UsernameFragment Nullable[string] `json:"usernameFragment,omitzero"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *IceCandidate) UnmarshalJSON(data []byte) error { + *v = IceCandidate{} + type raw IceCandidate + present, err := decodeObject(data, (*raw)(v)) + if err != nil { + return err + } + if err := present.require("candidate"); err != nil { + return err + } + if UTF16Length(v.Candidate) > 4096 { + return errors.New("candidate is too long") + } + if v.SDPMid.Value != nil && UTF16Length(*v.SDPMid.Value) > 128 { + return errors.New("sdpMid is too long") + } + if v.SDPMLineIndex.Value != nil && !inRangeInt(*v.SDPMLineIndex.Value, 0, 255) { + return errors.New("sdpMLineIndex is out of range") + } + if v.UsernameFragment.Value != nil && UTF16Length(*v.UsernameFragment.Value) > 256 { + return errors.New("usernameFragment is too long") + } + return nil +} + +// SignalPayload mirrors signalPayloadSchema. Candidate is nil for the JSON +// null the "candidate" variant requires. +type SignalPayload struct { + Kind string + ConnectionID string + Description *SessionDescription + Candidate *IceCandidate +} + +// MarshalJSON implements json.Marshaler. +func (v SignalPayload) MarshalJSON() ([]byte, error) { + if v.Kind == "description" { + return marshalJSON(struct { + Kind string `json:"kind"` + ConnectionID string `json:"connectionId"` + Description *SessionDescription `json:"description"` + }{v.Kind, v.ConnectionID, v.Description}) + } + return marshalJSON(struct { + Kind string `json:"kind"` + ConnectionID string `json:"connectionId"` + Candidate *IceCandidate `json:"candidate"` + }{v.Kind, v.ConnectionID, v.Candidate}) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *SignalPayload) UnmarshalJSON(data []byte) error { + *v = SignalPayload{} + kind, err := typeOf(data, "kind") + if err != nil { + return err + } + switch kind { + case "description": + var body struct { + Kind string `json:"kind"` + ConnectionID string `json:"connectionId"` + Description *SessionDescription `json:"description"` + } + present, err := decodeObject(data, &body) + if err != nil { + return err + } + if err := present.require("kind", "connectionId", "description"); err != nil { + return err + } + if !ValidOpaqueID(body.ConnectionID) { + return errors.New("connectionId is not an opaque id") + } + *v = SignalPayload{Kind: kind, ConnectionID: body.ConnectionID, Description: body.Description} + return nil + case "candidate": + var body struct { + Kind string `json:"kind"` + ConnectionID string `json:"connectionId"` + Candidate *IceCandidate `json:"candidate"` + } + present, err := decodeObject(data, &body) + if err != nil { + return err + } + if err := present.require("kind", "connectionId"); err != nil { + return err + } + if err := present.requireNullable("candidate"); err != nil { + return err + } + if !ValidOpaqueID(body.ConnectionID) { + return errors.New("connectionId is not an opaque id") + } + *v = SignalPayload{Kind: kind, ConnectionID: body.ConnectionID, Candidate: body.Candidate} + return nil + } + return fmt.Errorf("unknown signal payload kind %q", kind) +} + +// --------------------------------------------------------------------------- +// preparedRouteCandidateSchema / participantRouteAssignmentSchema +// --------------------------------------------------------------------------- + +// ConnectionAttempt mirrors preparedRouteCandidateSchema.connectionAttempt. +type ConnectionAttempt struct { + Current Int `json:"current"` + Total Int `json:"total"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *ConnectionAttempt) UnmarshalJSON(data []byte) error { + *v = ConnectionAttempt{} + type raw ConnectionAttempt + present, err := decodeObject(data, (*raw)(v)) + if err != nil { + return err + } + if err := present.require("current", "total"); err != nil { + return err + } + if !inRangeInt(v.Current, 1, 3) { + return errors.New("connectionAttempt.current is out of range") + } + if v.Total != 3 { + return errors.New("connectionAttempt.total must be 3") + } + return nil +} + +// PreparedRouteCandidate mirrors preparedRouteCandidateSchema. +type PreparedRouteCandidate struct { + ChildPeerID string `json:"childPeerId"` + ConnectionID string `json:"connectionId"` + Transport string `json:"transport"` + QualityProbe bool `json:"qualityProbe"` + ConnectionAttempt *ConnectionAttempt `json:"connectionAttempt,omitempty"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *PreparedRouteCandidate) UnmarshalJSON(data []byte) error { + *v = PreparedRouteCandidate{} + type raw PreparedRouteCandidate + present, err := decodeObject(data, (*raw)(v)) + if err != nil { + return err + } + if err := present.require( + "childPeerId", "connectionId", "transport", "qualityProbe"); err != nil { + return err + } + if err := present.optional("connectionAttempt"); err != nil { + return err + } + if !ValidOpaqueID(v.ChildPeerID) || !ValidOpaqueID(v.ConnectionID) { + return errors.New("prepared route candidate ids are not opaque ids") + } + if !enumOf(v.Transport, "direct", "sfu") { + return fmt.Errorf("unknown transport %q", v.Transport) + } + return nil +} + +// ParticipantRouteAssignment mirrors participantRouteAssignmentSchema. +type ParticipantRouteAssignment struct { + Upstream MediaRouteUpstream `json:"upstream"` + ChildPeerIDs []string `json:"childPeerIds"` + SfuPublicationGeneration *string `json:"sfuPublicationGeneration"` +} + +// MarshalJSON implements json.Marshaler. +func (v ParticipantRouteAssignment) MarshalJSON() ([]byte, error) { + type raw ParticipantRouteAssignment + value := raw(v) + if value.ChildPeerIDs == nil { + value.ChildPeerIDs = []string{} + } + return marshalJSON(value) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *ParticipantRouteAssignment) UnmarshalJSON(data []byte) error { + *v = ParticipantRouteAssignment{} + type raw ParticipantRouteAssignment + present, err := decodeObject(data, (*raw)(v)) + if err != nil { + return err + } + if err := present.require("upstream", "childPeerIds"); err != nil { + return err + } + if err := present.requireNullable("sfuPublicationGeneration"); err != nil { + return err + } + if len(v.ChildPeerIDs) > MaxEndpointMediaCopyCapacity { + return errors.New("childPeerIds holds too many entries") + } + seen := make(map[string]struct{}, len(v.ChildPeerIDs)) + for _, peerID := range v.ChildPeerIDs { + if !ValidOpaqueID(peerID) { + return errors.New("childPeerIds holds a non-opaque id") + } + if _, duplicate := seen[peerID]; duplicate { + return errors.New("childPeerIds must be unique") + } + seen[peerID] = struct{}{} + } + if v.SfuPublicationGeneration != nil && !ValidOpaqueID(*v.SfuPublicationGeneration) { + return errors.New("sfuPublicationGeneration is not an opaque id") + } + // superRefine: an SFU upstream requires its publication generation and a + // peer upstream cannot own one. + if v.Upstream.Kind == "sfu" && v.SfuPublicationGeneration == nil { + return errors.New("An SFU upstream requires its publication generation") + } + if v.Upstream.Kind == "peer" && v.SfuPublicationGeneration != nil { + return errors.New("A peer upstream cannot own an SFU publication generation") + } + return nil +} + +// --------------------------------------------------------------------------- +// routeDiagnosticSnapshotSchema +// --------------------------------------------------------------------------- + +// RouteDiagnosticParent mirrors routeDiagnosticParentSchema. +type RouteDiagnosticParent struct { + Kind string `json:"kind"` + Ordinal Int `json:"ordinal,omitempty"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *RouteDiagnosticParent) UnmarshalJSON(data []byte) error { + *v = RouteDiagnosticParent{} + type raw RouteDiagnosticParent + present, err := decodeObject(data, (*raw)(v)) + if err != nil { + return err + } + if err := present.require("kind"); err != nil { + return err + } + switch v.Kind { + case "none", "host", "sfu": + if present.has("ordinal") { + return fmt.Errorf("route diagnostic parent %q must not carry ordinal", v.Kind) + } + case "viewer": + if err := present.require("ordinal"); err != nil { + return err + } + if !inRangeInt(v.Ordinal, 1, MaxViewersPerRoomLimit) { + return errors.New("route diagnostic parent ordinal is out of range") + } + default: + return fmt.Errorf("unknown route diagnostic parent kind %q", v.Kind) + } + return nil +} + +// RouteDiagnosticQuality mirrors routeDiagnosticQualitySchema. +type RouteDiagnosticQuality struct { + EligibleWindows Int `json:"eligibleWindows"` + EligibleDurationMs Int `json:"eligibleDurationMs"` + FreezeWindows Int `json:"freezeWindows"` + FreezeCount Int `json:"freezeCount"` + FreezeDurationMs Int `json:"freezeDurationMs"` + PauseCount Int `json:"pauseCount"` + PauseDurationMs Int `json:"pauseDurationMs"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *RouteDiagnosticQuality) UnmarshalJSON(data []byte) error { + *v = RouteDiagnosticQuality{} + type raw RouteDiagnosticQuality + present, err := decodeObject(data, (*raw)(v)) + if err != nil { + return err + } + if err := present.require("eligibleWindows", "eligibleDurationMs", "freezeWindows", + "freezeCount", "freezeDurationMs", "pauseCount", "pauseDurationMs"); err != nil { + return err + } + for _, value := range []Int{v.EligibleWindows, v.EligibleDurationMs, v.FreezeWindows, + v.FreezeCount, v.FreezeDurationMs, v.PauseCount, v.PauseDurationMs} { + if !inRangeInt(value, 0, MaxSafeInteger) { + return errors.New("route diagnostic quality value is out of range") + } + } + return nil +} + +// RouteDiagnosticChild mirrors routeDiagnosticChildSchema. +type RouteDiagnosticChild struct { + Ordinal Int `json:"ordinal"` + Parent RouteDiagnosticParent `json:"parent"` + EffectiveCapacity Int `json:"effectiveCapacity"` + ChildCount Int `json:"childCount"` + DemandAgeMs *Int `json:"demandAgeMs"` + QueueWaitMs *Int `json:"queueWaitMs"` + CandidateStartMs *Int `json:"candidateStartMs"` + FirstDecodedFrameMs *Int `json:"firstDecodedFrameMs"` + FinalMs *Int `json:"finalMs"` + FinalRoute string `json:"finalRoute"` + RejectionBucket string `json:"rejectionBucket"` + Quality *RouteDiagnosticQuality `json:"quality"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *RouteDiagnosticChild) UnmarshalJSON(data []byte) error { + *v = RouteDiagnosticChild{} + type raw RouteDiagnosticChild + present, err := decodeObject(data, (*raw)(v)) + if err != nil { + return err + } + if err := present.require("ordinal", "parent", "effectiveCapacity", "childCount", + "finalRoute", "rejectionBucket"); err != nil { + return err + } + if err := present.requireNullable("demandAgeMs", "queueWaitMs", "candidateStartMs", + "firstDecodedFrameMs", "finalMs", "quality"); err != nil { + return err + } + if !inRangeInt(v.Ordinal, 1, MaxViewersPerRoomLimit) { + return errors.New("route diagnostic ordinal is out of range") + } + if !inRangeInt(v.EffectiveCapacity, 0, MaxEndpointMediaCopyCapacity) || + !inRangeInt(v.ChildCount, 0, MaxEndpointMediaCopyCapacity) { + return errors.New("route diagnostic capacity is out of range") + } + for _, duration := range []*Int{v.DemandAgeMs, v.QueueWaitMs, v.CandidateStartMs, + v.FirstDecodedFrameMs, v.FinalMs} { + if duration != nil && !inRangeInt(*duration, 0, MaxSafeInteger) { + return errors.New("route diagnostic duration is out of range") + } + } + if !enumOf(v.FinalRoute, routeDiagnosticFinalRoutes...) { + return fmt.Errorf("unknown finalRoute %q", v.FinalRoute) + } + if !enumOf(v.RejectionBucket, routeDiagnosticBuckets...) { + return fmt.Errorf("unknown rejectionBucket %q", v.RejectionBucket) + } + return nil +} + +// RouteDiagnosticOperation mirrors routeDiagnosticOperationSchema. +type RouteDiagnosticOperation struct { + ChildOrdinal Int `json:"childOrdinal"` + Reason string `json:"reason"` + Stage string `json:"stage"` + Cursor Int `json:"cursor"` + CandidateCount Int `json:"candidateCount"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *RouteDiagnosticOperation) UnmarshalJSON(data []byte) error { + *v = RouteDiagnosticOperation{} + type raw RouteDiagnosticOperation + present, err := decodeObject(data, (*raw)(v)) + if err != nil { + return err + } + if err := present.require( + "childOrdinal", "reason", "stage", "cursor", "candidateCount"); err != nil { + return err + } + if !inRangeInt(v.ChildOrdinal, 1, MaxViewersPerRoomLimit) { + return errors.New("route diagnostic childOrdinal is out of range") + } + if !enumOf(v.Reason, routeDemandReasons...) { + return fmt.Errorf("unknown route demand reason %q", v.Reason) + } + if !enumOf(v.Stage, routeDiagnosticStages...) { + return fmt.Errorf("unknown route diagnostic stage %q", v.Stage) + } + if !inRangeInt(v.Cursor, 0, MaxViewersPerRoomLimit) { + return errors.New("route diagnostic cursor is out of range") + } + if !inRangeInt(v.CandidateCount, 1, MaxViewersPerRoomLimit+1) { + return errors.New("route diagnostic candidateCount is out of range") + } + return nil +} + +// RouteDiagnosticSnapshot mirrors routeDiagnosticSnapshotSchema. +type RouteDiagnosticSnapshot struct { + Children []RouteDiagnosticChild `json:"children"` + Operation *RouteDiagnosticOperation `json:"operation"` +} + +// MarshalJSON implements json.Marshaler. +func (v RouteDiagnosticSnapshot) MarshalJSON() ([]byte, error) { + type raw RouteDiagnosticSnapshot + value := raw(v) + if value.Children == nil { + value.Children = []RouteDiagnosticChild{} + } + return marshalJSON(value) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *RouteDiagnosticSnapshot) UnmarshalJSON(data []byte) error { + *v = RouteDiagnosticSnapshot{} + type raw RouteDiagnosticSnapshot + present, err := decodeObject(data, (*raw)(v)) + if err != nil { + return err + } + if err := present.require("children"); err != nil { + return err + } + if err := present.requireNullable("operation"); err != nil { + return err + } + if len(v.Children) > MaxViewersPerRoomLimit { + return errors.New("route diagnostic snapshot holds too many children") + } + // superRefine: unique ordinals, resolvable viewer parents, and an + // operation that names a present child with a cursor inside its candidates. + ordinals := make(map[Int]struct{}, len(v.Children)) + for _, child := range v.Children { + ordinals[child.Ordinal] = struct{}{} + } + if len(ordinals) != len(v.Children) { + return errors.New("Route diagnostic ordinals must be unique") + } + for _, child := range v.Children { + if child.Parent.Kind != "viewer" { + continue + } + if _, ok := ordinals[child.Parent.Ordinal]; !ok || child.Parent.Ordinal == child.Ordinal { + return errors.New("Route diagnostic parent ordinal is invalid") + } + } + if v.Operation != nil { + if _, ok := ordinals[v.Operation.ChildOrdinal]; !ok || + v.Operation.Cursor >= v.Operation.CandidateCount { + return errors.New("Route diagnostic operation is invalid") + } + } + return nil +} + +// --------------------------------------------------------------------------- +// participantPresenceEntrySchema +// --------------------------------------------------------------------------- + +// ParticipantPresenceEntry mirrors participantPresenceEntrySchema. The viewer +// variant is viewerPresenceEntrySchema. +type ParticipantPresenceEntry struct { + Role Role `json:"role"` + PeerID string `json:"peerId"` + DisplayName DisplayName `json:"displayName"` + Upstream MediaRouteUpstream `json:"upstream"` + MediaReady bool `json:"mediaReady,omitempty"` +} + +// HostPresenceEntry builds the host entry, whose upstream is always +// { kind: "none" } and which never carries mediaReady. +func HostPresenceEntry(peerID string, displayName DisplayName) ParticipantPresenceEntry { + return ParticipantPresenceEntry{ + Role: RoleHost, PeerID: peerID, DisplayName: displayName, Upstream: NoUpstream(), + } +} + +// NewViewerPresenceEntry builds a viewer entry; mediaReady is emitted only +// when true, matching `...(mediaReady ? { mediaReady: true } : {})`. +func NewViewerPresenceEntry( + peerID string, displayName DisplayName, upstream MediaRouteUpstream, mediaReady bool, +) ParticipantPresenceEntry { + return ParticipantPresenceEntry{ + Role: RoleViewer, PeerID: peerID, DisplayName: displayName, + Upstream: upstream, MediaReady: mediaReady, + } +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *ParticipantPresenceEntry) UnmarshalJSON(data []byte) error { + *v = ParticipantPresenceEntry{} + type raw ParticipantPresenceEntry + present, err := decodeObject(data, (*raw)(v)) + if err != nil { + return err + } + if err := present.require("role", "peerId", "displayName", "upstream"); err != nil { + return err + } + if !ValidOpaqueID(v.PeerID) { + return errors.New("presence peerId is not an opaque id") + } + switch v.Role { + case RoleHost: + if present.has("mediaReady") { + return errors.New("host presence must not carry mediaReady") + } + if v.Upstream.Kind != "none" { + return errors.New("host presence upstream must be none") + } + case RoleViewer: + if err := present.optional("mediaReady"); err != nil { + return err + } + if present.has("mediaReady") && !v.MediaReady { + return errors.New("mediaReady must be true when present") + } + default: + return fmt.Errorf("unknown presence role %q", v.Role) + } + return nil +} + +// --------------------------------------------------------------------------- +// viewerQualityEvidenceMetricsSchema / senderQualityDiagnosticsSchema +// --------------------------------------------------------------------------- + +// ViewerQualityEvidenceGuard mirrors viewerQualityEvidenceGuardSchema. +type ViewerQualityEvidenceGuard struct { + ConnectionID string `json:"connectionId"` + RouteRevision Int `json:"routeRevision"` + PresentationEpoch Int `json:"presentationEpoch"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *ViewerQualityEvidenceGuard) UnmarshalJSON(data []byte) error { + *v = ViewerQualityEvidenceGuard{} + type raw ViewerQualityEvidenceGuard + present, err := decodeObject(data, (*raw)(v)) + if err != nil { + return err + } + if err := present.require("connectionId", "routeRevision", "presentationEpoch"); err != nil { + return err + } + if !ValidOpaqueID(v.ConnectionID) { + return errors.New("guard connectionId is not an opaque id") + } + if !validRevision(v.RouteRevision) { + return errors.New("guard routeRevision is out of range") + } + if !inRangeInt(v.PresentationEpoch, 0, MaxSafeInteger) { + return errors.New("guard presentationEpoch is out of range") + } + return nil +} + +// ViewerQualityEvidenceMetrics mirrors viewerQualityEvidenceMetricsSchema. +type ViewerQualityEvidenceMetrics struct { + NatTraversalPath string `json:"natTraversalPath"` + Width *Int `json:"width"` + Height *Int `json:"height"` + FramesPerSecond *Num `json:"framesPerSecond"` + BitrateKbps *Num `json:"bitrateKbps"` + PacketsReceivedDelta *Int `json:"packetsReceivedDelta"` + PacketsLostDelta *Int `json:"packetsLostDelta"` + RttMs *Num `json:"rttMs"` + JitterMs *Num `json:"jitterMs"` + FramesDecodedDelta *Int `json:"framesDecodedDelta"` + FramesDroppedDelta *Int `json:"framesDroppedDelta"` + DecodeMsPerFrame *Num `json:"decodeMsPerFrame"` + FreezeCountDelta *Int `json:"freezeCountDelta"` + FreezeDurationMsDelta *Num `json:"freezeDurationMsDelta"` + PauseCountDelta *Int `json:"pauseCountDelta"` + PauseDurationMsDelta *Num `json:"pauseDurationMsDelta"` + Codec *string `json:"codec"` + CodecProfile *string `json:"codecProfile"` + CodecParameters *string `json:"codecParameters"` + AudioBitrateKbps *Num `json:"audioBitrateKbps"` + AudioPacketLossPercent *Num `json:"audioPacketLossPercent"` + AudioJitterMs *Num `json:"audioJitterMs"` + AudioVideoPlayoutDeltaMs *Num `json:"audioVideoPlayoutDeltaMs"` + VideoJitterBufferDelayMs *Num `json:"videoJitterBufferDelayMs"` + AudioJitterBufferDelayMs *Num `json:"audioJitterBufferDelayMs"` + AudioConcealedSamplesPercent *Num `json:"audioConcealedSamplesPercent"` + AudioConcealmentEventsDelta *Int `json:"audioConcealmentEventsDelta"` + AudioCodec *string `json:"audioCodec"` +} + +var viewerQualityEvidenceMetricKeys = []string{ + "natTraversalPath", "width", "height", "framesPerSecond", "bitrateKbps", + "packetsReceivedDelta", "packetsLostDelta", "rttMs", "jitterMs", + "framesDecodedDelta", "framesDroppedDelta", "decodeMsPerFrame", + "freezeCountDelta", "freezeDurationMsDelta", "pauseCountDelta", + "pauseDurationMsDelta", "codec", "codecProfile", "codecParameters", + "audioBitrateKbps", "audioPacketLossPercent", "audioJitterMs", + "audioVideoPlayoutDeltaMs", "videoJitterBufferDelayMs", + "audioJitterBufferDelayMs", "audioConcealedSamplesPercent", + "audioConcealmentEventsDelta", "audioCodec", +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *ViewerQualityEvidenceMetrics) UnmarshalJSON(data []byte) error { + *v = ViewerQualityEvidenceMetrics{} + type raw ViewerQualityEvidenceMetrics + present, err := decodeObject(data, (*raw)(v)) + if err != nil { + return err + } + if err := present.requireNullable(viewerQualityEvidenceMetricKeys...); err != nil { + return err + } + if err := present.require("natTraversalPath"); err != nil { + return err + } + if !enumOf(v.NatTraversalPath, NatTraversalPaths[:]...) { + return fmt.Errorf("unknown natTraversalPath %q", v.NatTraversalPath) + } + for _, bound := range []struct { + value *Int + minimum, maximum int64 + }{ + {v.Width, 1, 16_384}, {v.Height, 1, 16_384}, + {v.PacketsReceivedDelta, 0, 1_000_000}, {v.PacketsLostDelta, 0, 1_000_000}, + {v.FramesDecodedDelta, 0, 10_000}, {v.FramesDroppedDelta, 0, 10_000}, + {v.FreezeCountDelta, 0, MaxSafeInteger}, {v.PauseCountDelta, 0, MaxSafeInteger}, + {v.AudioConcealmentEventsDelta, 0, 10_000}, + } { + if bound.value != nil && !inRangeInt(*bound.value, bound.minimum, bound.maximum) { + return errors.New("viewer quality evidence integer is out of range") + } + } + for _, bound := range []struct { + value *Num + minimum, maximum float64 + }{ + {v.FramesPerSecond, 0, 240}, {v.BitrateKbps, 0, 100_000}, + {v.RttMs, 0, 60_000}, {v.JitterMs, 0, 60_000}, + {v.DecodeMsPerFrame, 0, 60_000}, + {v.FreezeDurationMsDelta, 0, MaxSafeInteger}, + {v.PauseDurationMsDelta, 0, MaxSafeInteger}, + {v.AudioBitrateKbps, 0, 10_000}, {v.AudioPacketLossPercent, 0, 100}, + {v.AudioJitterMs, 0, 60_000}, + {v.AudioVideoPlayoutDeltaMs, -60_000, 60_000}, + {v.VideoJitterBufferDelayMs, 0, 60_000}, {v.AudioJitterBufferDelayMs, 0, 60_000}, + {v.AudioConcealedSamplesPercent, 0, 100}, + } { + if bound.value != nil && !inRangeNum(*bound.value, bound.minimum, bound.maximum) { + return errors.New("viewer quality evidence number is out of range") + } + } + if v.Codec != nil && (UTF16Length(*v.Codec) > 64 || !videoMimeTypePattern.MatchString(*v.Codec)) { + return errors.New("codec is not a video mime type") + } + if v.CodecProfile != nil && + (UTF16Length(*v.CodecProfile) > 64 || !codecProfilePattern.MatchString(*v.CodecProfile)) { + return errors.New("codecProfile is malformed") + } + if v.CodecParameters != nil && + (UTF16Length(*v.CodecParameters) > 128 || + !codecParametersPattern.MatchString(*v.CodecParameters)) { + return errors.New("codecParameters is malformed") + } + if v.AudioCodec != nil && + (UTF16Length(*v.AudioCodec) > 64 || !audioMimeTypePattern.MatchString(*v.AudioCodec)) { + return errors.New("audioCodec is not an audio mime type") + } + // refine: dimensions travel together. + if (v.Width == nil) != (v.Height == nil) { + return errors.New("Viewer quality dimensions must be present together") + } + // refine: codec evidence must be canonical. + if !IsCanonicalVideoCodecEvidence( + derefString(v.Codec), derefString(v.CodecProfile), derefString(v.CodecParameters)) { + return errors.New("Viewer codec evidence is not canonical") + } + return nil +} + +func derefString(value *string) string { + if value == nil { + return "" + } + return *value +} + +// SenderQualityDiagnostics mirrors senderQualityDiagnosticsSchema. +type SenderQualityDiagnostics struct { + NatTraversalPath string `json:"natTraversalPath"` + Reason *string `json:"reason"` + FramesPerSecond *Num `json:"framesPerSecond"` + BitrateKbps *Num `json:"bitrateKbps"` + CaptureFramesPerSecond Nullable[Num] `json:"captureFramesPerSecond,omitzero"` + MediaSourceFramesPerSecond Nullable[Num] `json:"mediaSourceFramesPerSecond,omitzero"` + Width Nullable[Int] `json:"width,omitzero"` + Height Nullable[Int] `json:"height,omitzero"` + VideoEncodingCount Nullable[Int] `json:"videoEncodingCount,omitzero"` + ActiveVideoEncodingCount Nullable[Int] `json:"activeVideoEncodingCount,omitzero"` + AvailableOutgoingKbps Nullable[Num] `json:"availableOutgoingKbps,omitzero"` + RttMs Nullable[Num] `json:"rttMs,omitzero"` + PacketLossPercent Nullable[Num] `json:"packetLossPercent,omitzero"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (v *SenderQualityDiagnostics) UnmarshalJSON(data []byte) error { + *v = SenderQualityDiagnostics{} + type raw SenderQualityDiagnostics + present, err := decodeObject(data, (*raw)(v)) + if err != nil { + return err + } + if err := present.require("natTraversalPath"); err != nil { + return err + } + if err := present.requireNullable( + "reason", "framesPerSecond", "bitrateKbps"); err != nil { + return err + } + if !enumOf(v.NatTraversalPath, NatTraversalPaths[:]...) { + return fmt.Errorf("unknown natTraversalPath %q", v.NatTraversalPath) + } + if v.Reason != nil && !enumOf(*v.Reason, senderQualityDegradedReasons...) { + return fmt.Errorf("unknown sender quality reason %q", *v.Reason) + } + for _, bound := range []struct { + value *Num + minimum, maximum float64 + }{ + {v.FramesPerSecond, 0, 240}, {v.BitrateKbps, 0, 100_000}, + {v.CaptureFramesPerSecond.Value, 0, 240}, + {v.MediaSourceFramesPerSecond.Value, 0, 240}, + {v.AvailableOutgoingKbps.Value, 0, 100_000}, + {v.RttMs.Value, 0, 60_000}, {v.PacketLossPercent.Value, 0, 100}, + } { + if bound.value != nil && !inRangeNum(*bound.value, bound.minimum, bound.maximum) { + return errors.New("sender quality diagnostic number is out of range") + } + } + for _, bound := range []struct { + value *Int + maximum int64 + }{ + {v.Width.Value, 16_384}, {v.Height.Value, 16_384}, + {v.VideoEncodingCount.Value, MaxSafeInteger}, + {v.ActiveVideoEncodingCount.Value, MaxSafeInteger}, + } { + if bound.value != nil && !inRangeInt(*bound.value, 0, bound.maximum) { + return errors.New("sender quality diagnostic integer is out of range") + } + } + return nil +} diff --git a/internal/server/room/compat_test.go b/internal/server/room/compat_test.go new file mode 100644 index 0000000..6275cd7 --- /dev/null +++ b/internal/server/room/compat_test.go @@ -0,0 +1,155 @@ +package room + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/TNTcraftHIM/Screener/internal/server/protocol" +) + +// testdata/node-rooms.db was written once by node:sqlite (DatabaseSync) with +// the schema of src/server/room-database.ts and two synthetic rooms: 4321 is +// active (NULL lease) with a 48-byte password material, 5678 is dormant with a +// lease of 1000 ms. No real token, grant or password is involved. The extension +// is .db because .gitignore excludes *.sqlite repository-wide. +func copyNodeFixture(t *testing.T) string { + t.Helper() + fixture, err := os.ReadFile(filepath.Join("testdata", "node-rooms.db")) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + path := filepath.Join(t.TempDir(), "rooms.sqlite") + if err := os.WriteFile(path, fixture, 0o600); err != nil { + t.Fatalf("write fixture copy: %v", err) + } + return path +} + +func openFixture(t *testing.T, startupNowMs, activeLeaseExpiresAtMs int64) []StoredRoomAuthority { + t.Helper() + database, err := NewDatabase(copyNodeFixture(t)) + if err != nil { + t.Fatalf("NewDatabase: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + restored, err := database.Initialize(startupNowMs, activeLeaseExpiresAtMs) + if err != nil { + t.Fatalf("Initialize: %v", err) + } + return restored +} + +func TestRecoversANodeWrittenDatabase(t *testing.T) { + restored := openFixture(t, 5_000, 6_000) + if len(restored) != 1 { + t.Fatalf("restored %d rooms, want 1 (the dormant room was due at 1000)", len(restored)) + } + room := restored[0] + if room.RoomID != "4321" || room.CodeEntryPolicy != protocol.CodeEntryPrivate { + t.Fatalf("restored %+v", room) + } + if room.LeaseExpiresAtMs == nil || *room.LeaseExpiresAtMs != 6_000 { + t.Fatalf("active room lease = %v, want the startup lease 6000", room.LeaseExpiresAtMs) + } + if !bytes.Equal(room.HostTokenDigest, bytes.Repeat([]byte{0x11}, 32)) || + !bytes.Equal(room.ViewerGrantDigest, bytes.Repeat([]byte{0x22}, 32)) { + t.Fatal("digests did not survive the Node round trip") + } + if room.ViewerAuthorizationGeneration != "MzMzMzMzMzMzMzMzMzMzMw" { + t.Fatalf("generation = %q", room.ViewerAuthorizationGeneration) + } + if !bytes.Equal(room.ViewerPasswordMaterial, bytes.Repeat([]byte{0x44}, 48)) { + t.Fatalf("password material = %d bytes", len(room.ViewerPasswordMaterial)) + } +} + +func TestKeepsADormantNodeWrittenDeadline(t *testing.T) { + restored := openFixture(t, 500, 1_500) + if len(restored) != 2 { + t.Fatalf("restored %d rooms, want 2", len(restored)) + } + // readStoredRooms orders by room_id. + if restored[0].RoomID != "4321" || restored[0].LeaseExpiresAtMs == nil || + *restored[0].LeaseExpiresAtMs != 1_500 { + t.Fatalf("active room = %+v", restored[0]) + } + if restored[1].RoomID != "5678" || restored[1].LeaseExpiresAtMs == nil || + *restored[1].LeaseExpiresAtMs != 1_000 { + t.Fatalf("dormant room = %+v", restored[1]) + } + if restored[1].ViewerPasswordMaterial != nil { + t.Fatalf("dormant room material = %v, want nil", restored[1].ViewerPasswordMaterial) + } +} + +func TestStoreRestoresANodeWrittenDatabase(t *testing.T) { + testClock := &clock{nowMs: 5_000} + store := stableStore(t, copyNodeFixture(t), testClock, Options{LeaseMs: 1_000}) + if store.Size() != 1 { + t.Fatalf("size = %d, want 1", store.Size()) + } + // The recovered room holds the startup lease: 5000 + 1000. + testClock.nowMs = 6_001 + expired, err := store.ExpireRooms(testClock.nowMs) + if err != nil { + t.Fatalf("ExpireRooms: %v", err) + } + if len(expired) != 1 || expired[0].RoomID != "4321" { + t.Fatalf("expired = %+v", expired) + } +} + +// TestSchemaTextMatchesTheNodeFixture proves the CREATE TABLE text this package +// executes is byte for byte the one src/server/room-database.ts executed. +func TestSchemaTextMatchesTheNodeFixture(t *testing.T) { + path := databasePath(t) + database, err := NewDatabase(path) + if err != nil { + t.Fatalf("NewDatabase: %v", err) + } + if _, err := database.Initialize(0, 1_000); err != nil { + t.Fatalf("Initialize: %v", err) + } + if err := database.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + created := readSchemaText(t, path) + fixture := readSchemaText(t, copyNodeFixture(t)) + if created.sql != fixture.sql { + t.Fatalf("schema text differs\n go: %q\n node: %q", created.sql, fixture.sql) + } + if created.applicationID != fixture.applicationID || + created.applicationID != roomDatabaseApplicationID { + t.Fatalf("application_id go=%d node=%d", created.applicationID, fixture.applicationID) + } + if created.userVersion != fixture.userVersion || + created.userVersion != roomDatabaseSchemaVersion { + t.Fatalf("user_version go=%d node=%d", created.userVersion, fixture.userVersion) + } +} + +type schemaText struct { + sql string + applicationID int64 + userVersion int64 +} + +func readSchemaText(t *testing.T, path string) schemaText { + t.Helper() + raw := rawDatabase(t, path) + var text schemaText + if err := raw.QueryRow( + "SELECT sql FROM sqlite_schema WHERE name = 'rooms'").Scan(&text.sql); err != nil { + t.Fatalf("read schema: %v", err) + } + if err := raw.QueryRow("PRAGMA application_id").Scan(&text.applicationID); err != nil { + t.Fatalf("read application_id: %v", err) + } + if err := raw.QueryRow("PRAGMA user_version").Scan(&text.userVersion); err != nil { + t.Fatalf("read user_version: %v", err) + } + return text +} diff --git a/internal/server/room/database.go b/internal/server/room/database.go new file mode 100644 index 0000000..c411410 --- /dev/null +++ b/internal/server/room/database.go @@ -0,0 +1,786 @@ +package room + +import ( + "context" + "database/sql" + "encoding/base64" + "errors" + "fmt" + "path/filepath" + "strings" + + // The pure-Go driver: the port must build with CGO_ENABLED=0 (D2). + _ "modernc.org/sqlite" + + "github.com/TNTcraftHIM/Screener/internal/server/protocol" +) + +// roomDatabaseApplicationID and roomDatabaseSchemaVersion are the +// APPLICATION_ID / SCHEMA_VERSION of src/server/room-database.ts, checked on +// open so another SQLite file cannot be adopted as the room authority. The ID +// is ASCII "SCRN"; roomSchema below repeats it in decimal because that literal +// has to match the TypeScript statement text byte for byte. +const ( + roomDatabaseApplicationID = 0x5343524e + roomDatabaseSchemaVersion = 1 +) + +// roomSchema is the exact text src/server/room-database.ts executes, so a file +// created here and one created by the TypeScript hold the same sqlite_schema. +const roomSchema = ` + CREATE TABLE rooms ( + room_id TEXT PRIMARY KEY NOT NULL + CHECK (room_id GLOB '[1-9][0-9][0-9][0-9]'), + host_token_digest BLOB NOT NULL + CHECK (typeof(host_token_digest) = 'blob' AND length(host_token_digest) = 32), + viewer_grant_digest BLOB NOT NULL + CHECK (typeof(viewer_grant_digest) = 'blob' AND length(viewer_grant_digest) = 32), + viewer_authorization_generation TEXT NOT NULL + CHECK ( + length(viewer_authorization_generation) = 22 AND + viewer_authorization_generation NOT GLOB '*[^A-Za-z0-9_-]*' + ), + code_entry_policy TEXT NOT NULL + CHECK (code_entry_policy IN ('open', 'private')), + viewer_password_material BLOB NULL + CHECK ( + viewer_password_material IS NULL OR + (typeof(viewer_password_material) = 'blob' AND length(viewer_password_material) = 48) + ), + lease_expires_at_ms INTEGER NULL + CHECK ( + lease_expires_at_ms IS NULL OR + (lease_expires_at_ms > 0 AND lease_expires_at_ms <= 9007199254740991) + ) + ) STRICT; + PRAGMA application_id = 1396920910; + PRAGMA user_version = 1; + ` + +const selectStoredRooms = `SELECT + room_id, + host_token_digest, + viewer_grant_digest, + viewer_authorization_generation, + code_entry_policy, + viewer_password_material, + lease_expires_at_ms + FROM rooms + ORDER BY room_id` + +const insertStoredRoom = `INSERT INTO rooms ( + room_id, + host_token_digest, + viewer_grant_digest, + viewer_authorization_generation, + code_entry_policy, + viewer_password_material, + lease_expires_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?)` + +const deleteRoomStatement = "DELETE FROM rooms WHERE room_id = ? AND host_token_digest = ?" + +// StoredRoomAuthority is StoredRoomAuthority. A nil ViewerPasswordMaterial and +// a nil LeaseExpiresAtMs are the TypeScript nulls. +type StoredRoomAuthority struct { + RoomID string + HostTokenDigest []byte + ViewerGrantDigest []byte + ViewerAuthorizationGeneration string + CodeEntryPolicy protocol.CodeEntryPolicy + ViewerPasswordMaterial []byte + LeaseExpiresAtMs *int64 +} + +// RoomIdentity is the {roomId, hostTokenDigest} pair deleteRooms takes. +type RoomIdentity struct { + RoomID string + HostTokenDigest []byte +} + +// Database is RoomDatabase: the SQLite stable authority. It holds one +// connection for its lifetime, because `PRAGMA locking_mode = EXCLUSIVE` is a +// connection property that a recycled pool connection would silently drop (D2). +type Database struct { + path string + db *sql.DB + conn *sql.Conn + // inTransaction reproduces DatabaseSync.isTransaction, which the driver + // does not expose; the ROLLBACK guard depends on it. + inTransaction bool +} + +// NewDatabase is the RoomDatabase constructor. +func NewDatabase(path string) (*Database, error) { + if path == "" || path == ":memory:" { + return nil, errors.New("Room database path must identify a file") + } + return &Database{path: path}, nil +} + +// Initialize opens the file, takes the exclusive lock, creates or verifies the +// schema and recovers the stored rooms: rooms whose lease has passed are +// deleted and rooms that were active at shutdown take activeLeaseExpiresAtMs. +func (d *Database) Initialize( + startupNowMs, activeLeaseExpiresAtMs int64, +) (restored []StoredRoomAuthority, err error) { + if d.conn != nil { + return nil, errors.New("Room database is already initialized") + } + if err := assertTimestamp(startupNowMs, "Room database startup time", true); err != nil { + return nil, err + } + if err := assertTimestamp( + activeLeaseExpiresAtMs, "Room database active-room lease deadline", false); err != nil { + return nil, err + } + if activeLeaseExpiresAtMs <= startupNowMs { + return nil, errors.New( + "Room database active-room lease deadline must be in the future") + } + + // `new DatabaseSync(this.path, { timeout: 0 })`: a lock conflict must fail + // immediately instead of waiting. + db, err := sql.Open("sqlite", "file:"+uriPath(d.path)+"?_pragma=busy_timeout(0)") + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + db.SetConnMaxLifetime(0) + db.SetConnMaxIdleTime(0) + conn, err := db.Conn(context.Background()) + if err != nil { + _ = db.Close() + return nil, err + } + d.db, d.conn = db, conn + defer func() { + if err != nil { + d.closeHandles() + } + }() + + // The pragma answers "exclusive" even when another process owns the file; + // the BEGIN EXCLUSIVE below is what actually takes the lock. + var lockingMode string + if err = d.conn.QueryRowContext( + context.Background(), "PRAGMA main.locking_mode = EXCLUSIVE").Scan(&lockingMode); err != nil { + return nil, err + } + if lockingMode != "exclusive" { + return nil, errors.New("Room database could not acquire exclusive locking mode") + } + if err = d.begin("BEGIN EXCLUSIVE"); err != nil { + return nil, err + } + restored, err = d.recover(startupNowMs, activeLeaseExpiresAtMs) + if err != nil { + d.rollbackIfNeeded() + return nil, err + } + if err = d.finish("COMMIT"); err != nil { + d.rollbackIfNeeded() + return nil, err + } + return restored, nil +} + +// uriPath is the literal path `new DatabaseSync(this.path)` opened, written for +// the driver's URI DSN: the driver splits on the first `?` and SQLite decodes +// `%HH` and stops at `#`, so those three characters are escaped. +func uriPath(path string) string { + return strings.NewReplacer("%", "%25", "#", "%23", "?", "%3F").Replace(filepath.ToSlash(path)) +} + +func (d *Database) recover( + startupNowMs, activeLeaseExpiresAtMs int64, +) ([]StoredRoomAuthority, error) { + if err := d.ensureExactSchema(); err != nil { + return nil, err + } + if err := d.assertDatabaseIntegrity(); err != nil { + return nil, err + } + stored, err := d.readStoredRooms() + if err != nil { + return nil, err + } + expiredCount, activeCount := 0, 0 + for _, room := range stored { + switch { + case room.LeaseExpiresAtMs == nil: + activeCount++ + case *room.LeaseExpiresAtMs <= startupNowMs: + expiredCount++ + } + } + + deleted, err := d.exec( + `DELETE FROM rooms + WHERE lease_expires_at_ms IS NOT NULL + AND lease_expires_at_ms <= ?`, startupNowMs) + if err != nil { + return nil, err + } + if deleted != int64(expiredCount) { + return nil, errors.New("Room database expiry cleanup changed an unexpected row count") + } + + converted, err := d.exec( + `UPDATE rooms + SET lease_expires_at_ms = ? + WHERE lease_expires_at_ms IS NULL`, activeLeaseExpiresAtMs) + if err != nil { + return nil, err + } + if converted != int64(activeCount) { + return nil, errors.New("Room database active-room recovery changed an unexpected row count") + } + + restored := make([]StoredRoomAuthority, 0, len(stored)) + for _, room := range stored { + if room.LeaseExpiresAtMs != nil && *room.LeaseExpiresAtMs <= startupNowMs { + continue + } + if room.LeaseExpiresAtMs == nil { + lease := activeLeaseExpiresAtMs + room.LeaseExpiresAtMs = &lease + } + restored = append(restored, room) + } + return restored, nil +} + +// InsertRoom is insertRoom. +func (d *Database) InsertRoom(room StoredRoomAuthority) error { + if err := assertStoredRoom(room); err != nil { + return err + } + return d.transaction(func() error { + changes, err := d.exec(insertStoredRoom, + room.RoomID, + room.HostTokenDigest, + room.ViewerGrantDigest, + room.ViewerAuthorizationGeneration, + room.CodeEntryPolicy, + room.ViewerPasswordMaterial, + room.LeaseExpiresAtMs) + if err != nil { + return err + } + return assertSingleChange(changes, "insert") + }) +} + +// SetLeaseDeadline is setLeaseDeadline; a nil deadline marks the room active. +func (d *Database) SetLeaseDeadline( + roomID string, hostTokenDigest []byte, leaseExpiresAtMs *int64, +) error { + if err := assertRoomIdentity(roomID, hostTokenDigest); err != nil { + return err + } + if leaseExpiresAtMs != nil { + if err := assertTimestamp(*leaseExpiresAtMs, "Room lease deadline", false); err != nil { + return err + } + } + return d.transaction(func() error { + changes, err := d.exec( + `UPDATE rooms + SET lease_expires_at_ms = ? + WHERE room_id = ? AND host_token_digest = ?`, + leaseExpiresAtMs, roomID, hostTokenDigest) + if err != nil { + return err + } + return assertSingleChange(changes, "lease update") + }) +} + +// SetViewerPassword is setViewerPassword; nil material clears the password. +func (d *Database) SetViewerPassword( + roomID string, hostTokenDigest, viewerPasswordMaterial []byte, +) error { + if err := assertRoomIdentity(roomID, hostTokenDigest); err != nil { + return err + } + if err := assertPasswordMaterial(viewerPasswordMaterial); err != nil { + return err + } + return d.transaction(func() error { + changes, err := d.exec( + `UPDATE rooms + SET viewer_password_material = ? + WHERE room_id = ? AND host_token_digest = ?`, + viewerPasswordMaterial, roomID, hostTokenDigest) + if err != nil { + return err + } + return assertSingleChange(changes, "password update") + }) +} + +// SetCodeEntryPolicy is setCodeEntryPolicy. +func (d *Database) SetCodeEntryPolicy( + roomID string, hostTokenDigest []byte, codeEntryPolicy protocol.CodeEntryPolicy, +) error { + if err := assertRoomIdentity(roomID, hostTokenDigest); err != nil { + return err + } + if !validCodeEntryPolicy(codeEntryPolicy) { + return errors.New("Room code-entry policy is invalid") + } + return d.transaction(func() error { + changes, err := d.exec( + `UPDATE rooms + SET code_entry_policy = ? + WHERE room_id = ? AND host_token_digest = ?`, + codeEntryPolicy, roomID, hostTokenDigest) + if err != nil { + return err + } + return assertSingleChange(changes, "code-entry policy update") + }) +} + +// SetViewerGrant is setViewerGrant. +func (d *Database) SetViewerGrant( + roomID string, hostTokenDigest, viewerGrantDigest []byte, + viewerAuthorizationGeneration string, +) error { + if err := assertRoomIdentity(roomID, hostTokenDigest); err != nil { + return err + } + if err := assertDigest(viewerGrantDigest, "Viewer grant"); err != nil { + return err + } + if err := assertAuthorizationGeneration(viewerAuthorizationGeneration); err != nil { + return err + } + return d.transaction(func() error { + changes, err := d.exec( + `UPDATE rooms + SET viewer_grant_digest = ?, + viewer_authorization_generation = ? + WHERE room_id = ? AND host_token_digest = ?`, + viewerGrantDigest, viewerAuthorizationGeneration, roomID, hostTokenDigest) + if err != nil { + return err + } + return assertSingleChange(changes, "Viewer grant update") + }) +} + +// DeleteRoom is deleteRoom. +func (d *Database) DeleteRoom(roomID string, hostTokenDigest []byte) error { + return d.DeleteRooms([]RoomIdentity{{RoomID: roomID, HostTokenDigest: hostTokenDigest}}) +} + +// DeleteRooms is deleteRooms: one transaction, and any room whose authority no +// longer matches rolls the whole batch back. +func (d *Database) DeleteRooms(rooms []RoomIdentity) error { + for _, room := range rooms { + if err := assertRoomIdentity(room.RoomID, room.HostTokenDigest); err != nil { + return err + } + } + if len(rooms) == 0 { + return nil + } + return d.transaction(func() error { + for _, room := range rooms { + changes, err := d.exec(deleteRoomStatement, room.RoomID, room.HostTokenDigest) + if err != nil { + return err + } + if err := assertSingleChange(changes, "delete"); err != nil { + return err + } + } + return nil + }) +} + +// ReplaceRoom is replaceRoom: the new room and the removal of the old one are +// one authority transition. +func (d *Database) ReplaceRoom( + oldRoomID string, oldHostTokenDigest []byte, replacement StoredRoomAuthority, +) error { + if err := assertRoomIdentity(oldRoomID, oldHostTokenDigest); err != nil { + return err + } + if err := assertStoredRoom(replacement); err != nil { + return err + } + if replacement.RoomID == oldRoomID { + return errors.New("Replacement room ID must be different") + } + return d.transaction(func() error { + inserted, err := d.exec(insertStoredRoom, + replacement.RoomID, + replacement.HostTokenDigest, + replacement.ViewerGrantDigest, + replacement.ViewerAuthorizationGeneration, + replacement.CodeEntryPolicy, + replacement.ViewerPasswordMaterial, + replacement.LeaseExpiresAtMs) + if err != nil { + return err + } + if err := assertSingleChange(inserted, "replacement insert"); err != nil { + return err + } + deleted, err := d.exec(deleteRoomStatement, oldRoomID, oldHostTokenDigest) + if err != nil { + return err + } + return assertSingleChange(deleted, "replacement delete") + }) +} + +// Close closes the connection; it is safe on a database that never opened one. +func (d *Database) Close() error { + return d.closeHandles() +} + +func (d *Database) closeHandles() error { + conn, db := d.conn, d.db + d.conn, d.db, d.inTransaction = nil, nil, false + var connError error + if conn != nil { + connError = conn.Close() + } + if db != nil { + if err := db.Close(); err != nil { + return err + } + } + return connError +} + +func (d *Database) ensureExactSchema() error { + applicationID, err := d.readPragmaInteger("application_id") + if err != nil { + return err + } + schemaVersion, err := d.readPragmaInteger("user_version") + if err != nil { + return err + } + schemaObjects, err := d.readSchemaObjects() + if err != nil { + return err + } + + if applicationID == 0 && schemaVersion == 0 && len(schemaObjects) == 0 { + _, err := d.conn.ExecContext(context.Background(), roomSchema) + return err + } + + if applicationID != roomDatabaseApplicationID { + return errors.New("Room database application identity does not match") + } + if schemaVersion != roomDatabaseSchemaVersion { + return errors.New("Room database schema version does not match") + } + if len(schemaObjects) != 1 || + schemaObjects[0][0] != "table" || schemaObjects[0][1] != "rooms" { + return errors.New("Room database schema objects do not match") + } + return d.assertExactColumns() +} + +func (d *Database) readSchemaObjects() ([][2]string, error) { + rows, err := d.conn.QueryContext(context.Background(), + `SELECT type, name + FROM sqlite_schema + WHERE name NOT LIKE 'sqlite_%' + ORDER BY type, name`) + if err != nil { + return nil, err + } + defer rows.Close() + var objects [][2]string + for rows.Next() { + var object [2]string + if err := rows.Scan(&object[0], &object[1]); err != nil { + return nil, err + } + objects = append(objects, object) + } + return objects, rows.Err() +} + +func (d *Database) assertDatabaseIntegrity() error { + rows, err := d.conn.QueryContext(context.Background(), "PRAGMA quick_check") + if err != nil { + return err + } + defer rows.Close() + var results []string + for rows.Next() { + var result string + if err := rows.Scan(&result); err != nil { + return err + } + results = append(results, result) + } + if err := rows.Err(); err != nil { + return err + } + if len(results) != 1 || results[0] != "ok" { + return errors.New("Room database integrity check failed") + } + return nil +} + +func (d *Database) assertExactColumns() error { + expected := [...]struct { + name string + columnType string + notNull int + primaryKey int + }{ + {"room_id", "TEXT", 1, 1}, + {"host_token_digest", "BLOB", 1, 0}, + {"viewer_grant_digest", "BLOB", 1, 0}, + {"viewer_authorization_generation", "TEXT", 1, 0}, + {"code_entry_policy", "TEXT", 1, 0}, + {"viewer_password_material", "BLOB", 0, 0}, + {"lease_expires_at_ms", "INTEGER", 0, 0}, + } + rows, err := d.conn.QueryContext(context.Background(), "PRAGMA table_info(rooms)") + if err != nil { + return err + } + defer rows.Close() + index := 0 + for rows.Next() { + var ( + cid int + name string + columnType string + notNull int + defaultValue sql.NullString + primaryKey int + ) + if err := rows.Scan( + &cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil { + return err + } + if index >= len(expected) || name != expected[index].name || + columnType != expected[index].columnType || + notNull != expected[index].notNull || + primaryKey != expected[index].primaryKey { + return errors.New("Room database columns do not match the current schema") + } + index++ + } + if err := rows.Err(); err != nil { + return err + } + if index != len(expected) { + return errors.New("Room database columns do not match the current schema") + } + return nil +} + +func (d *Database) readStoredRooms() ([]StoredRoomAuthority, error) { + rows, err := d.conn.QueryContext(context.Background(), selectStoredRooms) + if err != nil { + return nil, err + } + defer rows.Close() + var stored []StoredRoomAuthority + for rows.Next() { + var ( + room StoredRoomAuthority + policy string + lease sql.NullInt64 + material []byte + ) + if err := rows.Scan( + &room.RoomID, + &room.HostTokenDigest, + &room.ViewerGrantDigest, + &room.ViewerAuthorizationGeneration, + &policy, + &material, + &lease); err != nil { + return nil, err + } + if err := readDigest(room.HostTokenDigest, "Host token"); err != nil { + return nil, err + } + if err := readDigest(room.ViewerGrantDigest, "Viewer grant"); err != nil { + return nil, err + } + if !validCodeEntryPolicy(policy) { + return nil, errors.New("Room database contains an invalid code-entry policy") + } + room.CodeEntryPolicy = policy + if material != nil { + if len(material) != viewerPasswordMaterialBytes { + return nil, errors.New("Room database contains invalid Viewer password material") + } + room.ViewerPasswordMaterial = material + } + if lease.Valid { + if lease.Int64 <= 0 || lease.Int64 > protocol.MaxSafeInteger { + return nil, errors.New("Room database contains an invalid lease deadline") + } + room.LeaseExpiresAtMs = &lease.Int64 + } + if err := assertStoredRoom(room); err != nil { + return nil, err + } + stored = append(stored, room) + } + return stored, rows.Err() +} + +func (d *Database) readPragmaInteger(name string) (int64, error) { + var value int64 + if err := d.conn.QueryRowContext( + context.Background(), "PRAGMA "+name).Scan(&value); err != nil { + return 0, fmt.Errorf("Room database %s is invalid", name) + } + if value < -protocol.MaxSafeInteger || value > protocol.MaxSafeInteger { + return 0, fmt.Errorf("Room database %s is invalid", name) + } + return value, nil +} + +func (d *Database) transaction(operation func() error) error { + if d.conn == nil { + return errors.New("Room database is not initialized") + } + if err := d.begin("BEGIN IMMEDIATE"); err != nil { + return err + } + if err := operation(); err != nil { + d.rollbackIfNeeded() + return err + } + if err := d.finish("COMMIT"); err != nil { + d.rollbackIfNeeded() + return err + } + return nil +} + +func (d *Database) begin(statement string) error { + if _, err := d.conn.ExecContext(context.Background(), statement); err != nil { + return err + } + d.inTransaction = true + return nil +} + +func (d *Database) finish(statement string) error { + if _, err := d.conn.ExecContext(context.Background(), statement); err != nil { + return err + } + d.inTransaction = false + return nil +} + +// rollbackIfNeeded is `if (database.isTransaction) database.exec("ROLLBACK")`: +// a ROLLBACK with no active transaction is itself an error. +func (d *Database) rollbackIfNeeded() { + if d.inTransaction { + _ = d.finish("ROLLBACK") + } +} + +func (d *Database) exec(query string, args ...any) (int64, error) { + result, err := d.conn.ExecContext(context.Background(), query, args...) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +func assertStoredRoom(room StoredRoomAuthority) error { + if !protocol.ValidRoomCode(room.RoomID) { + return errors.New("Room database contains an invalid room ID") + } + if err := assertDigest(room.HostTokenDigest, "Host token"); err != nil { + return err + } + if err := assertDigest(room.ViewerGrantDigest, "Viewer grant"); err != nil { + return err + } + if err := assertAuthorizationGeneration(room.ViewerAuthorizationGeneration); err != nil { + return err + } + if !validCodeEntryPolicy(room.CodeEntryPolicy) { + return errors.New("Room database contains an invalid code-entry policy") + } + if err := assertPasswordMaterial(room.ViewerPasswordMaterial); err != nil { + return err + } + if room.LeaseExpiresAtMs != nil { + return assertTimestamp(*room.LeaseExpiresAtMs, "Room database lease deadline", false) + } + return nil +} + +func assertRoomIdentity(roomID string, hostTokenDigest []byte) error { + if !protocol.ValidRoomCode(roomID) { + return errors.New("Room ID is invalid") + } + return assertDigest(hostTokenDigest, "Host token") +} + +func readDigest(value []byte, name string) error { + if len(value) != 32 { + return fmt.Errorf("Room database contains an invalid %s digest", name) + } + return nil +} + +func assertDigest(value []byte, name string) error { + if len(value) != 32 { + return fmt.Errorf("%s digest must contain 32 bytes", name) + } + return nil +} + +func assertPasswordMaterial(value []byte) error { + if value != nil && len(value) != viewerPasswordMaterialBytes { + return errors.New("Viewer password material must contain 48 bytes") + } + return nil +} + +func assertAuthorizationGeneration(value string) error { + decoded, err := base64.RawURLEncoding.DecodeString(value) + if err != nil || len(decoded) != 16 || + base64.RawURLEncoding.EncodeToString(decoded) != value { + return errors.New("Viewer authorization generation is invalid") + } + return nil +} + +// assertTimestamp is the TypeScript Number.isSafeInteger guard: SQLite STRICT +// accepts an integral REAL and a numeric TEXT in an INTEGER column, so the +// range check has to stay on this side. +func assertTimestamp(value int64, name string, allowZero bool) error { + if value > protocol.MaxSafeInteger || (allowZero && value < 0) || (!allowZero && value <= 0) { + return fmt.Errorf("%s must be a safe positive integer", name) + } + return nil +} + +func assertSingleChange(changes int64, operation string) error { + if changes != 1 { + return fmt.Errorf("Room database %s did not match current authority", operation) + } + return nil +} + +func validCodeEntryPolicy(policy protocol.CodeEntryPolicy) bool { + return policy == protocol.CodeEntryOpen || policy == protocol.CodeEntryPrivate +} diff --git a/internal/server/room/database_test.go b/internal/server/room/database_test.go new file mode 100644 index 0000000..1f864ba --- /dev/null +++ b/internal/server/room/database_test.go @@ -0,0 +1,684 @@ +package room + +import ( + "bytes" + "crypto/sha256" + "database/sql" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/TNTcraftHIM/Screener/internal/server/protocol" +) + +// The scenarios below are ported from tests/room-database.test.ts. + +func databasePath(t *testing.T) string { + t.Helper() + // t.TempDir registers its removal before any store cleanup below, so the + // database is always closed first; Windows will not remove an open file. + return filepath.Join(t.TempDir(), "rooms.sqlite") +} + +func stableStore(t *testing.T, path string, testClock *clock, options Options) *Store { + t.Helper() + database, err := NewDatabase(path) + if err != nil { + t.Fatalf("NewDatabase: %v", err) + } + if options.LeaseMs == 0 { + options.LeaseMs = 1_000 + } + if options.MaxRooms == 0 { + options.MaxRooms = 8 + } + options.MaxViewersPerRoom = 8 + options.Database = database + options.Now = func() int64 { return testClock.nowMs } + store, err := New(options) + if err != nil { + t.Fatalf("New: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + if err := store.Initialize(); err != nil { + t.Fatalf("Initialize: %v", err) + } + return store +} + +func rawDatabase(t *testing.T, path string) *sql.DB { + t.Helper() + database, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)) + if err != nil { + t.Fatalf("sql.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + return database +} + +func rawExec(t *testing.T, path, statement string) { + t.Helper() + if _, err := rawDatabase(t, path).Exec(statement); err != nil { + t.Fatalf("raw exec %q: %v", statement, err) + } +} + +func dbViewerInput(roomID, sessionID, viewerGrant string) ConnectParticipantInput { + input := viewerInput(roomID, sessionID, viewerGrant) + input.ClientID = "client-" + sessionID + return input +} + +func TestDoesNotPersistARoomWhenPasswordDerivationIsBusy(t *testing.T) { + path := databasePath(t) + testClock := &clock{nowMs: 1_000} + first := stableStore(t, path, testClock, Options{MaxRooms: 1}) + + withSaturatedGate(t, first, func() { + _, err := tryCreateRoom(first, protocol.CodeEntryPrivate, "room-password", "4321") + expectCode(t, err, CodeRoomBusy) + }) + if first.Size() != 0 { + t.Fatalf("size = %d, want 0", first.Size()) + } + if err := first.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + second := stableStore(t, path, testClock, Options{MaxRooms: 1}) + if second.Size() != 0 { + t.Fatalf("restored size = %d, want 0", second.Size()) + } + if created := createRoom(t, second, protocol.CodeEntryPrivate, "", "4321"); created.RoomID != "4321" { + t.Fatalf("room = %q, want 4321", created.RoomID) + } +} + +func TestRestoresTheExactAuthorityAggregateWithoutParticipants(t *testing.T) { + path := databasePath(t) + testClock := &clock{nowMs: 1_000} + first := stableStore(t, path, testClock, Options{}) + room := createRoom(t, first, protocol.CodeEntryOpen, "", "4321") + if _, err := first.SetCodeEntryPolicy( + room.RoomID, protocol.CodeEntryPrivate, room.HostToken); err != nil { + t.Fatalf("SetCodeEntryPolicy: %v", err) + } + if _, err := trySetViewerPassword( + first, room.RoomID, "room-password", room.HostToken); err != nil { + t.Fatalf("SetViewerPassword: %v", err) + } + rotated, err := first.SetViewerGrant(room.RoomID, "rotate", room.HostToken) + if err != nil { + t.Fatalf("SetViewerGrant: %v", err) + } + host := mustConnect(t, first, hostInput(room.RoomID, room.HostToken)) + oldViewer := mustConnect(t, first, dbViewerInput(room.RoomID, "old-viewer", rotated.ViewerGrant)) + if connected, ok := first.GetConnectedHost(room.RoomID); !ok || connected.PeerID != host.PeerID { + t.Fatalf("host = %+v %v", connected, ok) + } + if _, ok := first.GetConnectedViewer(room.RoomID, oldViewer.PeerID); !ok { + t.Fatal("viewer is not connected") + } + if err := first.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + testClock.nowMs = 2_000 + second := stableStore(t, path, testClock, Options{}) + if second.Size() != 1 { + t.Fatalf("restored size = %d, want 1", second.Size()) + } + if _, ok := second.GetConnectedHost(room.RoomID); ok { + t.Fatal("a restored room reported a connected host") + } + if viewers := second.GetConnectedViewers(room.RoomID); len(viewers) != 0 { + t.Fatalf("restored viewers = %+v", viewers) + } + _, err = second.ConnectParticipant(dbViewerInput(room.RoomID, "revoked-grant", room.ViewerGrant)) + expectCode(t, err, CodeInvalidToken) + + granted := mustConnect(t, second, dbViewerInput(room.RoomID, "current-grant", rotated.ViewerGrant)) + if granted.ExpiresAt == nil || *granted.ExpiresAt != "1970-01-01T00:00:03.000Z" { + t.Fatalf("expiresAt = %v", granted.ExpiresAt) + } + if granted.ViewerAuthorizationGeneration != rotated.ViewerAuthorizationGeneration { + t.Fatalf("generation = %q, want %q", + granted.ViewerAuthorizationGeneration, rotated.ViewerAuthorizationGeneration) + } + connected, err := tryConnectViewerWithPassword(second, ConnectViewerWithPasswordInput{ + RoomID: room.RoomID, + Password: "room-password", + ClientID: "password-client", + SessionID: "password-session", + }, nil) + if err != nil || connected.CodeEntryPolicy != protocol.CodeEntryPrivate { + t.Fatalf("password login = %+v, %v", connected, err) + } + _, err = second.ConnectParticipant(hostInput(room.RoomID, "wrong-token")) + expectCode(t, err, CodeInvalidToken) + resumed := mustConnect(t, second, hostInput(room.RoomID, room.HostToken, "recovered-host")) + if resumed.ExpiresAt != nil { + t.Fatalf("resumed expiresAt = %v, want null", *resumed.ExpiresAt) + } + if err := second.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + raw := rawDatabase(t, path) + rows, err := raw.Query("SELECT * FROM rooms") + if err != nil { + t.Fatalf("raw query: %v", err) + } + defer rows.Close() + columns, err := rows.Columns() + if err != nil { + t.Fatalf("Columns: %v", err) + } + want := []string{ + "room_id", "host_token_digest", "viewer_grant_digest", + "viewer_authorization_generation", "code_entry_policy", + "viewer_password_material", "lease_expires_at_ms", + } + if strings.Join(columns, ",") != strings.Join(want, ",") { + t.Fatalf("columns = %v", columns) + } + if !rows.Next() { + t.Fatal("no stored room") + } + var ( + roomID string + hostDigest []byte + grantDigest []byte + generation string + policy string + material []byte + leaseExpiresAtMs sql.NullInt64 + ) + if err := rows.Scan(&roomID, &hostDigest, &grantDigest, &generation, &policy, + &material, &leaseExpiresAtMs); err != nil { + t.Fatalf("Scan: %v", err) + } + hostTokenDigest := sha256.Sum256([]byte(room.HostToken)) + if !bytes.Equal(hostDigest, hostTokenDigest[:]) { + t.Fatal("stored Host token digest does not match") + } + grantTokenDigest := sha256.Sum256([]byte(rotated.ViewerGrant)) + if !bytes.Equal(grantDigest, grantTokenDigest[:]) { + t.Fatal("stored Viewer grant digest does not match") + } + if len(material) != 48 { + t.Fatalf("password material = %d bytes, want 48", len(material)) + } + // Nothing reversible is stored: no token, grant or password appears. + stored := roomID + generation + policy + string(hostDigest) + string(grantDigest) + string(material) + for _, secret := range []string{room.HostToken, rotated.ViewerGrant, "room-password"} { + if strings.Contains(stored, secret) { + t.Fatal("a credential was stored verbatim") + } + } +} + +func TestConvertsAnActiveMarkerOnceAndPreservesDormantDeadlines(t *testing.T) { + path := databasePath(t) + testClock := &clock{nowMs: 100} + first := stableStore(t, path, testClock, Options{}) + room := createRoom(t, first, protocol.CodeEntryOpen, "", "5678") + mustConnect(t, first, hostInput(room.RoomID, room.HostToken)) + if err := first.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + testClock.nowMs = 500 + second := stableStore(t, path, testClock, Options{}) + firstRestart := mustConnect(t, second, dbViewerInput(room.RoomID, "first-restart", room.ViewerGrant)) + if firstRestart.ExpiresAt == nil || *firstRestart.ExpiresAt != "1970-01-01T00:00:01.500Z" { + t.Fatalf("expiresAt = %v", firstRestart.ExpiresAt) + } + if err := second.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + testClock.nowMs = 700 + third := stableStore(t, path, testClock, Options{}) + secondRestart := mustConnect(t, third, dbViewerInput(room.RoomID, "second-restart", room.ViewerGrant)) + if secondRestart.ExpiresAt == nil || *secondRestart.ExpiresAt != "1970-01-01T00:00:01.500Z" { + t.Fatalf("expiresAt = %v", secondRestart.ExpiresAt) + } + if err := third.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + testClock.nowMs = 1_501 + fourth := stableStore(t, path, testClock, Options{}) + if fourth.Size() != 0 { + t.Fatalf("size = %d, want 0", fourth.Size()) + } + replacement := createRoom(t, fourth, protocol.CodeEntryOpen, "", room.RoomID) + if replacement.RoomID != room.RoomID { + t.Fatalf("replacement room = %q", replacement.RoomID) + } + _, err := fourth.ConnectParticipant(hostInput(room.RoomID, room.HostToken)) + expectCode(t, err, CodeInvalidToken) + _, err = fourth.ConnectParticipant(dbViewerInput(room.RoomID, "stale-grant", room.ViewerGrant)) + expectCode(t, err, CodeInvalidToken) +} + +func TestPreservesTheExactHostDisconnectDeadline(t *testing.T) { + path := databasePath(t) + testClock := &clock{nowMs: 100} + first := stableStore(t, path, testClock, Options{}) + room := createRoom(t, first, protocol.CodeEntryOpen, "", "5890") + host := mustConnect(t, first, hostInput(room.RoomID, room.HostToken)) + testClock.nowMs = 250 + if _, err := first.DisconnectParticipant(room.RoomID, host.PeerID, "host-session"); err != nil { + t.Fatalf("DisconnectParticipant: %v", err) + } + if err := first.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + testClock.nowMs = 500 + second := stableStore(t, path, testClock, Options{}) + dormant := mustConnect(t, second, dbViewerInput(room.RoomID, "dormant-viewer", room.ViewerGrant)) + if dormant.ExpiresAt == nil || *dormant.ExpiresAt != "1970-01-01T00:00:01.250Z" { + t.Fatalf("expiresAt = %v", dormant.ExpiresAt) + } +} + +func TestPersistsExplicitRoomDeletionBeforeRecyclingItsCode(t *testing.T) { + path := databasePath(t) + testClock := &clock{nowMs: 10} + first := stableStore(t, path, testClock, Options{}) + room := createRoom(t, first, protocol.CodeEntryOpen, "", "6789") + closed, err := first.AbandonRoom(room.RoomID) + if err != nil || closed == nil || closed.RoomID != room.RoomID { + t.Fatalf("AbandonRoom = %v, %v", closed, err) + } + if err := first.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + second := stableStore(t, path, testClock, Options{}) + if second.Size() != 0 { + t.Fatalf("size = %d, want 0", second.Size()) + } + replacement := createRoom(t, second, protocol.CodeEntryOpen, "", room.RoomID) + if replacement.RoomID != room.RoomID { + t.Fatalf("replacement room = %q", replacement.RoomID) + } + if replacement.HostToken == room.HostToken || replacement.ViewerGrant == room.ViewerGrant { + t.Fatal("the recycled code kept the old credentials") + } +} + +func TestRollsBackABatchExpiryWhenAnyAuthorityIsStale(t *testing.T) { + path := databasePath(t) + testClock := &clock{nowMs: 10} + first := stableStore(t, path, testClock, Options{}) + createRoom(t, first, protocol.CodeEntryOpen, "", "4321") + createRoom(t, first, protocol.CodeEntryOpen, "", "5678") + if err := first.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + database, err := NewDatabase(path) + if err != nil { + t.Fatalf("NewDatabase: %v", err) + } + restored, err := database.Initialize(testClock.nowMs, 1_010) + if err != nil { + t.Fatalf("Initialize: %v", err) + } + if len(restored) != 2 { + t.Fatalf("restored %d rooms, want 2", len(restored)) + } + err = database.DeleteRooms([]RoomIdentity{ + {RoomID: restored[0].RoomID, HostTokenDigest: restored[0].HostTokenDigest}, + {RoomID: restored[1].RoomID, HostTokenDigest: make([]byte, 32)}, + }) + if err == nil || err.Error() != "Room database delete did not match current authority" { + t.Fatalf("DeleteRooms: %v", err) + } + if err := database.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + second := stableStore(t, path, testClock, Options{}) + if second.Size() != 2 { + t.Fatalf("size = %d, want 2", second.Size()) + } +} + +func TestPersistsRoomReplacementAsOneAuthorityTransition(t *testing.T) { + path := databasePath(t) + testClock := &clock{nowMs: 10} + first := stableStore(t, path, testClock, Options{}) + original := createRoom(t, first, protocol.CodeEntryOpen, "", "4321") + replacement, err := tryReplaceRoom( + first, original.RoomID, original.HostToken, protocol.CodeEntryPrivate, "new-password") + if err != nil { + t.Fatalf("ReplaceRoom: %v", err) + } + if err := first.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + second := stableStore(t, path, testClock, Options{}) + if second.Size() != 1 { + t.Fatalf("size = %d, want 1", second.Size()) + } + _, err = second.ConnectParticipant(hostInput(original.RoomID, original.HostToken)) + expectCode(t, err, CodeInvalidToken) + connected := mustConnect(t, second, dbViewerInput( + replacement.Created.RoomID, "replacement-viewer", replacement.Created.ViewerGrant)) + if connected.RoomID != replacement.Created.RoomID { + t.Fatalf("viewer joined %q", connected.RoomID) + } + withPassword, err := tryConnectViewerWithPassword(second, ConnectViewerWithPasswordInput{ + RoomID: replacement.Created.RoomID, + Password: "new-password", + ClientID: "password-viewer", + SessionID: "password-session", + }, nil) + if err != nil || withPassword.RoomID != replacement.Created.RoomID { + t.Fatalf("password login = %+v, %v", withPassword, err) + } +} + +// TestOpensThePathWithURIReservedCharactersLiterally: `new DatabaseSync(path)` +// opened the literal path, while the driver's DSN is a URI in which `%41` +// decodes to `A` and `#` ends the file name; the file must still be the +// configured one, and the busy-timeout pragma must survive the escaping. +func TestOpensThePathWithURIReservedCharactersLiterally(t *testing.T) { + testClock := &clock{nowMs: 10} + path := filepath.Join(t.TempDir(), "rooms%41#1.sqlite") + first := stableStore(t, path, testClock, Options{}) + createRoom(t, first, protocol.CodeEntryOpen, "", "4321") + if err := first.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("the configured file was not the one opened: %v", err) + } + if second := stableStore(t, path, testClock, Options{}); second.Size() != 1 { + t.Fatalf("restored size = %d, want 1", second.Size()) + } +} + +func TestRejectsAnotherOwnerOfTheSameDatabase(t *testing.T) { + path := databasePath(t) + first, err := NewDatabase(path) + if err != nil { + t.Fatalf("NewDatabase: %v", err) + } + if _, err := first.Initialize(0, 1_000); err != nil { + t.Fatalf("Initialize: %v", err) + } + contender, err := NewDatabase(path) + if err != nil { + t.Fatalf("NewDatabase: %v", err) + } + _, err = contender.Initialize(0, 1_000) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "locked") { + t.Fatalf("contender Initialize = %v, want a lock failure", err) + } + if err := contender.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if err := first.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + successor, err := NewDatabase(path) + if err != nil { + t.Fatalf("NewDatabase: %v", err) + } + restored, err := successor.Initialize(0, 1_000) + if err != nil || len(restored) != 0 { + t.Fatalf("successor Initialize = %v, %v", restored, err) + } + if err := successor.Close(); err != nil { + t.Fatalf("Close: %v", err) + } +} + +func TestRejectsAnUnknownDatabaseIdentity(t *testing.T) { + for _, testCase := range []struct{ name, mutation string }{ + {"application identity", "PRAGMA application_id = 1"}, + {"schema version", "PRAGMA user_version = 2"}, + } { + t.Run(testCase.name, func(t *testing.T) { + path := databasePath(t) + initial, err := NewDatabase(path) + if err != nil { + t.Fatalf("NewDatabase: %v", err) + } + if _, err := initial.Initialize(0, 1_000); err != nil { + t.Fatalf("Initialize: %v", err) + } + if err := initial.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + rawExec(t, path, testCase.mutation) + + reopened, err := NewDatabase(path) + if err != nil { + t.Fatalf("NewDatabase: %v", err) + } + _, err = reopened.Initialize(0, 1_000) + if err == nil || !strings.Contains(err.Error(), "does not match") { + t.Fatalf("Initialize = %v", err) + } + if err := reopened.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + }) + } +} + +func TestRejectsAnAlteredCurrentVersionSchema(t *testing.T) { + path := databasePath(t) + initial, err := NewDatabase(path) + if err != nil { + t.Fatalf("NewDatabase: %v", err) + } + if _, err := initial.Initialize(0, 1_000); err != nil { + t.Fatalf("Initialize: %v", err) + } + if err := initial.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + rawExec(t, path, "ALTER TABLE rooms ADD COLUMN unexpected TEXT") + + reopened, err := NewDatabase(path) + if err != nil { + t.Fatalf("NewDatabase: %v", err) + } + _, err = reopened.Initialize(0, 1_000) + if err == nil || err.Error() != "Room database columns do not match the current schema" { + t.Fatalf("Initialize = %v", err) + } + if err := reopened.Close(); err != nil { + t.Fatalf("Close: %v", err) + } +} + +func TestRejectsCorruptAuthorityRows(t *testing.T) { + path := databasePath(t) + testClock := &clock{nowMs: 100} + initial := stableStore(t, path, testClock, Options{}) + createRoom(t, initial, protocol.CodeEntryOpen, "", "7890") + if err := initial.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + raw := rawDatabase(t, path) + if _, err := raw.Exec("PRAGMA ignore_check_constraints = ON"); err != nil { + t.Fatalf("raw pragma: %v", err) + } + if _, err := raw.Exec("UPDATE rooms SET host_token_digest = zeroblob(31)"); err != nil { + t.Fatalf("raw update: %v", err) + } + if err := raw.Close(); err != nil { + t.Fatalf("raw close: %v", err) + } + + database, err := NewDatabase(path) + if err != nil { + t.Fatalf("NewDatabase: %v", err) + } + reopened, err := New(Options{ + LeaseMs: 1_000, + MaxRooms: 8, + MaxViewersPerRoom: 8, + Now: func() int64 { return testClock.nowMs }, + Database: database, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + // The oracle accepts either rejection: this SQLite build reports the broken + // row from PRAGMA quick_check before the digest length assertion runs. + err = reopened.Initialize() + if err == nil || (!strings.Contains(err.Error(), "Host token") && + !strings.Contains(err.Error(), "integrity")) { + t.Fatalf("Initialize = %v", err) + } + if err := reopened.Close(); err != nil { + t.Fatalf("Close: %v", err) + } +} + +// TestRejectsInvalidAuthorityArguments keeps the assertions the TypeScript ran +// before touching SQLite: STRICT would accept an integral REAL or a numeric +// TEXT timestamp, and a caller mistake must not reach the file at all. +func TestRejectsInvalidAuthorityArguments(t *testing.T) { + valid := StoredRoomAuthority{ + RoomID: "1234", + HostTokenDigest: make([]byte, 32), + ViewerGrantDigest: make([]byte, 32), + ViewerAuthorizationGeneration: "MzMzMzMzMzMzMzMzMzMzMw", + CodeEntryPolicy: protocol.CodeEntryOpen, + } + lease := int64(protocol.MaxSafeInteger + 1) + shortDigest := make([]byte, 31) + for _, testCase := range []struct { + name string + run func(*Database) error + want string + }{ + {"room ID", func(d *Database) error { + room := valid + room.RoomID = "0123" + return d.InsertRoom(room) + }, "Room database contains an invalid room ID"}, + {"host digest", func(d *Database) error { + room := valid + room.HostTokenDigest = shortDigest + return d.InsertRoom(room) + }, "Host token digest must contain 32 bytes"}, + {"grant digest", func(d *Database) error { + room := valid + room.ViewerGrantDigest = shortDigest + return d.InsertRoom(room) + }, "Viewer grant digest must contain 32 bytes"}, + {"generation", func(d *Database) error { + room := valid + room.ViewerAuthorizationGeneration = "MzMzMzMzMzMzMzMzMzMzMw==" + return d.InsertRoom(room) + }, "Viewer authorization generation is invalid"}, + {"policy", func(d *Database) error { + room := valid + room.CodeEntryPolicy = "public" + return d.InsertRoom(room) + }, "Room database contains an invalid code-entry policy"}, + {"password material", func(d *Database) error { + room := valid + room.ViewerPasswordMaterial = make([]byte, 47) + return d.InsertRoom(room) + }, "Viewer password material must contain 48 bytes"}, + {"lease deadline", func(d *Database) error { + room := valid + room.LeaseExpiresAtMs = &lease + return d.InsertRoom(room) + }, "Room database lease deadline must be a safe positive integer"}, + {"identity", func(d *Database) error { + return d.SetLeaseDeadline("123", make([]byte, 32), nil) + }, "Room ID is invalid"}, + {"update policy", func(d *Database) error { + return d.SetCodeEntryPolicy("1234", make([]byte, 32), "public") + }, "Room code-entry policy is invalid"}, + {"replacement identity", func(d *Database) error { + return d.ReplaceRoom("1234", make([]byte, 32), valid) + }, "Replacement room ID must be different"}, + } { + t.Run(testCase.name, func(t *testing.T) { + database, err := NewDatabase(databasePath(t)) + if err != nil { + t.Fatalf("NewDatabase: %v", err) + } + if _, err := database.Initialize(0, 1_000); err != nil { + t.Fatalf("Initialize: %v", err) + } + defer func() { _ = database.Close() }() + if err := testCase.run(database); err == nil || err.Error() != testCase.want { + t.Fatalf("error = %v, want %q", err, testCase.want) + } + }) + } +} + +func TestRejectsAMemoryDatabasePath(t *testing.T) { + for _, path := range []string{"", ":memory:"} { + if _, err := NewDatabase(path); err == nil || + err.Error() != "Room database path must identify a file" { + t.Fatalf("NewDatabase(%q) = %v", path, err) + } + } +} + +func TestRejectsASecondInitialize(t *testing.T) { + path := databasePath(t) + database, err := NewDatabase(path) + if err != nil { + t.Fatalf("NewDatabase: %v", err) + } + if _, err := database.Initialize(0, 1_000); err != nil { + t.Fatalf("Initialize: %v", err) + } + if _, err := database.Initialize(0, 1_000); err == nil || + err.Error() != "Room database is already initialized" { + t.Fatalf("second Initialize = %v", err) + } + if err := database.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if err := database.InsertRoom(StoredRoomAuthority{ + RoomID: "1234", + HostTokenDigest: make([]byte, 32), + ViewerGrantDigest: make([]byte, 32), + ViewerAuthorizationGeneration: "MzMzMzMzMzMzMzMzMzMzMw", + CodeEntryPolicy: protocol.CodeEntryOpen, + }); err == nil || err.Error() != "Room database is not initialized" { + t.Fatalf("InsertRoom after Close = %v", err) + } +} + +func TestRejectsAnActiveLeaseThatIsNotInTheFuture(t *testing.T) { + database, err := NewDatabase(databasePath(t)) + if err != nil { + t.Fatalf("NewDatabase: %v", err) + } + if _, err := database.Initialize(1_000, 1_000); err == nil || + err.Error() != "Room database active-room lease deadline must be in the future" { + t.Fatalf("Initialize = %v", err) + } + if _, err := database.Initialize(-1, 1_000); err == nil || + err.Error() != "Room database startup time must be a safe positive integer" { + t.Fatalf("Initialize = %v", err) + } + if err := database.Close(); err != nil { + t.Fatalf("Close: %v", err) + } +} diff --git a/internal/server/room/doc.go b/internal/server/room/doc.go new file mode 100644 index 0000000..84d73cb --- /dev/null +++ b/internal/server/room/doc.go @@ -0,0 +1,88 @@ +// Package room ports the room authority: src/server/room-store.ts (rooms, +// participants, room codes, viewer credentials, the four-digit code pool) and +// src/server/room-database.ts (the SQLite stable authority). Every mutation +// writes SQLite before memory and returns the write error unchanged; there is +// no catch-and-continue anywhere, exactly as in the TypeScript. +// +// # Locking +// +// Store has no mutex. The signaling layer's single global lock guards it, which +// is what Node's single thread did: nothing interleaved except at an await. The +// caller must hold that lock for every method below except the two derive +// methods, which run the password KDF and must NOT be called under the lock. +// +// The TypeScript methods that await the KDF are split into a locked "begin", +// an unlocked derive, and a locked "commit" so that the guards the TypeScript +// re-ran after its await are re-run here after the relock, in the same order. +// The commit takes the derive result as (material, derived error) because the +// TypeScript inspected the gate outcome (busy/cancelled) only after re-reading +// the room: forward both values unchanged and the commit reproduces the order. +// +// POST /api/rooms (createRoom): +// +// mu.Lock() +// lease, err := store.BeginCreateRoom() // ROOM_LIMIT + lease deadline, pre-KDF +// if err != nil { mu.Unlock(); return err } +// var material []byte +// var derived error +// if password != "" { +// mu.Unlock() +// material, derived = store.DeriveViewerPasswordMaterial(password, nil) +// mu.Lock() +// } +// created, err := store.CreateRoom(policy, material, derived, preferredRoomID, lease) +// mu.Unlock() +// +// replaceRoom and set-viewer-password (the mayStart closure is the TypeScript +// `rooms.get(roomId) === current && verifyDigest(hostToken, ...)`; it runs on +// the derive goroutine, so it takes the lock itself): +// +// mu.Lock() +// current, err := store.HostManagedRoom(roomID, hostToken) // may fail INVALID_TOKEN/ROOM_EXPIRED +// if err != nil { mu.Unlock(); return err } +// var material []byte +// var derived error +// if password != "" { +// mu.Unlock() +// material, derived = store.DeriveViewerPasswordMaterial(password, func() bool { +// mu.Lock() +// defer mu.Unlock() +// return store.HostStillOwnsRoom(roomID, hostToken, current) +// }) +// mu.Lock() +// } +// replaced, err := store.ReplaceRoom(roomID, hostToken, policy, material, derived, current) +// mu.Unlock() +// +// Viewer password login (signaling authenticate). mayConnect is the TypeScript +// closure `!closing && socketStates.get(socket) === state && !state.revoked && +// !state.authenticated && socket.readyState === OPEN`. It is evaluated twice, +// once on the derive goroutine (gate admission) and once under the lock inside +// the commit, so the caller needs both a locking and a non-locking form of the +// same predicate: +// +// mu.Lock() +// salt, expected, ref, err := store.ViewerPasswordChallenge(input.RoomID) +// mu.Unlock() +// if err != nil { return err } // not initialized +// derivedKey, err := store.DeriveViewerPassword(input.Password, salt, func() bool { +// mu.Lock() +// defer mu.Unlock() +// return mayConnectLocked() +// }) +// mu.Lock() +// if err != nil { mu.Unlock(); return err } // ROOM_BUSY or INVALID_TOKEN +// participant, err := store.ConnectViewerWithPassword( +// input, derivedKey, expected, ref, mayConnectLocked) +// mu.Unlock() +// +// The room reference handed back by HostManagedRoom and ViewerPasswordChallenge +// is only ever compared for pointer identity, reproducing the TypeScript +// `currentRoom !== room` checks that detect a room replaced while the KDF ran. +// Holding it across the unlock is safe: an abandoned room is dropped from the +// map, never mutated into another room. +// +// Options.Random is called both under the caller's lock and from the derive +// goroutine (the 16-byte salt), so it must be safe for concurrent use; +// crypto/rand is. +package room diff --git a/internal/server/room/password.go b/internal/server/room/password.go new file mode 100644 index 0000000..cae24c6 --- /dev/null +++ b/internal/server/room/password.go @@ -0,0 +1,145 @@ +package room + +import ( + "errors" + "slices" + "sync" + + "golang.org/x/crypto/scrypt" + + "github.com/TNTcraftHIM/Screener/internal/server/protocol" +) + +const ( + viewerPasswordSaltBytes = 16 + viewerPasswordVerifierBytes = 32 + viewerPasswordMaterialBytes = viewerPasswordSaltBytes + viewerPasswordVerifierBytes + + viewerPasswordScryptN = 16_384 + viewerPasswordScryptR = 8 + viewerPasswordScryptP = 1 + + // AsyncGate bounds from src/server/room-store.ts (DECISIONS D11): scrypt at + // N=16384 costs ~16 MiB and tens of milliseconds, so two concurrent + // derivations bound the memory and CPU a room burst can claim, and sixteen + // waiters bound the queue before the next caller is told ROOM_BUSY. + gateActiveLimit = 2 + gatePendingLimit = 16 +) + +// errDerivationCancelled is the AsyncGate "cancelled" outcome. Callers never +// inspect it: they hand it to the commit method, which maps it the way the +// TypeScript call site did. +var errDerivationCancelled = errors.New("password derivation was cancelled") + +// isGateOutcome reports whether err is one of the two AsyncGate outcomes rather +// than a thrown error. The TypeScript returned busy and cancelled as values, +// which its post-await checks inspected after re-reading the room; everything +// else threw out of the await. +func isGateOutcome(err error) bool { + return errors.Is(err, errDerivationCancelled) || hasCode(err, CodeRoomBusy) +} + +// DeriveViewerPasswordMaterial is createViewerPasswordMaterial: it validates the +// password, draws a fresh salt and returns salt||verifier. Call it WITHOUT the +// caller's lock held and hand both results to CreateRoom, ReplaceRoom or +// SetViewerPassword unchanged; those reproduce the TypeScript order in which a +// busy or cancelled outcome is turned into an error. mayStart may be nil, which +// is the TypeScript default of `() => true`. +func (s *Store) DeriveViewerPasswordMaterial( + password string, mayStart func() bool, +) ([]byte, error) { + if !protocol.ValidViewerPassword(password) { + return nil, codeError(CodeInvalidToken) + } + salt := s.random(viewerPasswordSaltBytes) + if len(salt) != viewerPasswordSaltBytes { + return nil, errors.New("Viewer password salt source must return 16 bytes") + } + verifier, err := s.deriveViewerPassword(password, salt, mayStart) + if err != nil { + return nil, err + } + return slices.Concat(salt, verifier), nil +} + +// DeriveViewerPassword is the unlocked middle of connectViewerWithPassword: it +// derives the verifier for the salt ViewerPasswordChallenge returned. Unlike +// DeriveViewerPasswordMaterial it maps both gate outcomes itself, because the +// TypeScript threw them immediately after the await, before re-reading the room. +func (s *Store) DeriveViewerPassword( + password string, salt []byte, mayConnect func() bool, +) ([]byte, error) { + if !protocol.ValidViewerPassword(password) { + return nil, codeError(CodeInvalidToken) + } + derived, err := s.deriveViewerPassword(password, salt, mayConnect) + if errors.Is(err, errDerivationCancelled) { + return nil, codeError(CodeInvalidToken) + } + return derived, err +} + +func (s *Store) deriveViewerPassword( + password string, salt []byte, mayStart func() bool, +) ([]byte, error) { + return s.gate.run(mayStart, func() ([]byte, error) { + return scrypt.Key([]byte(password), salt, + viewerPasswordScryptN, viewerPasswordScryptR, viewerPasswordScryptP, + viewerPasswordVerifierBytes) + }) +} + +// gate is AsyncGate: at most limit derivations run at once and at most +// pendingLimit wait, first in first out. A saturated gate answers ROOM_BUSY. +type gate struct { + mu sync.Mutex + active int + waiters []chan struct{} + limit int + pendingLimit int +} + +func (g *gate) run(mayStart func() bool, task func() ([]byte, error)) ([]byte, error) { + if !g.acquire() { + return nil, codeError(CodeRoomBusy) + } + defer g.release() + if mayStart != nil && !mayStart() { + return nil, errDerivationCancelled + } + return task() +} + +func (g *gate) acquire() bool { + g.mu.Lock() + if g.active < g.limit { + g.active++ + g.mu.Unlock() + return true + } + if len(g.waiters) >= g.pendingLimit { + g.mu.Unlock() + return false + } + wake := make(chan struct{}) + g.waiters = append(g.waiters, wake) + g.mu.Unlock() + <-wake + return true +} + +// release hands the slot to the first waiter instead of decrementing, which is +// what `const next = this.waiters.shift(); if (next) { next(); return; }` did. +func (g *gate) release() { + g.mu.Lock() + if len(g.waiters) > 0 { + wake := g.waiters[0] + g.waiters = g.waiters[1:] + g.mu.Unlock() + close(wake) + return + } + g.active-- + g.mu.Unlock() +} diff --git a/internal/server/room/store.go b/internal/server/room/store.go new file mode 100644 index 0000000..607c86d --- /dev/null +++ b/internal/server/room/store.go @@ -0,0 +1,1199 @@ +package room + +import ( + "bytes" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "math" + "slices" + "strconv" + "time" + + "github.com/TNTcraftHIM/Screener/internal/server/ordered" + "github.com/TNTcraftHIM/Screener/internal/server/protocol" +) + +// Capacity is ROOM_CAPACITY: every four-digit room code. +const Capacity = 9_000 + +const roomCodeFirst = 1_000 + +// ErrorCode is RoomStoreErrorCode. +type ErrorCode string + +// The RoomStoreErrorCode set. +const ( + CodeInvalidToken ErrorCode = "INVALID_TOKEN" + CodeRoomAccessDenied ErrorCode = "ROOM_ACCESS_DENIED" + CodeRoomBusy ErrorCode = "ROOM_BUSY" + CodeRoomNotFound ErrorCode = "ROOM_NOT_FOUND" + CodeRoomExpired ErrorCode = "ROOM_EXPIRED" + CodeRoomFull ErrorCode = "ROOM_FULL" + CodeHostAlreadyConnected ErrorCode = "HOST_ALREADY_CONNECTED" + CodeRoomLimit ErrorCode = "ROOM_LIMIT" +) + +// Error is RoomStoreError. Its message is the code, as `super(code)` made it. +type Error struct { + Code ErrorCode +} + +// Error returns the code, which is the message `super(code)` produced. +func (e *Error) Error() string { return string(e.Code) } + +func codeError(code ErrorCode) error { return &Error{Code: code} } + +func hasCode(err error, code ErrorCode) bool { + var roomError *Error + return errors.As(err, &roomError) && roomError.Code == code +} + +// admittedBy values. +const ( + admittedByGrant = "grant" + admittedByCode = "code" +) + +type participant struct { + clientID string + peerID string + // sessionID is "" where the TypeScript left sessionId undefined. Session + // IDs are opaque IDs of at least 8 characters, so "" cannot collide. + sessionID string + admittedBy string +} + +// Room is one room's authority plus its participants. It has no exported +// members: callers hold the pointer only to compare identity across the +// unlocked password derivation, as the TypeScript compared room objects. +type Room struct { + roomID string + hostTokenDigest []byte + viewerGrantDigest []byte + viewerPasswordMaterial []byte + viewerAuthorizationGeneration string + codeEntryPolicy protocol.CodeEntryPolicy + sharingActive bool + leaseExpiresAtMs *int64 + host *participant + // ordering: JS Map insertion order decides viewer presence order, the + // closed-session list and the router's participant order. + viewers ordered.Map[string, *participant] +} + +// CreatedRoom is CreatedRoom. ViewerGrant is "" where the TypeScript had null. +type CreatedRoom struct { + RoomID string + HostToken string + CodeEntryPolicy protocol.CodeEntryPolicy + ViewerGrant string + ExpiresAt *string +} + +// ConnectParticipantInput is ConnectParticipantInput; Token is read for a host +// and ViewerGrant for a viewer, "" meaning the key was absent. +type ConnectParticipantInput struct { + RoomID string + Role protocol.Role + Token string + ViewerGrant string + ClientID string + SessionID string +} + +// ConnectViewerWithPasswordInput is ConnectViewerWithPasswordInput. Password is +// consumed by the caller's DeriveViewerPassword call, not by the commit. +type ConnectViewerWithPasswordInput struct { + RoomID string + Password string + ClientID string + SessionID string +} + +// ConnectedParticipant is ConnectedParticipant. ReplacedSessionID is "" where +// the TypeScript omitted replacedSessionId. +type ConnectedParticipant struct { + RoomID string + Role protocol.Role + PeerID string + ExpiresAt *string + HostOnline bool + ReplacedSessionID string + CodeEntryPolicy protocol.CodeEntryPolicy + ViewerPasswordEnabled bool + ViewerAuthorizationGeneration string +} + +// DisconnectedParticipant is DisconnectedParticipant. +type DisconnectedParticipant struct { + RoomID string + Role protocol.Role + PeerID string +} + +// ConnectedPeer is ConnectedPeer. +type ConnectedPeer struct { + PeerID string + SessionID string +} + +// ClosedRoom is ClosedRoom. +type ClosedRoom struct { + RoomID string + SessionIDs []string +} + +// ReplacedRoom is ReplacedRoom. +type ReplacedRoom struct { + Created CreatedRoom + Closed ClosedRoom +} + +// RevokedViewer is RevokedViewer; SessionID is "" where the TypeScript omitted +// sessionId. +type RevokedViewer struct { + PeerID string + SessionID string +} + +// ViewerGrantUpdate is ViewerGrantUpdate. ViewerGrant is "" for a revoke. +type ViewerGrantUpdate struct { + ViewerGrant string + ViewerAuthorizationGeneration string + PreviousViewerAuthorizationGeneration string + RevokedViewers []RevokedViewer +} + +// CodeEntryUpdate is CodeEntryUpdate. +type CodeEntryUpdate struct { + CodeEntryPolicy protocol.CodeEntryPolicy + ViewerPasswordEnabled bool +} + +// Options is RoomStoreOptions. +type Options struct { + LeaseMs int64 + MaxRooms int + MaxViewersPerRoom int + Database *Database + Now func() int64 + Random func(size int) []byte +} + +// Store is RoomStore. See the package comment for the locking contract. +type Store struct { + // ordering: JS Map insertion order decides AbandonAllRooms/ExpireRooms. + rooms ordered.Map[string, *Room] + freeRoomCodes []string + now func() int64 + random func(size int) []byte + database *Database + initialized bool + leaseMs int64 + maxRooms int + maxViewersPerRoom int + // gate is per store; the TypeScript module-level gate was shared by every + // RoomStore in a process, of which there was one (D11). + gate gate +} + +// New is the RoomStore constructor; the TypeScript threw where this returns an +// error. +func New(options Options) (*Store, error) { + if options.LeaseMs <= 0 || options.LeaseMs > protocol.MaxSafeInteger { + return nil, errors.New("Room lease must be a positive integer") + } + if options.MaxRooms <= 0 || options.MaxRooms > Capacity { + return nil, fmt.Errorf("Room limit must be an integer between 1 and %d", Capacity) + } + if options.MaxViewersPerRoom <= 0 || + options.MaxViewersPerRoom > protocol.MaxViewersPerRoomLimit { + return nil, fmt.Errorf( + "Room viewer limit must be an integer between 1 and %d", + protocol.MaxViewersPerRoomLimit) + } + store := &Store{ + freeRoomCodes: make([]string, Capacity), + now: options.Now, + random: options.Random, + database: options.Database, + initialized: options.Database == nil, + leaseMs: options.LeaseMs, + maxRooms: options.MaxRooms, + maxViewersPerRoom: options.MaxViewersPerRoom, + gate: gate{limit: gateActiveLimit, pendingLimit: gatePendingLimit}, + } + for index := range store.freeRoomCodes { + store.freeRoomCodes[index] = strconv.Itoa(roomCodeFirst + index) + } + if store.now == nil { + store.now = func() int64 { return time.Now().UnixMilli() } + } + if store.random == nil { + store.random = cryptoRandom + } + return store, nil +} + +func cryptoRandom(size int) []byte { + value := make([]byte, size) + // crypto/rand.Read never fails; it panics if the operating system source does. + _, _ = rand.Read(value) + return value +} + +// MaxViewersPerRoom is the readonly RoomStore field of the same name. +func (s *Store) MaxViewersPerRoom() int { return s.maxViewersPerRoom } + +// Size is the `size` getter. +func (s *Store) Size() int { return s.rooms.Len() } + +// Close closes the database. +func (s *Store) Close() error { + if s.database == nil { + return nil + } + return s.database.Close() +} + +// Initialize recovers the stable authority. It is a no-op without a database. +func (s *Store) Initialize() error { + if s.initialized { + return nil + } + if s.database == nil { + s.initialized = true + return nil + } + if err := s.restore(s.now()); err != nil { + s.rooms.Clear() + _ = s.database.Close() + return err + } + s.initialized = true + return nil +} + +func (s *Store) restore(startupNowMs int64) error { + lease, err := s.leaseDeadline(startupNowMs) + if err != nil { + return err + } + storedRooms, err := s.database.Initialize(startupNowMs, lease) + if err != nil { + return err + } + if len(storedRooms) > s.maxRooms { + return errors.New("Room database exceeds the configured room limit") + } + roomIDs := make(map[string]struct{}, len(storedRooms)) + for _, stored := range storedRooms { + roomIDs[stored.RoomID] = struct{}{} + } + if len(roomIDs) != len(storedRooms) { + return errors.New("Room database contains duplicate room IDs") + } + for _, stored := range storedRooms { + s.rooms.Set(stored.RoomID, &Room{ + roomID: stored.RoomID, + hostTokenDigest: bytes.Clone(stored.HostTokenDigest), + viewerGrantDigest: bytes.Clone(stored.ViewerGrantDigest), + viewerPasswordMaterial: bytes.Clone(stored.ViewerPasswordMaterial), + viewerAuthorizationGeneration: stored.ViewerAuthorizationGeneration, + codeEntryPolicy: stored.CodeEntryPolicy, + sharingActive: false, + leaseExpiresAtMs: stored.LeaseExpiresAtMs, + }) + } + available := s.freeRoomCodes[:0] + for _, code := range s.freeRoomCodes { + if _, taken := roomIDs[code]; !taken { + available = append(available, code) + } + } + s.freeRoomCodes = available + return nil +} + +// BeginCreateRoom runs the part of createRoom that precedes the password +// derivation: the room limit and the lease deadline, which the TypeScript took +// from the clock before awaiting the KDF. Pass the deadline to CreateRoom. +func (s *Store) BeginCreateRoom() (leaseExpiresAtMs int64, err error) { + if err := s.ensureInitialized(); err != nil { + return 0, err + } + if s.rooms.Len() >= s.maxRooms { + return 0, codeError(CodeRoomLimit) + } + return s.leaseDeadline(s.now()) +} + +// CreateRoom is the commit half of createRoom. material and derived are the +// results of DeriveViewerPasswordMaterial (both zero when no password was +// given) and leaseExpiresAtMs comes from BeginCreateRoom. +func (s *Store) CreateRoom( + policy protocol.CodeEntryPolicy, + material []byte, + derived error, + preferredRoomID string, + leaseExpiresAtMs int64, +) (CreatedRoom, error) { + // createViewerPasswordMaterial threw before any of the checks below. + if derived != nil && !isGateOutcome(derived) { + return CreatedRoom{}, derived + } + if err := s.ensureInitialized(); err != nil { + return CreatedRoom{}, err + } + if s.rooms.Len() >= s.maxRooms { + return CreatedRoom{}, codeError(CodeRoomLimit) + } + if hasCode(derived, CodeRoomBusy) { + return CreatedRoom{}, codeError(CodeRoomBusy) + } + if errors.Is(derived, errDerivationCancelled) { + return CreatedRoom{}, errors.New("Room creation password derivation was cancelled") + } + roomID, err := s.takeRoomCode(preferredRoomID) + if err != nil { + return CreatedRoom{}, err + } + room, created, err := s.newRoom(roomID, policy, material, leaseExpiresAtMs) + if err == nil { + err = s.writeDatabase(func() error { return s.database.InsertRoom(storedRoomAuthority(room)) }) + } + if err != nil { + s.releaseRoomCode(roomID) + return CreatedRoom{}, err + } + s.rooms.Set(roomID, room) + return created, nil +} + +// HostManagedRoom is getHostManagedRoom: the room the exact Host token owns. +// It is the pre-KDF half of replaceRoom and setViewerPassword. +func (s *Store) HostManagedRoom(roomID, hostToken string) (*Room, error) { + if err := s.ensureInitialized(); err != nil { + return nil, err + } + return s.getHostManagedRoom(roomID, hostToken) +} + +// HostStillOwnsRoom is the mayStart closure replaceRoom and setViewerPassword +// hand to the KDF gate: the room must still be the same object and the token +// must still own it. Call it with the lock held. +func (s *Store) HostStillOwnsRoom(roomID, hostToken string, current *Room) bool { + room, ok := s.rooms.Get(roomID) + return ok && room == current && verifyDigest(hostToken, room.hostTokenDigest) +} + +// ReplaceRoom is the commit half of replaceRoom. current is the room +// HostManagedRoom returned before the derivation. +func (s *Store) ReplaceRoom( + roomID, hostToken string, + policy protocol.CodeEntryPolicy, + material []byte, + derived error, + current *Room, +) (ReplacedRoom, error) { + // createViewerPasswordMaterial threw before the second getHostManagedRoom. + if derived != nil && !isGateOutcome(derived) { + return ReplacedRoom{}, derived + } + if err := s.ensureInitialized(); err != nil { + return ReplacedRoom{}, err + } + owned, err := s.getHostManagedRoom(roomID, hostToken) + if err != nil { + return ReplacedRoom{}, err + } + if owned != current || errors.Is(derived, errDerivationCancelled) { + return ReplacedRoom{}, codeError(CodeRoomAccessDenied) + } + if hasCode(derived, CodeRoomBusy) { + return ReplacedRoom{}, codeError(CodeRoomBusy) + } + + replacementRoomID, err := s.takeRoomCode("") + if err != nil { + return ReplacedRoom{}, err + } + replaced, err := s.replaceRoom(roomID, replacementRoomID, policy, material, current) + if err != nil { + s.releaseRoomCode(replacementRoomID) + return ReplacedRoom{}, err + } + return replaced, nil +} + +func (s *Store) replaceRoom( + roomID, replacementRoomID string, + policy protocol.CodeEntryPolicy, + material []byte, + current *Room, +) (ReplacedRoom, error) { + leaseExpiresAtMs, err := s.leaseDeadline(s.now()) + if err != nil { + return ReplacedRoom{}, err + } + room, created, err := s.newRoom(replacementRoomID, policy, material, leaseExpiresAtMs) + if err != nil { + return ReplacedRoom{}, err + } + err = s.writeDatabase(func() error { + return s.database.ReplaceRoom(roomID, current.hostTokenDigest, storedRoomAuthority(room)) + }) + if err != nil { + return ReplacedRoom{}, err + } + closed := ClosedRoom{RoomID: roomID, SessionIDs: connectedSessionIDs(current)} + s.rooms.Delete(roomID) + s.rooms.Set(replacementRoomID, room) + s.releaseRoomCode(roomID) + return ReplacedRoom{Created: created, Closed: closed}, nil +} + +// SetViewerPassword is the commit half of setViewerPassword; it reports whether +// a password is now set. current is the room HostManagedRoom returned before +// the derivation. +func (s *Store) SetViewerPassword( + roomID, hostToken string, + material []byte, + derived error, + current *Room, +) (bool, error) { + // createViewerPasswordMaterial threw before the second getHostManagedRoom. + if derived != nil && !isGateOutcome(derived) { + return false, derived + } + if err := s.ensureInitialized(); err != nil { + return false, err + } + currentRoom, err := s.getHostManagedRoom(roomID, hostToken) + if err != nil { + return false, err + } + if currentRoom != current || errors.Is(derived, errDerivationCancelled) { + return false, codeError(CodeRoomAccessDenied) + } + if hasCode(derived, CodeRoomBusy) { + return false, codeError(CodeRoomBusy) + } + err = s.writeDatabase(func() error { + return s.database.SetViewerPassword(roomID, currentRoom.hostTokenDigest, material) + }) + if err != nil { + return false, err + } + currentRoom.viewerPasswordMaterial = material + return material != nil, nil +} + +// SetCodeEntryPolicy is setCodeEntryPolicy. +func (s *Store) SetCodeEntryPolicy( + roomID string, + policy protocol.CodeEntryPolicy, + hostToken string, +) (CodeEntryUpdate, error) { + if err := s.ensureInitialized(); err != nil { + return CodeEntryUpdate{}, err + } + room, err := s.getHostManagedRoom(roomID, hostToken) + if err != nil { + return CodeEntryUpdate{}, err + } + err = s.writeDatabase(func() error { + return s.database.SetCodeEntryPolicy(roomID, room.hostTokenDigest, policy) + }) + if err != nil { + return CodeEntryUpdate{}, err + } + room.codeEntryPolicy = policy + return CodeEntryUpdate{ + CodeEntryPolicy: policy, + ViewerPasswordEnabled: room.viewerPasswordMaterial != nil, + }, nil +} + +// SetViewerGrant is setViewerGrant; action is "rotate" or "revoke". +func (s *Store) SetViewerGrant( + roomID, action, hostToken string, +) (ViewerGrantUpdate, error) { + if err := s.ensureInitialized(); err != nil { + return ViewerGrantUpdate{}, err + } + room, err := s.getHostManagedRoom(roomID, hostToken) + if err != nil { + return ViewerGrantUpdate{}, err + } + previousGeneration := room.viewerAuthorizationGeneration + viewerGrant := "" + if action == "rotate" { + if viewerGrant, err = s.createViewerGrant(); err != nil { + return ViewerGrantUpdate{}, err + } + } + + var revokedViewers []RevokedViewer + for _, viewer := range room.viewers.Values() { + if viewer.admittedBy == admittedByGrant { + revokedViewers = append(revokedViewers, RevokedViewer{ + PeerID: viewer.peerID, + SessionID: viewer.sessionID, + }) + } + } + var viewerGrantDigest []byte + if viewerGrant != "" { + viewerGrantDigest = digest(viewerGrant) + } else { + if viewerGrantDigest, err = s.randomDigest(); err != nil { + return ViewerGrantUpdate{}, err + } + } + generation, err := s.newAuthorizationGeneration() + if err != nil { + return ViewerGrantUpdate{}, err + } + err = s.writeDatabase(func() error { + return s.database.SetViewerGrant( + roomID, room.hostTokenDigest, viewerGrantDigest, generation) + }) + if err != nil { + return ViewerGrantUpdate{}, err + } + room.viewerGrantDigest = viewerGrantDigest + room.viewerAuthorizationGeneration = generation + // ordering: delete during iteration, which a JS Map allows. + for clientID, viewer := range room.viewers.All() { + if viewer.admittedBy == admittedByGrant { + room.viewers.Delete(clientID) + } + } + return ViewerGrantUpdate{ + ViewerGrant: viewerGrant, + ViewerAuthorizationGeneration: room.viewerAuthorizationGeneration, + PreviousViewerAuthorizationGeneration: previousGeneration, + RevokedViewers: revokedViewers, + }, nil +} + +// ConnectParticipant is connectParticipant. +func (s *Store) ConnectParticipant(input ConnectParticipantInput) (ConnectedParticipant, error) { + if err := s.ensureInitialized(); err != nil { + return ConnectedParticipant{}, err + } + room, err := s.getAvailableRoom(input.RoomID) + if err != nil { + if input.Role == protocol.RoleViewer && input.ViewerGrant == "" && + (hasCode(err, CodeInvalidToken) || hasCode(err, CodeRoomExpired)) { + return ConnectedParticipant{}, codeError(CodeRoomNotFound) + } + return ConnectedParticipant{}, err + } + if input.Role == protocol.RoleHost { + if !verifyDigest(input.Token, room.hostTokenDigest) { + return ConnectedParticipant{}, codeError(CodeInvalidToken) + } + return s.connectHost(room, input) + } + + if input.ViewerGrant != "" { + if !viewerGrantIsValid(room, input.ViewerGrant) { + return ConnectedParticipant{}, codeError(CodeInvalidToken) + } + return s.connectViewer(room, input.ClientID, input.SessionID, admittedByGrant) + } + if room.codeEntryPolicy != protocol.CodeEntryOpen { + return ConnectedParticipant{}, codeError(CodeInvalidToken) + } + return s.connectViewer(room, input.ClientID, input.SessionID, admittedByCode) +} + +// ViewerGrantMayEnter is viewerGrantMayEnter. +func (s *Store) ViewerGrantMayEnter(roomID, grant string) (bool, error) { + if err := s.ensureInitialized(); err != nil { + return false, err + } + room, err := s.getAvailableRoom(roomID) + if err != nil { + var roomError *Error + if errors.As(err, &roomError) { + return false, nil + } + return false, err + } + return viewerGrantIsValid(room, grant), nil +} + +// ViewerPasswordChallenge is the locked first half of connectViewerWithPassword: +// it returns the salt to derive against, a copy of the material to compare with +// and the room reference to re-check afterwards. A missing room, an expired +// room and a room without a private password all answer with the same 48 zero +// bytes so the derivation cost does not reveal which. The only error is the +// `ensureInitialized()` the TypeScript ran before the password check. +func (s *Store) ViewerPasswordChallenge( + roomID string, +) (salt, expectedMaterial []byte, room *Room, err error) { + if err := s.ensureInitialized(); err != nil { + return nil, nil, nil, err + } + found, err := s.getAvailableRoom(roomID) + if err != nil { + found = nil + } + expectedMaterial = make([]byte, viewerPasswordMaterialBytes) + if found != nil && found.codeEntryPolicy == protocol.CodeEntryPrivate && + found.viewerPasswordMaterial != nil { + copy(expectedMaterial, found.viewerPasswordMaterial) + } + return expectedMaterial[:viewerPasswordSaltBytes], expectedMaterial, found, nil +} + +// ConnectViewerWithPassword is the commit half of connectViewerWithPassword. +// derived is the DeriveViewerPassword result; expectedMaterial and room come +// from ViewerPasswordChallenge; mayConnect is evaluated with the lock held. +func (s *Store) ConnectViewerWithPassword( + input ConnectViewerWithPasswordInput, + derived, expectedMaterial []byte, + room *Room, + mayConnect func() bool, +) (ConnectedParticipant, error) { + if err := s.ensureInitialized(); err != nil { + return ConnectedParticipant{}, err + } + matches := subtle.ConstantTimeCompare( + derived, expectedMaterial[viewerPasswordSaltBytes:]) == 1 + + currentRoom, err := s.getAvailableRoom(input.RoomID) + if err != nil { + var roomError *Error + if errors.As(err, &roomError) { + return ConnectedParticipant{}, codeError(CodeRoomNotFound) + } + return ConnectedParticipant{}, err + } + if room == nil || currentRoom != room || + currentRoom.codeEntryPolicy != protocol.CodeEntryPrivate || + currentRoom.viewerPasswordMaterial == nil || + !matches || + subtle.ConstantTimeCompare(currentRoom.viewerPasswordMaterial, expectedMaterial) != 1 || + !mayConnect() { + return ConnectedParticipant{}, codeError(CodeInvalidToken) + } + connected, err := s.connectViewer( + currentRoom, input.ClientID, input.SessionID, admittedByCode) + if hasCode(err, CodeRoomFull) { + return ConnectedParticipant{}, codeError(CodeInvalidToken) + } + return connected, err +} + +// DisconnectParticipant is disconnectParticipant; it returns nil where the +// TypeScript returned undefined. +func (s *Store) DisconnectParticipant( + roomID, peerID, sessionID string, +) (*DisconnectedParticipant, error) { + if err := s.ensureInitialized(); err != nil { + return nil, err + } + room, ok := s.rooms.Get(roomID) + if !ok { + return nil, nil + } + + if room.host != nil && room.host.peerID == peerID { + if room.host.sessionID != sessionID { + return nil, nil + } + leaseExpiresAtMs, err := s.leaseDeadline(s.now()) + if err != nil { + return nil, err + } + err = s.writeDatabase(func() error { + return s.database.SetLeaseDeadline(roomID, room.hostTokenDigest, &leaseExpiresAtMs) + }) + if err != nil { + return nil, err + } + room.host.sessionID = "" + room.sharingActive = false + room.leaseExpiresAtMs = &leaseExpiresAtMs + return &DisconnectedParticipant{ + RoomID: roomID, Role: protocol.RoleHost, PeerID: peerID}, nil + } + + viewer := findViewerByPeerID(room, peerID) + if viewer == nil || viewer.sessionID != sessionID { + return nil, nil + } + viewer.sessionID = "" + return &DisconnectedParticipant{ + RoomID: roomID, Role: protocol.RoleViewer, PeerID: peerID}, nil +} + +// RemoveDisconnectedViewer is removeDisconnectedViewer. +func (s *Store) RemoveDisconnectedViewer(roomID, peerID string) bool { + room, ok := s.rooms.Get(roomID) + if !ok { + return false + } + viewer := findViewerByPeerID(room, peerID) + if viewer == nil || viewer.sessionID != "" { + return false + } + return room.viewers.Delete(viewer.clientID) +} + +// GetConnectedHost is getConnectedHost. +func (s *Store) GetConnectedHost(roomID string) (ConnectedPeer, bool) { + room, ok := s.rooms.Get(roomID) + if !ok || room.host == nil || room.host.sessionID == "" { + return ConnectedPeer{}, false + } + return ConnectedPeer{PeerID: room.host.peerID, SessionID: room.host.sessionID}, true +} + +// GetConnectedViewer is getConnectedViewer. +func (s *Store) GetConnectedViewer(roomID, peerID string) (ConnectedPeer, bool) { + room, ok := s.rooms.Get(roomID) + if !ok { + return ConnectedPeer{}, false + } + viewer := findViewerByPeerID(room, peerID) + if viewer == nil || viewer.sessionID == "" { + return ConnectedPeer{}, false + } + return ConnectedPeer{PeerID: peerID, SessionID: viewer.sessionID}, true +} + +// GetViewerPeerIDs is getViewerPeerIds, in viewer insertion order. +func (s *Store) GetViewerPeerIDs(roomID string) []string { + room, ok := s.rooms.Get(roomID) + if !ok { + return nil + } + peerIDs := make([]string, 0, room.viewers.Len()) + for _, viewer := range room.viewers.Values() { + peerIDs = append(peerIDs, viewer.peerID) + } + return peerIDs +} + +// GetConnectedViewers is getConnectedViewers, in viewer insertion order. +func (s *Store) GetConnectedViewers(roomID string) []ConnectedPeer { + room, ok := s.rooms.Get(roomID) + if !ok { + return nil + } + var connected []ConnectedPeer + for _, viewer := range room.viewers.Values() { + if viewer.sessionID != "" { + connected = append(connected, ConnectedPeer{ + PeerID: viewer.peerID, SessionID: viewer.sessionID}) + } + } + return connected +} + +// AbandonRoom is abandonRoom; it returns nil where the TypeScript returned +// undefined. +func (s *Store) AbandonRoom(roomID string) (*ClosedRoom, error) { + if err := s.ensureInitialized(); err != nil { + return nil, err + } + room, ok := s.rooms.Get(roomID) + if !ok { + return nil, nil + } + sessionIDs := connectedSessionIDs(room) + err := s.writeDatabase(func() error { + return s.database.DeleteRoom(roomID, room.hostTokenDigest) + }) + if err != nil { + return nil, err + } + s.rooms.Delete(roomID) + s.releaseRoomCode(roomID) + return &ClosedRoom{RoomID: roomID, SessionIDs: sessionIDs}, nil +} + +// AbandonAllRooms is abandonAllRooms, in room insertion order. +func (s *Store) AbandonAllRooms() ([]ClosedRoom, error) { + if err := s.ensureInitialized(); err != nil { + return nil, err + } + var closedRooms []ClosedRoom + for _, roomID := range s.rooms.Keys() { + closed, err := s.AbandonRoom(roomID) + if err != nil { + return nil, err + } + if closed != nil { + closedRooms = append(closedRooms, *closed) + } + } + return closedRooms, nil +} + +// ExpireRooms is expireRooms, in room insertion order. The TypeScript defaulted +// nowMs to the injected clock; every caller passed it explicitly. +func (s *Store) ExpireRooms(nowMs int64) ([]ClosedRoom, error) { + if err := s.ensureInitialized(); err != nil { + return nil, err + } + var expiredIDs []string + var identities []RoomIdentity + var expired []ClosedRoom + for roomID, room := range s.rooms.All() { + if !roomIsExpired(room, nowMs) { + continue + } + expiredIDs = append(expiredIDs, roomID) + identities = append(identities, RoomIdentity{ + RoomID: roomID, HostTokenDigest: room.hostTokenDigest}) + expired = append(expired, ClosedRoom{ + RoomID: roomID, SessionIDs: connectedSessionIDs(room)}) + } + if err := s.writeDatabase(func() error { return s.database.DeleteRooms(identities) }); err != nil { + return nil, err + } + for _, roomID := range expiredIDs { + s.rooms.Delete(roomID) + s.releaseRoomCode(roomID) + } + return expired, nil +} + +func (s *Store) connectHost( + room *Room, input ConnectParticipantInput, +) (ConnectedParticipant, error) { + current := room.host + if current != nil && current.sessionID != "" && current.clientID != input.ClientID { + return ConnectedParticipant{}, codeError(CodeHostAlreadyConnected) + } + + joining := current + if current == nil || current.clientID != input.ClientID { + peerID, err := s.newPeerID(room) + if err != nil { + return ConnectedParticipant{}, err + } + joining = &participant{ + clientID: input.ClientID, peerID: peerID, admittedBy: admittedByCode} + } + replacedSessionID := joining.sessionID + err := s.writeDatabase(func() error { + return s.database.SetLeaseDeadline(room.roomID, room.hostTokenDigest, nil) + }) + if err != nil { + return ConnectedParticipant{}, err + } + joining.sessionID = input.SessionID + room.host = joining + room.sharingActive = true + room.leaseExpiresAtMs = nil + + return connectedParticipant( + room, joining.peerID, protocol.RoleHost, replacedSessionID, input.SessionID), nil +} + +func (s *Store) connectViewer( + room *Room, clientID, sessionID, admittedBy string, +) (ConnectedParticipant, error) { + joining, ok := room.viewers.Get(clientID) + if !ok && room.viewers.Len() >= s.maxViewersPerRoom { + return ConnectedParticipant{}, codeError(CodeRoomFull) + } + if !ok { + peerID, err := s.newPeerID(room) + if err != nil { + return ConnectedParticipant{}, err + } + joining = &participant{clientID: clientID, peerID: peerID, admittedBy: admittedBy} + } + replacedSessionID := joining.sessionID + joining.sessionID = sessionID + joining.admittedBy = admittedBy + // ordering: Map.set keeps the position of an existing key. + room.viewers.Set(clientID, joining) + return connectedParticipant( + room, joining.peerID, protocol.RoleViewer, replacedSessionID, sessionID), nil +} + +func connectedParticipant( + room *Room, peerID string, role protocol.Role, replacedSessionID, sessionID string, +) ConnectedParticipant { + if replacedSessionID == sessionID { + replacedSessionID = "" + } + return ConnectedParticipant{ + RoomID: room.roomID, + Role: role, + PeerID: peerID, + ExpiresAt: formatExpiresAt(room.leaseExpiresAtMs), + HostOnline: room.host != nil && room.host.sessionID != "", + ReplacedSessionID: replacedSessionID, + CodeEntryPolicy: room.codeEntryPolicy, + ViewerPasswordEnabled: room.viewerPasswordMaterial != nil, + ViewerAuthorizationGeneration: room.viewerAuthorizationGeneration, + } +} + +func (s *Store) getAvailableRoom(roomID string) (*Room, error) { + room, ok := s.rooms.Get(roomID) + if !ok { + return nil, codeError(CodeInvalidToken) + } + if roomIsExpired(room, s.now()) { + return nil, codeError(CodeRoomExpired) + } + return room, nil +} + +func (s *Store) getHostManagedRoom(roomID, hostToken string) (*Room, error) { + room, err := s.getAvailableRoom(roomID) + if err != nil { + return nil, err + } + if !verifyDigest(hostToken, room.hostTokenDigest) { + return nil, codeError(CodeInvalidToken) + } + return room, nil +} + +func (s *Store) newPeerID(room *Room) (string, error) { + return s.uniqueID(16, func(candidate string) bool { + if room.host != nil && room.host.peerID == candidate { + return true + } + for _, viewer := range room.viewers.Values() { + if viewer.peerID == candidate { + return true + } + } + return false + }) +} + +func (s *Store) takeRoomCode(preferredRoomID string) (string, error) { + if len(s.freeRoomCodes) == 0 { + return "", codeError(CodeRoomLimit) + } + if preferredRoomID != "" { + if index := slices.Index(s.freeRoomCodes, preferredRoomID); index >= 0 { + return s.takeRoomCodeAt(index), nil + } + } + index, err := uniformIndex(len(s.freeRoomCodes), s.random) + if err != nil { + return "", err + } + return s.takeRoomCodeAt(index), nil +} + +// uniformIndex draws a uniform index below count from 8 random bytes, rejecting +// the values above the largest multiple of count. The TypeScript computed the +// limit in BigInt as 2^64 - 2^64 % count; (MaxUint64 % count + 1) % count is +// the same remainder without the uint64 wrap at a power-of-two count (D11). +func uniformIndex(count int, random func(size int) []byte) (int, error) { + span := uint64(count) + remainder := (math.MaxUint64%span + 1) % span + for attempt := 0; attempt < 16; attempt++ { + value := random(8) + if len(value) != 8 { + return 0, errors.New("Room code random source must return 8 bytes") + } + if remainder != 0 && + binary.BigEndian.Uint64(value) >= math.MaxUint64-remainder+1 { + continue + } + return int(binary.BigEndian.Uint64(value) % span), nil + } + return 0, errors.New("Unable to select a room code uniformly") +} + +// takeRoomCodeAt removes the code at index by swapping the last code into it. +func (s *Store) takeRoomCodeAt(index int) string { + roomID := s.freeRoomCodes[index] + last := len(s.freeRoomCodes) - 1 + lastRoomID := s.freeRoomCodes[last] + s.freeRoomCodes = s.freeRoomCodes[:last] + if index < last { + s.freeRoomCodes[index] = lastRoomID + } + return roomID +} + +func (s *Store) releaseRoomCode(roomID string) { + s.freeRoomCodes = append(s.freeRoomCodes, roomID) +} + +func (s *Store) uniqueID(size int, exists func(candidate string) bool) (string, error) { + for attempt := 0; attempt < 8; attempt++ { + value := s.random(size) + if len(value) != size { + return "", fmt.Errorf("Identifier random source must return %d bytes", size) + } + candidate := base64.RawURLEncoding.EncodeToString(value) + if !exists(candidate) { + return candidate, nil + } + } + return "", errors.New("Unable to allocate a unique identifier") +} + +func viewerGrantIsValid(room *Room, grant string) bool { + if grant == "" || !protocol.ValidViewerGrant(grant) { + return false + } + return verifyDigest(grant, room.viewerGrantDigest) +} + +func (s *Store) createViewerGrant() (string, error) { + secret := s.random(16) + if len(secret) != 16 { + return "", errors.New("Viewer grant random source must return 16 bytes") + } + return base64.RawURLEncoding.EncodeToString(secret), nil +} + +func (s *Store) newRoom( + roomID string, + policy protocol.CodeEntryPolicy, + material []byte, + leaseExpiresAtMs int64, +) (*Room, CreatedRoom, error) { + hostTokenBytes := s.random(32) + if len(hostTokenBytes) != 32 { + return nil, CreatedRoom{}, errors.New("Host token random source must return 32 bytes") + } + hostToken := base64.RawURLEncoding.EncodeToString(hostTokenBytes) + viewerGrant, err := s.createViewerGrant() + if err != nil { + return nil, CreatedRoom{}, err + } + generation, err := s.newAuthorizationGeneration() + if err != nil { + return nil, CreatedRoom{}, err + } + room := &Room{ + roomID: roomID, + hostTokenDigest: digest(hostToken), + viewerGrantDigest: digest(viewerGrant), + viewerPasswordMaterial: material, + viewerAuthorizationGeneration: generation, + codeEntryPolicy: policy, + sharingActive: false, + leaseExpiresAtMs: &leaseExpiresAtMs, + } + return room, CreatedRoom{ + RoomID: roomID, + HostToken: hostToken, + CodeEntryPolicy: policy, + ViewerGrant: viewerGrant, + ExpiresAt: formatExpiresAt(&leaseExpiresAtMs), + }, nil +} + +func (s *Store) randomDigest() ([]byte, error) { + value := s.random(32) + if len(value) != 32 { + return nil, errors.New("Viewer grant random source must return 32 bytes") + } + return bytes.Clone(value), nil +} + +func (s *Store) newAuthorizationGeneration() (string, error) { + value := s.random(16) + if len(value) != 16 { + return "", errors.New("Authorization generation random source must return 16 bytes") + } + return base64.RawURLEncoding.EncodeToString(value), nil +} + +func (s *Store) leaseDeadline(nowMs int64) (int64, error) { + deadline := nowMs + s.leaseMs + if deadline <= nowMs || deadline > protocol.MaxSafeInteger || + deadline < -protocol.MaxSafeInteger { + return 0, errors.New("Room lease deadline exceeds the supported time range") + } + return deadline, nil +} + +func (s *Store) ensureInitialized() error { + if !s.initialized { + return errors.New("RoomStore stable authority is not initialized") + } + return nil +} + +// writeDatabase runs a stable-authority write when a database is configured, +// which is the TypeScript `this.options.database?.write(...)`. +func (s *Store) writeDatabase(write func() error) error { + if s.database == nil { + return nil + } + return write() +} + +func storedRoomAuthority(room *Room) StoredRoomAuthority { + return StoredRoomAuthority{ + RoomID: room.roomID, + HostTokenDigest: room.hostTokenDigest, + ViewerGrantDigest: room.viewerGrantDigest, + ViewerAuthorizationGeneration: room.viewerAuthorizationGeneration, + CodeEntryPolicy: room.codeEntryPolicy, + ViewerPasswordMaterial: room.viewerPasswordMaterial, + LeaseExpiresAtMs: room.leaseExpiresAtMs, + } +} + +func roomIsExpired(room *Room, nowMs int64) bool { + return !room.sharingActive && room.leaseExpiresAtMs != nil && + *room.leaseExpiresAtMs <= nowMs +} + +func formatExpiresAt(expiresAtMs *int64) *string { + if expiresAtMs == nil { + return nil + } + value := time.UnixMilli(*expiresAtMs).UTC().Format("2006-01-02T15:04:05.000Z") + return &value +} + +func findViewerByPeerID(room *Room, peerID string) *participant { + for _, viewer := range room.viewers.Values() { + if viewer.peerID == peerID { + return viewer + } + } + return nil +} + +func connectedSessionIDs(room *Room) []string { + var sessionIDs []string + if room.host != nil && room.host.sessionID != "" { + sessionIDs = append(sessionIDs, room.host.sessionID) + } + for _, viewer := range room.viewers.Values() { + if viewer.sessionID != "" { + sessionIDs = append(sessionIDs, viewer.sessionID) + } + } + return sessionIDs +} + +func digest(value string) []byte { + sum := sha256.Sum256([]byte(value)) + return sum[:] +} + +func verifyDigest(value string, expectedDigest []byte) bool { + return subtle.ConstantTimeCompare(digest(value), expectedDigest) == 1 +} diff --git a/internal/server/room/store_test.go b/internal/server/room/store_test.go new file mode 100644 index 0000000..78caa46 --- /dev/null +++ b/internal/server/room/store_test.go @@ -0,0 +1,1045 @@ +package room + +import ( + "bytes" + "errors" + "regexp" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/TNTcraftHIM/Screener/internal/server/protocol" +) + +// The scenarios below are ported from tests/room-store.test.ts. + +type clock struct{ nowMs int64 } + +func newStore(t *testing.T, options Options) (*Store, *clock) { + t.Helper() + testClock := &clock{} + if options.LeaseMs == 0 { + options.LeaseMs = 1_000 + } + if options.MaxRooms == 0 { + options.MaxRooms = 2 + } + if options.MaxViewersPerRoom == 0 { + options.MaxViewersPerRoom = 2 + } + options.Now = func() int64 { return testClock.nowMs } + store, err := New(options) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := store.Initialize(); err != nil { + t.Fatalf("Initialize: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + return store, testClock +} + +func constantRandom(value func(size int) byte) func(size int) []byte { + return func(size int) []byte { return bytes.Repeat([]byte{value(size)}, size) } +} + +// tryCreateRoom runs the locked/unlocked/locked createRoom sequence the package +// comment documents. +func tryCreateRoom( + store *Store, policy protocol.CodeEntryPolicy, password, preferredRoomID string, +) (CreatedRoom, error) { + lease, err := store.BeginCreateRoom() + if err != nil { + return CreatedRoom{}, err + } + var material []byte + var derived error + if password != "" { + material, derived = store.DeriveViewerPasswordMaterial(password, nil) + } + return store.CreateRoom(policy, material, derived, preferredRoomID, lease) +} + +func createRoom( + t *testing.T, store *Store, policy protocol.CodeEntryPolicy, password, preferredRoomID string, +) CreatedRoom { + t.Helper() + created, err := tryCreateRoom(store, policy, password, preferredRoomID) + if err != nil { + t.Fatalf("CreateRoom: %v", err) + } + return created +} + +func tryReplaceRoom( + store *Store, roomID, hostToken string, policy protocol.CodeEntryPolicy, password string, +) (ReplacedRoom, error) { + current, err := store.HostManagedRoom(roomID, hostToken) + if err != nil { + return ReplacedRoom{}, err + } + var material []byte + var derived error + if password != "" { + material, derived = store.DeriveViewerPasswordMaterial(password, func() bool { + return store.HostStillOwnsRoom(roomID, hostToken, current) + }) + } + return store.ReplaceRoom(roomID, hostToken, policy, material, derived, current) +} + +func trySetViewerPassword(store *Store, roomID, password, hostToken string) (bool, error) { + current, err := store.HostManagedRoom(roomID, hostToken) + if err != nil { + return false, err + } + var material []byte + var derived error + if password != "" { + material, derived = store.DeriveViewerPasswordMaterial(password, func() bool { + return store.HostStillOwnsRoom(roomID, hostToken, current) + }) + } + return store.SetViewerPassword(roomID, hostToken, material, derived, current) +} + +func tryConnectViewerWithPassword( + store *Store, input ConnectViewerWithPasswordInput, mayConnect func() bool, +) (ConnectedParticipant, error) { + if mayConnect == nil { + mayConnect = func() bool { return true } + } + salt, expectedMaterial, room, err := store.ViewerPasswordChallenge(input.RoomID) + if err != nil { + return ConnectedParticipant{}, err + } + derived, err := store.DeriveViewerPassword(input.Password, salt, mayConnect) + if err != nil { + return ConnectedParticipant{}, err + } + return store.ConnectViewerWithPassword(input, derived, expectedMaterial, room, mayConnect) +} + +func hostInput(roomID, hostToken string, sessionID ...string) ConnectParticipantInput { + session := "host-session" + if len(sessionID) > 0 { + session = sessionID[0] + } + return ConnectParticipantInput{ + RoomID: roomID, + Role: protocol.RoleHost, + Token: hostToken, + ClientID: "host-client", + SessionID: session, + } +} + +func viewerInput(roomID, sessionID, viewerGrant string) ConnectParticipantInput { + return ConnectParticipantInput{ + RoomID: roomID, + Role: protocol.RoleViewer, + ViewerGrant: viewerGrant, + ClientID: "viewer-" + sessionID, + SessionID: sessionID, + } +} + +func expectCode(t *testing.T, err error, code ErrorCode) { + t.Helper() + if !hasCode(err, code) { + t.Fatalf("expected %s, got %v", code, err) + } +} + +func mustConnect(t *testing.T, store *Store, input ConnectParticipantInput) ConnectedParticipant { + t.Helper() + connected, err := store.ConnectParticipant(input) + if err != nil { + t.Fatalf("ConnectParticipant: %v", err) + } + return connected +} + +var roomCodePattern = regexp.MustCompile(`^[1-9][0-9]{3}$`) +var viewerGrantPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{21}[AQgw]$`) + +func TestAllocatesEveryFreeRoomCodeAndRecyclesReleases(t *testing.T) { + store, _ := newStore(t, Options{ + MaxRooms: Capacity, + Random: constantRandom(func(int) byte { return 0 }), + }) + + rooms := make([]CreatedRoom, 0, Capacity) + seen := make(map[string]struct{}, Capacity) + for index := 0; index < Capacity; index++ { + created := createRoom(t, store, protocol.CodeEntryOpen, "", "") + if !roomCodePattern.MatchString(created.RoomID) { + t.Fatalf("room code %q is not four digits", created.RoomID) + } + seen[created.RoomID] = struct{}{} + rooms = append(rooms, created) + } + if len(seen) != Capacity { + t.Fatalf("allocated %d distinct codes, want %d", len(seen), Capacity) + } + if _, err := tryCreateRoom(store, protocol.CodeEntryOpen, "", ""); !hasCode(err, CodeRoomLimit) { + t.Fatalf("expected ROOM_LIMIT, got %v", err) + } + + released := rooms[4_321].RoomID + closed, err := store.AbandonRoom(released) + if err != nil || closed == nil || closed.RoomID != released { + t.Fatalf("AbandonRoom(%s) = %v, %v", released, closed, err) + } + if created := createRoom(t, store, protocol.CodeEntryOpen, "", ""); created.RoomID != released { + t.Fatalf("recycled code = %q, want %q", created.RoomID, released) + } +} + +func TestUsesAFreePreferredCodeAndFallsBack(t *testing.T) { + store, _ := newStore(t, Options{MaxRooms: 3}) + + preferred := createRoom(t, store, protocol.CodeEntryOpen, "", "4321") + fallback := createRoom(t, store, protocol.CodeEntryOpen, "", "4321") + + if preferred.RoomID != "4321" { + t.Fatalf("preferred code = %q", preferred.RoomID) + } + if fallback.RoomID == "4321" { + t.Fatal("fallback reused the occupied preferred code") + } + if closed, err := store.AbandonRoom("4321"); err != nil || closed.RoomID != "4321" { + t.Fatalf("AbandonRoom = %v, %v", closed, err) + } + if again := createRoom(t, store, protocol.CodeEntryOpen, "", "4321"); again.RoomID != "4321" { + t.Fatalf("released preferred code = %q", again.RoomID) + } +} + +func TestAbandonsEveryCurrentRoomAsOneShutdown(t *testing.T) { + store, _ := newStore(t, Options{MaxRooms: 3}) + first := createRoom(t, store, protocol.CodeEntryOpen, "", "") + second := createRoom(t, store, protocol.CodeEntryOpen, "", "") + mustConnect(t, store, hostInput(first.RoomID, first.HostToken)) + + closed, err := store.AbandonAllRooms() + if err != nil { + t.Fatalf("AbandonAllRooms: %v", err) + } + if len(closed) != 2 { + t.Fatalf("closed %d rooms, want 2", len(closed)) + } + // Room insertion order is contract: the JS Map iterated in creation order. + if closed[0].RoomID != first.RoomID || closed[1].RoomID != second.RoomID { + t.Fatalf("closed order = %q, %q", closed[0].RoomID, closed[1].RoomID) + } + if len(closed[0].SessionIDs) != 1 || closed[0].SessionIDs[0] != "host-session" { + t.Fatalf("closed sessions = %v", closed[0].SessionIDs) + } + if len(closed[1].SessionIDs) != 0 { + t.Fatalf("second room sessions = %v", closed[1].SessionIDs) + } + if store.Size() != 0 { + t.Fatalf("size = %d, want 0", store.Size()) + } +} + +func TestAtomicallyReplacesARoomWithDifferentAuthority(t *testing.T) { + store, _ := newStore(t, Options{}) + original := createRoom(t, store, protocol.CodeEntryPrivate, "old-password", "4321") + host := mustConnect(t, store, hostInput(original.RoomID, original.HostToken)) + mustConnect(t, store, viewerInput(original.RoomID, "viewer-session", original.ViewerGrant)) + + _, err := tryReplaceRoom( + store, original.RoomID, original.HostToken, protocol.CodeEntryPrivate, "bad password") + expectCode(t, err, CodeInvalidToken) + if connected, ok := store.GetConnectedHost(original.RoomID); !ok || connected.PeerID != host.PeerID { + t.Fatalf("host lost after a rejected replacement: %v %v", connected, ok) + } + + replacement, err := tryReplaceRoom( + store, original.RoomID, original.HostToken, protocol.CodeEntryPrivate, "new-password") + if err != nil { + t.Fatalf("ReplaceRoom: %v", err) + } + if replacement.Created.RoomID == original.RoomID { + t.Fatal("replacement reused the room code") + } + if replacement.Closed.RoomID != original.RoomID || + len(replacement.Closed.SessionIDs) != 2 || + replacement.Closed.SessionIDs[0] != "host-session" || + replacement.Closed.SessionIDs[1] != "viewer-session" { + t.Fatalf("closed = %+v", replacement.Closed) + } + _, err = store.ConnectParticipant(hostInput(original.RoomID, original.HostToken)) + expectCode(t, err, CodeInvalidToken) + connected := mustConnect(t, store, viewerInput( + replacement.Created.RoomID, "new-viewer-session", replacement.Created.ViewerGrant)) + if connected.RoomID != replacement.Created.RoomID { + t.Fatalf("viewer joined %q", connected.RoomID) + } + withPassword, err := tryConnectViewerWithPassword(store, ConnectViewerWithPasswordInput{ + RoomID: replacement.Created.RoomID, + Password: "new-password", + ClientID: "password-viewer", + SessionID: "password-session", + }, nil) + if err != nil || withPassword.RoomID != replacement.Created.RoomID { + t.Fatalf("password login after replacement: %+v %v", withPassword, err) + } +} + +func TestRejectsRoomLimitsBeyondTheCodeSpace(t *testing.T) { + _, err := New(Options{LeaseMs: 1_000, MaxRooms: Capacity + 1, MaxViewersPerRoom: 2}) + if err == nil || err.Error() != "Room limit must be an integer between 1 and 9000" { + t.Fatalf("New: %v", err) + } +} + +func TestKeepsActiveSharingAliveAndRenewsOnlyWithTheExactHostToken(t *testing.T) { + store, testClock := newStore(t, Options{LeaseMs: 1_000}) + room := createRoom(t, store, protocol.CodeEntryOpen, "", "") + host := mustConnect(t, store, hostInput(room.RoomID, room.HostToken)) + + testClock.nowMs = 2_000 + if expired, err := store.ExpireRooms(testClock.nowMs); err != nil || len(expired) != 0 { + t.Fatalf("ExpireRooms while sharing = %v, %v", expired, err) + } + + if _, err := store.DisconnectParticipant(room.RoomID, host.PeerID, "host-session"); err != nil { + t.Fatalf("DisconnectParticipant: %v", err) + } + testClock.nowMs = 2_500 + if expired, err := store.ExpireRooms(testClock.nowMs); err != nil || len(expired) != 0 { + t.Fatalf("ExpireRooms inside the lease = %v, %v", expired, err) + } + + _, err := store.ConnectParticipant(hostInput(room.RoomID, "wrong-token")) + expectCode(t, err, CodeInvalidToken) + resumed := mustConnect(t, store, hostInput(room.RoomID, room.HostToken, "host-session-2")) + if resumed.ExpiresAt != nil { + t.Fatalf("resumed expiresAt = %v, want null", *resumed.ExpiresAt) + } + + if _, err := store.DisconnectParticipant( + room.RoomID, resumed.PeerID, "host-session-2"); err != nil { + t.Fatalf("DisconnectParticipant: %v", err) + } + mustConnect(t, store, viewerInput(room.RoomID, "viewer-session", room.ViewerGrant)) + testClock.nowMs = 3_501 + expired, err := store.ExpireRooms(testClock.nowMs) + if err != nil || len(expired) != 1 { + t.Fatalf("ExpireRooms after the lease = %v, %v", expired, err) + } + _, err = store.ConnectParticipant(hostInput(room.RoomID, room.HostToken, "late-host")) + expectCode(t, err, CodeInvalidToken) +} + +func TestManagesDormantAccessWithTheExactHostTokenWithoutRenewing(t *testing.T) { + store, testClock := newStore(t, Options{LeaseMs: 1_000}) + room := createRoom(t, store, protocol.CodeEntryOpen, "", "") + otherRoom := createRoom(t, store, protocol.CodeEntryOpen, "", "") + testClock.nowMs = 900 + + _, err := trySetViewerPassword(store, room.RoomID, "room-password", "wrong-token") + expectCode(t, err, CodeInvalidToken) + _, err = store.SetCodeEntryPolicy(room.RoomID, protocol.CodeEntryPrivate, otherRoom.HostToken) + expectCode(t, err, CodeInvalidToken) + + update, err := store.SetCodeEntryPolicy( + room.RoomID, protocol.CodeEntryPrivate, room.HostToken) + if err != nil || update.CodeEntryPolicy != protocol.CodeEntryPrivate || + update.ViewerPasswordEnabled { + t.Fatalf("SetCodeEntryPolicy = %+v, %v", update, err) + } + if enabled, err := trySetViewerPassword( + store, room.RoomID, "room-password", room.HostToken); err != nil || !enabled { + t.Fatalf("SetViewerPassword = %v, %v", enabled, err) + } + if enabled, err := trySetViewerPassword( + store, room.RoomID, "", room.HostToken); err != nil || enabled { + t.Fatalf("cleared SetViewerPassword = %v, %v", enabled, err) + } + grant, err := store.SetViewerGrant(room.RoomID, "rotate", room.HostToken) + if err != nil || !viewerGrantPattern.MatchString(grant.ViewerGrant) { + t.Fatalf("SetViewerGrant = %+v, %v", grant, err) + } + if _, ok := store.GetConnectedHost(room.RoomID); ok { + t.Fatal("a dormant room reported a connected host") + } + + testClock.nowMs = 1_001 + expired, err := store.ExpireRooms(testClock.nowMs) + if err != nil { + t.Fatalf("ExpireRooms: %v", err) + } + if !slicesContainsRoom(expired, room.RoomID) { + t.Fatalf("expired = %+v, want %q", expired, room.RoomID) + } +} + +func slicesContainsRoom(rooms []ClosedRoom, roomID string) bool { + for _, room := range rooms { + if room.RoomID == roomID { + return true + } + } + return false +} + +func TestKeepsViewerGrantsIndependentFromCodeEntryPolicy(t *testing.T) { + store, _ := newStore(t, Options{MaxRooms: 3}) + room := createRoom(t, store, protocol.CodeEntryPrivate, "", "") + + _, err := store.ConnectParticipant(viewerInput(room.RoomID, "code-only", "")) + expectCode(t, err, CodeInvalidToken) + _, err = tryConnectViewerWithPassword(store, ConnectViewerWithPasswordInput{ + RoomID: room.RoomID, + Password: "any-password", + ClientID: "private-code-viewer", + SessionID: "private-code-session", + }, nil) + expectCode(t, err, CodeInvalidToken) + granted := mustConnect(t, store, viewerInput(room.RoomID, "granted", room.ViewerGrant)) + if granted.Role != protocol.RoleViewer { + t.Fatalf("role = %q", granted.Role) + } +} + +func TestKeepsTheExactViewerGrantValidForTheRoomIncarnation(t *testing.T) { + store, testClock := newStore(t, Options{LeaseMs: 1_000}) + room := createRoom(t, store, protocol.CodeEntryOpen, "", "") + mustConnect(t, store, hostInput(room.RoomID, room.HostToken)) + + if !viewerGrantPattern.MatchString(room.ViewerGrant) { + t.Fatalf("viewer grant = %q", room.ViewerGrant) + } + testClock.nowMs = 8 * 24 * 60 * 60 * 1_000 + connected := mustConnect(t, store, viewerInput(room.RoomID, "room-lived-grant", room.ViewerGrant)) + if connected.Role != protocol.RoleViewer { + t.Fatalf("role = %q", connected.Role) + } +} + +func TestSupportsOpenAndPasswordEnabledPrivateCodeEntry(t *testing.T) { + store, _ := newStore(t, Options{MaxRooms: 3}) + open := createRoom(t, store, protocol.CodeEntryOpen, "", "") + if connected := mustConnect(t, store, viewerInput(open.RoomID, "open", "")); connected.Role != + protocol.RoleViewer { + t.Fatalf("role = %q", connected.Role) + } + + password := createRoom(t, store, protocol.CodeEntryPrivate, "room-password", "") + _, err := store.ConnectParticipant(viewerInput(password.RoomID, "wrong", "")) + expectCode(t, err, CodeInvalidToken) + connected, err := tryConnectViewerWithPassword(store, ConnectViewerWithPasswordInput{ + RoomID: password.RoomID, + Password: "room-password", + ClientID: "password-viewer", + SessionID: "password-session", + }, nil) + if err != nil || connected.Role != protocol.RoleViewer { + t.Fatalf("password login = %+v, %v", connected, err) + } +} + +// gateState reads the KDF gate counters the way only a same-package test can. +func gateState(store *Store) (active, pending int) { + store.gate.mu.Lock() + defer store.gate.mu.Unlock() + return store.gate.active, len(store.gate.waiters) +} + +// withSaturatedGate holds the KDF gate at 2 running and 16 queued derivations +// for the duration of operation, which is what the vitest helper did by +// starting 18 password logins whose mayStart never returned. +func withSaturatedGate(t *testing.T, store *Store, operation func()) { + t.Helper() + release := make(chan struct{}) + var group sync.WaitGroup + for index := 0; index < 18; index++ { + group.Add(1) + go func() { + defer group.Done() + _, _ = store.DeriveViewerPassword( + "gate-password", make([]byte, viewerPasswordSaltBytes), func() bool { + <-release + return false + }) + }() + } + defer func() { + close(release) + group.Wait() + }() + waitForSaturatedGate(t, store) + operation() +} + +func waitForSaturatedGate(t *testing.T, store *Store) { + t.Helper() + deadline := time.Now().Add(30 * time.Second) + for { + active, pending := gateState(store) + if active == 2 && pending == 16 { + return + } + if time.Now().After(deadline) { + t.Fatalf("gate did not saturate: active=%d pending=%d", active, pending) + } + time.Sleep(time.Millisecond) + } +} + +func TestDoesNotCreateAPasswordlessRoomWhenTheGateIsBusy(t *testing.T) { + store, _ := newStore(t, Options{MaxRooms: 1}) + + withSaturatedGate(t, store, func() { + _, err := tryCreateRoom(store, protocol.CodeEntryPrivate, "room-password", "4321") + expectCode(t, err, CodeRoomBusy) + }) + if store.Size() != 0 { + t.Fatalf("size = %d, want 0", store.Size()) + } + if created := createRoom(t, store, protocol.CodeEntryPrivate, "", "4321"); created.RoomID != "4321" { + t.Fatalf("room = %q, want 4321", created.RoomID) + } +} + +func TestKeepsGateSaturationDistinctFromInvalidCredentials(t *testing.T) { + store, _ := newStore(t, Options{}) + room := createRoom(t, store, protocol.CodeEntryPrivate, "room-password", "") + + withSaturatedGate(t, store, func() { + _, err := tryConnectViewerWithPassword(store, ConnectViewerWithPasswordInput{ + RoomID: room.RoomID, + Password: "room-password", + ClientID: "busy-password-client", + SessionID: "busy-password-session", + }, nil) + expectCode(t, err, CodeRoomBusy) + }) +} + +func TestKeepsRoomReplacementAndPasswordUpdatesUnchangedWhenBusy(t *testing.T) { + store, _ := newStore(t, Options{MaxRooms: 3}) + room := createRoom(t, store, protocol.CodeEntryPrivate, "old-password", "4321") + + withSaturatedGate(t, store, func() { + _, err := tryReplaceRoom( + store, room.RoomID, room.HostToken, protocol.CodeEntryPrivate, "replacement-password") + expectCode(t, err, CodeRoomBusy) + }) + if store.Size() != 1 { + t.Fatalf("size = %d, want 1", store.Size()) + } + if _, err := tryConnectViewerWithPassword(store, ConnectViewerWithPasswordInput{ + RoomID: room.RoomID, + Password: "old-password", + ClientID: "after-replacement-client", + SessionID: "after-replacement-session", + }, nil); err != nil { + t.Fatalf("password login after a busy replacement: %v", err) + } + + withSaturatedGate(t, store, func() { + _, err := trySetViewerPassword(store, room.RoomID, "updated-password", room.HostToken) + expectCode(t, err, CodeRoomBusy) + }) + if _, err := tryConnectViewerWithPassword(store, ConnectViewerWithPasswordInput{ + RoomID: room.RoomID, + Password: "old-password", + ClientID: "after-update-client", + SessionID: "after-update-session", + }, nil); err != nil { + t.Fatalf("password login after a busy update: %v", err) + } +} + +func TestBoundsPasswordDerivationsAndUsesTheSamePathForUnknownRooms(t *testing.T) { + store, _ := newStore(t, Options{}) + + var mayStartCalls atomic.Int64 + release := make(chan struct{}) + results := make(chan error, 40) + var group sync.WaitGroup + for index := 0; index < 40; index++ { + group.Add(1) + go func(index int) { + defer group.Done() + _, err := tryConnectViewerWithPassword(store, ConnectViewerWithPasswordInput{ + RoomID: "9999", + Password: "bounded-password", + ClientID: "bounded-client-" + strconv.Itoa(index), + SessionID: "bounded-session-" + strconv.Itoa(index), + }, func() bool { + // The first two admitted hold their slot until every attempt + // has been accepted or rejected, which the single-threaded + // TypeScript got for free. + if mayStartCalls.Add(1) <= 2 { + <-release + return true + } + return false + }) + results <- err + }(index) + } + waitForSaturatedGate(t, store) + close(release) + group.Wait() + close(results) + + counts := map[ErrorCode]int{} + for err := range results { + var roomError *Error + if !errors.As(err, &roomError) { + t.Fatalf("unexpected error %v", err) + } + counts[roomError.Code]++ + } + if calls := mayStartCalls.Load(); calls != 18 { + t.Fatalf("mayStart calls = %d, want 18", calls) + } + if counts[CodeRoomNotFound] != 2 || counts[CodeInvalidToken] != 16 || + counts[CodeRoomBusy] != 22 { + t.Fatalf("outcomes = %v", counts) + } +} + +func TestRotatesAndRevokesViewerGrantsWithoutChangingCodeEntry(t *testing.T) { + store, _ := newStore(t, Options{}) + room := createRoom(t, store, protocol.CodeEntryOpen, "", "") + mustConnect(t, store, hostInput(room.RoomID, room.HostToken)) + + rotated, err := store.SetViewerGrant(room.RoomID, "rotate", room.HostToken) + if err != nil { + t.Fatalf("rotate: %v", err) + } + _, err = store.ConnectParticipant(viewerInput(room.RoomID, "old-grant", room.ViewerGrant)) + expectCode(t, err, CodeInvalidToken) + grantedViewer := mustConnect(t, store, viewerInput(room.RoomID, "new-grant", rotated.ViewerGrant)) + codeViewer := mustConnect(t, store, viewerInput(room.RoomID, "still-open", "")) + + revoked, err := store.SetViewerGrant(room.RoomID, "revoke", room.HostToken) + if err != nil { + t.Fatalf("revoke: %v", err) + } + if revoked.ViewerGrant != "" { + t.Fatalf("revoked grant = %q, want empty", revoked.ViewerGrant) + } + if len(revoked.RevokedViewers) != 1 || revoked.RevokedViewers[0].PeerID != grantedViewer.PeerID { + t.Fatalf("revoked viewers = %+v", revoked.RevokedViewers) + } + if connected, ok := store.GetConnectedViewer( + room.RoomID, codeViewer.PeerID); !ok || connected.SessionID != "still-open" { + t.Fatalf("code viewer = %+v %v", connected, ok) + } + _, err = store.ConnectParticipant(viewerInput(room.RoomID, "revoked", rotated.ViewerGrant)) + expectCode(t, err, CodeInvalidToken) +} + +func TestKeepsTheOldGrantWhenRotationCannotCreateAGeneration(t *testing.T) { + failedRandomCall := 0 + randomCalls := 0 + randomValue := byte(0) + store, _ := newStore(t, Options{ + Random: func(size int) []byte { + randomCalls++ + randomValue++ + if randomCalls == failedRandomCall { + size-- + } + return bytes.Repeat([]byte{randomValue}, size) + }, + }) + room := createRoom(t, store, protocol.CodeEntryOpen, "", "") + mustConnect(t, store, hostInput(room.RoomID, room.HostToken)) + viewer := mustConnect(t, store, viewerInput(room.RoomID, "existing", room.ViewerGrant)) + + failedRandomCall = randomCalls + 2 + _, err := store.SetViewerGrant(room.RoomID, "rotate", room.HostToken) + if err == nil || + err.Error() != "Authorization generation random source must return 16 bytes" { + t.Fatalf("rotate: %v", err) + } + failedRandomCall = 0 + + if connected, ok := store.GetConnectedViewer( + room.RoomID, viewer.PeerID); !ok || connected.SessionID != "existing" { + t.Fatalf("viewer = %+v %v", connected, ok) + } + connected := mustConnect(t, store, viewerInput( + room.RoomID, "old-grant-still-valid", room.ViewerGrant)) + if connected.Role != protocol.RoleViewer { + t.Fatalf("role = %q", connected.Role) + } +} + +func TestClearsEveryCredentialOnProcessRestart(t *testing.T) { + firstStore, _ := newStore(t, Options{}) + room := createRoom(t, firstStore, protocol.CodeEntryPrivate, "room-password", "") + secondStore, _ := newStore(t, Options{}) + + replacement := createRoom(t, secondStore, protocol.CodeEntryOpen, "", "") + if !roomCodePattern.MatchString(replacement.RoomID) { + t.Fatalf("room code = %q", replacement.RoomID) + } + _, err := secondStore.ConnectParticipant(hostInput(room.RoomID, room.HostToken)) + expectCode(t, err, CodeInvalidToken) + _, err = secondStore.ConnectParticipant(viewerInput(room.RoomID, "old-grant", room.ViewerGrant)) + expectCode(t, err, CodeInvalidToken) + _, err = tryConnectViewerWithPassword(secondStore, ConnectViewerWithPasswordInput{ + RoomID: room.RoomID, + Password: "room-password", + ClientID: "viewer", + SessionID: "session", + }, nil) + expectCode(t, err, CodeRoomNotFound) +} + +func TestRejectsAnOldGrantWhenANewRoomReusesTheSameCode(t *testing.T) { + firstStore, _ := newStore(t, Options{ + Random: constantRandom(func(size int) byte { + if size == 8 { + return 0 + } + return 1 + }), + }) + firstRoom := createRoom(t, firstStore, protocol.CodeEntryOpen, "", "") + if _, err := firstStore.AbandonRoom(firstRoom.RoomID); err != nil { + t.Fatalf("AbandonRoom: %v", err) + } + + secondStore, _ := newStore(t, Options{ + Random: constantRandom(func(size int) byte { + if size == 8 { + return 0 + } + return 2 + }), + }) + secondRoom := createRoom(t, secondStore, protocol.CodeEntryOpen, "", "") + + if secondRoom.RoomID != firstRoom.RoomID { + t.Fatalf("codes differ: %q %q", secondRoom.RoomID, firstRoom.RoomID) + } + if secondRoom.ViewerGrant == firstRoom.ViewerGrant { + t.Fatal("the new incarnation reused the old grant") + } + _, err := secondStore.ConnectParticipant( + viewerInput(secondRoom.RoomID, "old-incarnation", firstRoom.ViewerGrant)) + expectCode(t, err, CodeInvalidToken) +} + +func TestParticipantReplacementRules(t *testing.T) { + store, _ := newStore(t, Options{}) + room := createRoom(t, store, protocol.CodeEntryOpen, "", "") + host := mustConnect(t, store, hostInput(room.RoomID, room.HostToken)) + + resumed := mustConnect(t, store, hostInput(room.RoomID, room.HostToken, "host-session-2")) + if resumed.PeerID != host.PeerID || resumed.ReplacedSessionID != "host-session" { + t.Fatalf("resumed host = %+v", resumed) + } + _, err := store.ConnectParticipant(ConnectParticipantInput{ + RoomID: room.RoomID, + Role: protocol.RoleHost, + Token: room.HostToken, + ClientID: "second-host-client", + SessionID: "second-host-session", + }) + expectCode(t, err, CodeHostAlreadyConnected) + + first := mustConnect(t, store, viewerInput(room.RoomID, "first", room.ViewerGrant)) + second := mustConnect(t, store, viewerInput(room.RoomID, "second", room.ViewerGrant)) + _, err = store.ConnectParticipant(viewerInput(room.RoomID, "third", room.ViewerGrant)) + expectCode(t, err, CodeRoomFull) + if peerIDs := store.GetViewerPeerIDs(room.RoomID); len(peerIDs) != 2 || + peerIDs[0] != first.PeerID || peerIDs[1] != second.PeerID { + t.Fatalf("viewer order = %v", peerIDs) + } + + // A viewer returning under the same client ID keeps its peer ID and its + // place in the insertion order, which viewer presence is ordered by. + returning := mustConnect(t, store, ConnectParticipantInput{ + RoomID: room.RoomID, + Role: protocol.RoleViewer, + ViewerGrant: room.ViewerGrant, + ClientID: "viewer-first", + SessionID: "first-again", + }) + if returning.PeerID != first.PeerID || returning.ReplacedSessionID != "first" { + t.Fatalf("returning viewer = %+v", returning) + } + if peerIDs := store.GetViewerPeerIDs(room.RoomID); len(peerIDs) != 2 || + peerIDs[0] != first.PeerID || peerIDs[1] != second.PeerID { + t.Fatalf("viewer order after the return = %v", peerIDs) + } + + if _, err := store.DisconnectParticipant(room.RoomID, second.PeerID, "second"); err != nil { + t.Fatalf("DisconnectParticipant: %v", err) + } + connected := store.GetConnectedViewers(room.RoomID) + if len(connected) != 1 || connected[0].PeerID != first.PeerID || + connected[0].SessionID != "first-again" { + t.Fatalf("connected viewers = %+v", connected) + } + if store.RemoveDisconnectedViewer(room.RoomID, first.PeerID) { + t.Fatal("removed a connected viewer") + } + if !store.RemoveDisconnectedViewer(room.RoomID, second.PeerID) { + t.Fatal("did not remove the disconnected viewer") + } + if peerIDs := store.GetViewerPeerIDs(room.RoomID); len(peerIDs) != 1 || + peerIDs[0] != first.PeerID { + t.Fatalf("viewer order after the removal = %v", peerIDs) + } + + for _, testCase := range []struct { + roomID, grant string + want bool + }{ + {room.RoomID, room.ViewerGrant, true}, + {room.RoomID, "", false}, + {room.RoomID, "AAAAAAAAAAAAAAAAAAAAAA", false}, + {"9999", room.ViewerGrant, false}, + } { + mayEnter, err := store.ViewerGrantMayEnter(testCase.roomID, testCase.grant) + if err != nil || mayEnter != testCase.want { + t.Fatalf("ViewerGrantMayEnter(%q, %q) = %v, %v", + testCase.roomID, testCase.grant, mayEnter, err) + } + } +} + +// TestHostChangeChecksIdentityAfterTheDerivation pins the order of the checks +// the TypeScript ran after its KDF await: the second getHostManagedRoom first, +// then the room identity together with a cancelled derivation, then busy. +func TestHostChangeChecksIdentityAfterTheDerivation(t *testing.T) { + // A constant random source gives a recreated room the same Host token, so + // the identity comparison is reachable instead of being shadowed by the + // token check. + store, testClock := newStore(t, Options{ + MaxRooms: 3, + Random: constantRandom(func(int) byte { return 7 }), + }) + room := createRoom(t, store, protocol.CodeEntryOpen, "", "4321") + current, err := store.HostManagedRoom(room.RoomID, room.HostToken) + if err != nil { + t.Fatalf("HostManagedRoom: %v", err) + } + if !store.HostStillOwnsRoom(room.RoomID, room.HostToken, current) { + t.Fatal("HostStillOwnsRoom rejected the unchanged room") + } + + if _, err := store.AbandonRoom(room.RoomID); err != nil { + t.Fatalf("AbandonRoom: %v", err) + } + if store.HostStillOwnsRoom(room.RoomID, room.HostToken, current) { + t.Fatal("HostStillOwnsRoom accepted an abandoned room") + } + _, err = store.SetViewerPassword(room.RoomID, room.HostToken, nil, nil, current) + expectCode(t, err, CodeInvalidToken) + + again := createRoom(t, store, protocol.CodeEntryOpen, "", "4321") + if again.HostToken != room.HostToken { + t.Fatal("the constant random source did not repeat the Host token") + } + _, err = store.SetViewerPassword(again.RoomID, again.HostToken, nil, nil, current) + expectCode(t, err, CodeRoomAccessDenied) + + incarnation, err := store.HostManagedRoom(again.RoomID, again.HostToken) + if err != nil { + t.Fatalf("HostManagedRoom: %v", err) + } + testClock.nowMs = 5_000 + _, err = store.SetViewerPassword(again.RoomID, again.HostToken, nil, nil, incarnation) + expectCode(t, err, CodeRoomExpired) + // An expired room outranks a saturated gate, as the TypeScript order did. + _, err = store.SetViewerPassword( + again.RoomID, again.HostToken, nil, codeError(CodeRoomBusy), incarnation) + expectCode(t, err, CodeRoomExpired) + + testClock.nowMs = 0 + _, err = store.SetViewerPassword( + again.RoomID, again.HostToken, nil, errDerivationCancelled, incarnation) + expectCode(t, err, CodeRoomAccessDenied) + _, err = store.SetViewerPassword( + again.RoomID, again.HostToken, nil, codeError(CodeRoomBusy), incarnation) + expectCode(t, err, CodeRoomBusy) + _, err = store.ReplaceRoom(again.RoomID, again.HostToken, protocol.CodeEntryOpen, + nil, errDerivationCancelled, incarnation) + expectCode(t, err, CodeRoomAccessDenied) +} + +// TestPasswordLoginRechecksTheRoomAfterTheDerivation covers the guard chain the +// TypeScript ran after its scrypt await: a vanished room answers ROOM_NOT_FOUND, +// a replaced room and a refusing mayConnect answer INVALID_TOKEN, and so does a +// full room, whose ROOM_FULL must not leak to the client. +func TestPasswordLoginRechecksTheRoomAfterTheDerivation(t *testing.T) { + // A constant random source repeats the salt, so a recreated room holds the + // same material and only the room identity separates the two incarnations. + store, _ := newStore(t, Options{ + MaxRooms: 3, + Random: constantRandom(func(int) byte { return 7 }), + }) + room := createRoom(t, store, protocol.CodeEntryPrivate, "room-password", "4321") + input := ConnectViewerWithPasswordInput{ + RoomID: room.RoomID, + Password: "room-password", + ClientID: "password-viewer", + SessionID: "password-session", + } + alwaysConnect := func() bool { return true } + + salt, expectedMaterial, reference, err := store.ViewerPasswordChallenge(input.RoomID) + if err != nil { + t.Fatalf("ViewerPasswordChallenge: %v", err) + } + derived, err := store.DeriveViewerPassword(input.Password, salt, nil) + if err != nil { + t.Fatalf("DeriveViewerPassword: %v", err) + } + if _, err := store.AbandonRoom(room.RoomID); err != nil { + t.Fatalf("AbandonRoom: %v", err) + } + _, err = store.ConnectViewerWithPassword( + input, derived, expectedMaterial, reference, alwaysConnect) + expectCode(t, err, CodeRoomNotFound) + + createRoom(t, store, protocol.CodeEntryPrivate, "room-password", "4321") + _, err = store.ConnectViewerWithPassword( + input, derived, expectedMaterial, reference, alwaysConnect) + expectCode(t, err, CodeInvalidToken) + + salt, expectedMaterial, reference, err = store.ViewerPasswordChallenge(input.RoomID) + if err != nil { + t.Fatalf("ViewerPasswordChallenge: %v", err) + } + derived, err = store.DeriveViewerPassword(input.Password, salt, nil) + if err != nil { + t.Fatalf("DeriveViewerPassword: %v", err) + } + _, err = store.ConnectViewerWithPassword( + input, derived, expectedMaterial, reference, func() bool { return false }) + expectCode(t, err, CodeInvalidToken) +} + +// TestPasswordLoginRequiresAnInitializedStore pins the first check of +// connectViewerWithPassword: `ensureInitialized()` ran before the password was +// validated and before any derivation, so an uninitialized store answers the +// initialization error at the challenge, not INVALID_TOKEN after a KDF. +func TestPasswordLoginRequiresAnInitializedStore(t *testing.T) { + database, err := NewDatabase(databasePath(t)) + if err != nil { + t.Fatalf("NewDatabase: %v", err) + } + store, err := New(Options{LeaseMs: 1_000, MaxRooms: 2, MaxViewersPerRoom: 2, Database: database}) + if err != nil { + t.Fatalf("New: %v", err) + } + _, _, _, err = store.ViewerPasswordChallenge("1234") + if err == nil || err.Error() != "RoomStore stable authority is not initialized" { + t.Fatalf("ViewerPasswordChallenge before Initialize = %v", err) + } +} + +// TestPasswordLoginHidesAFullRoom keeps ROOM_FULL from leaking to a Viewer that +// authenticated with the room password. +func TestPasswordLoginHidesAFullRoom(t *testing.T) { + store, _ := newStore(t, Options{MaxViewersPerRoom: 2}) + room := createRoom(t, store, protocol.CodeEntryPrivate, "room-password", "") + mustConnect(t, store, viewerInput(room.RoomID, "first", room.ViewerGrant)) + mustConnect(t, store, viewerInput(room.RoomID, "second", room.ViewerGrant)) + + _, err := tryConnectViewerWithPassword(store, ConnectViewerWithPasswordInput{ + RoomID: room.RoomID, + Password: "room-password", + ClientID: "password-viewer", + SessionID: "password-session", + }, nil) + expectCode(t, err, CodeInvalidToken) +} + +func TestCreateRoomReportsACancelledDerivation(t *testing.T) { + store, _ := newStore(t, Options{}) + lease, err := store.BeginCreateRoom() + if err != nil { + t.Fatalf("BeginCreateRoom: %v", err) + } + material, derived := store.DeriveViewerPasswordMaterial( + "room-password", func() bool { return false }) + if material != nil || !errors.Is(derived, errDerivationCancelled) { + t.Fatalf("derive = %v, %v", material, derived) + } + _, err = store.CreateRoom(protocol.CodeEntryPrivate, material, derived, "", lease) + if err == nil || err.Error() != "Room creation password derivation was cancelled" { + t.Fatalf("CreateRoom = %v", err) + } + if store.Size() != 0 { + t.Fatalf("size = %d, want 0", store.Size()) + } +} + +func TestRejectsAMalformedViewerPassword(t *testing.T) { + store, _ := newStore(t, Options{}) + for _, password := range []string{"", "with space", strings.Repeat("a", 65)} { + material, derived := store.DeriveViewerPasswordMaterial(password, nil) + if material != nil || !hasCode(derived, CodeInvalidToken) { + t.Fatalf("derive(%q) = %v, %v", password, material, derived) + } + if _, err := store.DeriveViewerPassword( + password, make([]byte, viewerPasswordSaltBytes), nil); !hasCode(err, CodeInvalidToken) { + t.Fatalf("login derive(%q) = %v", password, err) + } + } +} + +// TestUniformIndexRejectionLimit covers the D11 formula: (MaxUint64%n + 1) % n +// must not wrap where 2^64 % n is 0, which is every power-of-two free count. +func TestUniformIndexRejectionLimit(t *testing.T) { + allOnes := bytes.Repeat([]byte{0xff}, 8) + zeros := make([]byte, 8) + for _, testCase := range []struct { + name string + count int + wantIndex int + wantDraws int + }{ + {"power of two accepts the top value", 8_192, 8_191, 1}, + {"one free code accepts the top value", 1, 0, 1}, + {"three free codes reject the top value", 3, 0, 2}, + } { + t.Run(testCase.name, func(t *testing.T) { + draws := 0 + random := func(size int) []byte { + draws++ + if draws == 1 { + return allOnes + } + return zeros + } + index, err := uniformIndex(testCase.count, random) + if err != nil { + t.Fatalf("uniformIndex: %v", err) + } + if index != testCase.wantIndex { + t.Fatalf("index = %d, want %d", index, testCase.wantIndex) + } + if draws != testCase.wantDraws { + t.Fatalf("draws = %d, want %d", draws, testCase.wantDraws) + } + }) + } +} + +func TestUniformIndexRejectsAShortRandomSource(t *testing.T) { + _, err := uniformIndex(9_000, func(int) []byte { return make([]byte, 7) }) + if err == nil || !strings.Contains(err.Error(), "must return 8 bytes") { + t.Fatalf("uniformIndex: %v", err) + } +} diff --git a/internal/server/room/testdata/node-rooms.db b/internal/server/room/testdata/node-rooms.db new file mode 100644 index 0000000..3303a6f Binary files /dev/null and b/internal/server/room/testdata/node-rooms.db differ diff --git a/internal/server/route/api.go b/internal/server/route/api.go new file mode 100644 index 0000000..ea887bd --- /dev/null +++ b/internal/server/route/api.go @@ -0,0 +1,519 @@ +package route + +import ( + "github.com/TNTcraftHIM/Screener/internal/server/ordered" + "github.com/TNTcraftHIM/Screener/internal/server/protocol" +) + +// Resource is the caller-owned SFU/overlap allocation the controller stores, +// moves between collections and compares by pointer identity (TS: the bare +// Resource type parameter, always used through JS Set semantics). The +// controller never reads its fields; they exist for the router and tests. +type Resource struct { + Kind ResourceKind + // EndpointPeerID is set for ResourceOverlap. + EndpointPeerID string + // Fence fields (RoomID, ShareGeneration, PublicationGeneration) are set + // for sfu-publication and sfu-subscription; ViewerPeerID only for the + // subscription. All are opaque IDs. + RoomID string + ShareGeneration string + PublicationGeneration string + ViewerPeerID string + // Released is flipped by the router once the allocation is freed. + Released bool +} + +// ResourceKind is the RouteResource discriminator of hybrid-media-router.ts. +type ResourceKind string + +// Resource kinds. +const ( + ResourceOverlap ResourceKind = "overlap" + ResourceSfuSubscription ResourceKind = "sfu-subscription" + ResourceSfuPublication ResourceKind = "sfu-publication" +) + +// UpstreamKind discriminates CandidateTuple, CommittedEdge, committedEdgeSeed +// and QualityUpstream (TS kind: "peer" | "sfu"). +type UpstreamKind string + +// Upstream kinds. +const ( + UpstreamPeer UpstreamKind = "peer" + UpstreamSfu UpstreamKind = "sfu" +) + +// Transport is the edge transport (TS transport: "direct" | "sfu"). +type Transport string + +// Transports. +const ( + TransportDirect Transport = "direct" + TransportSfu Transport = "sfu" +) + +// PublicationPlan is the SFU tuple action (TS publication: "reuse" | "create" | "replace"). +type PublicationPlan string + +// Publication plans. +const ( + PublicationReuse PublicationPlan = "reuse" + PublicationCreate PublicationPlan = "create" + PublicationReplace PublicationPlan = "replace" +) + +// ReservationKind discriminates CandidateReservation. +type ReservationKind string + +// Reservation kinds. +const ( + ReservationDirect ReservationKind = "direct" + ReservationSfuReuse ReservationKind = "sfu-reuse" + ReservationSfuCreate ReservationKind = "sfu-create" +) + +// RetirementKind discriminates EndpointRetirement. +type RetirementKind string + +// Retirement kinds. +const ( + RetireEdge RetirementKind = "edge" + RetirePublication RetirementKind = "publication" +) + +// TransitionKind discriminates EndpointTransition. +type TransitionKind string + +// Transition kinds. +const ( + TransitionNone TransitionKind = "none" + TransitionOverlap TransitionKind = "overlap" + TransitionBoundedGap TransitionKind = "bounded-gap" +) + +// SenderQualityState is the sender-side health sample state +// (TS "unknown" | "healthy" | "degraded"). +type SenderQualityState string + +// Sender quality states. +const ( + SenderQualityUnknown SenderQualityState = "unknown" + SenderQualityHealthy SenderQualityState = "healthy" + SenderQualityDegraded SenderQualityState = "degraded" +) + +// DemandReason mirrors protocol RouteDemandReason. +type DemandReason string + +// Demand reasons. +const ( + DemandJoin DemandReason = "join" + DemandEdgeUnavailable DemandReason = "edge-unavailable" + DemandParentDeparted DemandReason = "parent-departed" + DemandCapacityReduction DemandReason = "capacity-reduction" + DemandSfuBootstrap DemandReason = "sfu-bootstrap" + DemandDirectConvergence DemandReason = "direct-convergence" + DemandQualityConvergence DemandReason = "quality-convergence" + DemandRootConvergence DemandReason = "root-convergence" +) + +// FinalRoute mirrors protocol RouteDiagnosticFinalRoute. +type FinalRoute string + +// Final routes. +const ( + FinalRouteDirect FinalRoute = "direct" + FinalRouteSfu FinalRoute = "sfu" + FinalRouteWaiting FinalRoute = "waiting" + FinalRouteFailed FinalRoute = "failed" +) + +// RejectionBucket mirrors protocol RouteDiagnosticRejectionBucket. +type RejectionBucket string + +// Rejection buckets. +const ( + RejectionNone RejectionBucket = "none" + RejectionStale RejectionBucket = "stale" + RejectionEndpointCapacity RejectionBucket = "endpoint-capacity" + RejectionSfuAdmission RejectionBucket = "sfu-admission" + RejectionCandidateFailed RejectionBucket = "candidate-failed" + RejectionFirstFrameTimeout RejectionBucket = "first-frame-timeout" + RejectionOperationDeadline RejectionBucket = "operation-deadline" + RejectionAborted RejectionBucket = "aborted" +) + +// RouteQualityEvidenceResult is the ObserveQualityEvidence outcome. +type RouteQualityEvidenceResult string + +// Quality evidence outcomes. +const ( + QualityEvidenceRejected RouteQualityEvidenceResult = "rejected" + QualityEvidenceAccepted RouteQualityEvidenceResult = "accepted" + QualityEvidenceObserved RouteQualityEvidenceResult = "observed" +) + +// CandidateTuple is one route candidate: a direct edge from ParentPeerID +// (Kind UpstreamPeer, Transport always TransportDirect, Regenerate = rebuild +// the same edge with a fresh connection) or an SFU edge (Kind UpstreamSfu, +// Publication set). TS: CandidateTuple. +type CandidateTuple struct { + Kind UpstreamKind + ParentPeerID string + Transport Transport + Regenerate bool + Publication PublicationPlan +} + +// CandidateReservation is what the caller allocated before +// BeginCurrentCandidate. Edge/Publication are set per Kind +// (direct: neither; sfu-reuse: Edge, Borrowed when it is another holder's +// live subscription and must not be released; sfu-create: Edge and +// Publication). Overlap is optional for every kind. TS: CandidateReservation. +type CandidateReservation struct { + Kind ReservationKind + Edge *Resource + Publication *Resource + Borrowed bool + Overlap *Resource +} + +// EndpointRetirement is the producer retirement a bounded-gap transition +// requires: an existing direct edge (RetireEdge: Child*/Parent*/Transport/ +// ConnectionID) or the Host publication (RetirePublication: HostSessionID/ +// Generation/ConnectionID). TS: EndpointRetirement. +type EndpointRetirement struct { + Kind RetirementKind + ChildPeerID string + ChildSessionID string + ParentPeerID string + ParentSessionID string + Transport Transport + ConnectionID string + HostSessionID string + Generation string +} + +// EndpointTransition says how the producer's copy budget absorbs the +// candidate. ProducerPeerID is nil only for TransitionNone on an sfu/reuse +// plan (TS omitted it there and endpointTransitionEquals compares it). +// Retire is set only for TransitionBoundedGap. TS: EndpointTransition. +type EndpointTransition struct { + Kind TransitionKind + ProducerPeerID *string + Retire *EndpointRetirement +} + +// CandidatePlan is a tuple with its endpoint transition. TS: CandidatePlan. +type CandidatePlan struct { + Tuple CandidateTuple + EndpointTransition EndpointTransition +} + +// CommittedEdge is a child's one upstream edge in the committed graph. +// Peer edges (Kind UpstreamPeer) set ParentPeerID/ParentSessionID and leave +// Resource nil; SFU edges (Kind UpstreamSfu) set PublicationGeneration and +// Resource. TS: CommittedEdge. +type CommittedEdge struct { + Kind UpstreamKind + ChildSessionID string + ParentPeerID string + ParentSessionID string + PublicationGeneration string + Transport Transport + ConnectionID string + Usable bool + PhysicalActive bool + Resource *Resource +} + +// committedEdgeSeed is CommittedEdge without the session fields the +// controller derives at hydrateEdge. TS: CommittedEdgeSeed, which like +// hydrateEdge only ever had test callers. +type committedEdgeSeed struct { + Kind UpstreamKind + ParentPeerID string + PublicationGeneration string + Transport Transport + ConnectionID string + Usable bool + PhysicalActive bool + Resource *Resource +} + +// HostPublication is the single live SFU ingress. TS: HostPublication. +type HostPublication struct { + Generation string + HostSessionID string + ConnectionID string + Usable bool + PhysicalActive bool + Resource *Resource +} + +// ConnectionAttemptProgress is the NAT-prediction attempt counter +// (Total is always 3). TS: ConnectionAttemptProgress. +type ConnectionAttemptProgress struct { + Current int + Total int +} + +// CurrentAttempt is OperationSnapshot.current: the live candidate attempt. +// ConnectionAttempt is nil when the attempt has no NAT opportunity. +type CurrentAttempt struct { + Tuple CandidateTuple + Revision int64 + ConnectionID string + ConnectionAttempt *ConnectionAttemptProgress +} + +// OperationSnapshot is a deep copy of the in-flight operation plus the +// derived WakeAtMs. Current is nil without a live attempt. TS: OperationSnapshot. +type OperationSnapshot struct { + ChildPeerID string + ChildSessionID string + DemandPeerID string + Reason DemandReason + BaseRevision int64 + FactVersion int64 + Candidates []CandidatePlan + Cursor int + DeadlineAtMs int64 + WakeAtMs int64 + Current *CurrentAttempt +} + +// RouteSnapshot is the committed graph as the router reads it. +// UpstreamByViewer is a fresh insertion-ordered copy (child peerId -> edge); +// HostPublication is a shallow copy or nil; Operation is nil when idle. +// TS: RouteSnapshot. +type RouteSnapshot struct { + Revision int64 + Paused bool + FactVersion int64 + UpstreamByViewer *ordered.Map[string, CommittedEdge] + HostPublication *HostPublication + Operation *OperationSnapshot +} + +// ReconcileResult is the scheduler outcome. TS: ReconcileResult. +type ReconcileResult struct { + Operation *OperationSnapshot + RemovedPeerIDs []string + FailedPeerIDs []string + Released []*Resource +} + +// SettleResult is the outcome of a candidate settlement call. Committed is +// true only when CandidateReady committed the attempt (TS: optional +// committed, read as truthy by the router). TS: SettleResult. +type SettleResult struct { + Accepted bool + Committed bool + FailedPeerIDs []string + ActiveRevision int64 + Released []*Resource +} + +// SenderQualityEvidenceResult is the outcome of the two sender-side +// observers. TS declares it separately with a required committed flag; the +// Go shape is identical to SettleResult. +type SenderQualityEvidenceResult = SettleResult + +// BeginResult is the outcome of the cursor-guarded candidate calls. +// TS: BeginResult. +type BeginResult struct { + Accepted bool + Operation *OperationSnapshot + FailedPeerIDs []string + Released []*Resource +} + +// ParticipantInput describes a connected participant. TS: ParticipantInput. +type ParticipantInput struct { + PeerID string + Role protocol.Role + SessionID string + EffectiveDownstreamCapacity int +} + +// EdgeGuard identifies one committed edge exactly. ParentSessionID is only +// compared for peer edges; callers pass the edge's own ParentSessionID for +// those and leave it empty for SFU edges. TS: EdgeGuard. +type EdgeGuard struct { + ChildPeerID string + ChildSessionID string + RouteRevision int64 + ConnectionID string + ParentSessionID string +} + +// ParentEdgeGuard identifies a direct edge from the parent's side. +// TS: the inline input of invalidateDirectEdgeFromParent. +type ParentEdgeGuard struct { + ParentPeerID string + ParentSessionID string + RouteRevision int64 + ConnectionID string +} + +// PublicationGuard identifies the Host publication exactly. TS: the inline +// guard of invalidateHostPublication and retireHostPublication. +type PublicationGuard struct { + HostSessionID string + RouteRevision int64 + Generation string + ConnectionID string +} + +// AdoptDirectConnectionInput is an EdgeGuard plus the replacement +// connection. TS: EdgeGuard & { newConnectionId }. +type AdoptDirectConnectionInput struct { + EdgeGuard + NewConnectionID string +} + +// CandidateGuard identifies the live attempt exactly. TS: CandidateGuard. +type CandidateGuard struct { + ChildPeerID string + ChildSessionID string + Revision int64 + ConnectionID string +} + +// CandidateCursorGuard identifies one candidate slot of the current +// operation: it must still be at Cursor with an equal Plan, built at +// FactVersion on BaseRevision. TS: CandidateCursorGuard. +type CandidateCursorGuard struct { + ChildPeerID string + ChildSessionID string + BaseRevision int64 + FactVersion int64 + Cursor int + Plan CandidatePlan +} + +// BeginInput is the BeginCurrentCandidate input. PublicationGeneration and +// PublicationConnectionID are required for sfu create/replace tuples; +// HostSessionID is required for every SFU tuple (empty = TS undefined, +// rejected the same way). TS: the inline input of beginCurrentCandidate. +type BeginInput struct { + Guard CandidateCursorGuard + NowMs int64 + ConnectionID string + Reservation CandidateReservation + PublicationGeneration string + PublicationConnectionID string + HostSessionID string +} + +// CandidateProof carries the Viewer's one-shot relative quality approval. +// TS: the proof parameter of candidateReady ({ relativeQualityApproved? }). +type CandidateProof struct { + RelativeQualityApproved bool +} + +// QualityUpstream names the upstream a receiver-side window was measured +// on: PeerID is set only for UpstreamPeer. TS: RouteQualityEvidenceInput.upstream. +type QualityUpstream struct { + Kind UpstreamKind + PeerID string +} + +// RouteQualityEvidenceInput is one receiver-side quality window. Only +// FramesDecodedDelta, FreezeCountDelta, FreezeDurationMsDelta, +// PauseCountDelta and PauseDurationMsDelta of Metrics are read. +// TS: RouteQualityEvidenceInput. +type RouteQualityEvidenceInput struct { + ChildPeerID string + ChildSessionID string + RouteRevision int64 + ConnectionID string + Upstream QualityUpstream + PresentationEpoch int64 + WindowMs int64 + Metrics protocol.ViewerQualityEvidenceMetrics + AcceptedAtMs int64 +} + +// SenderQualityEvidenceInput is one sender-side health sample from a parent +// about one child. SenderIdentity and SampleTimestampMs are nullable. +// TS: SenderQualityEvidenceInput. +type SenderQualityEvidenceInput struct { + ParentPeerID string + ParentSessionID string + ChildPeerID string + RouteRevision int64 + ConnectionID string + SenderIdentity *string + SampleTimestampMs *int64 + State SenderQualityState + AcceptedAtMs int64 +} + +// SfuPublisherQualityEvidenceInput is one Host->SFU ingress health sample. +// TS: SfuPublisherQualityEvidenceInput. +type SfuPublisherQualityEvidenceInput struct { + HostPeerID string + HostSessionID string + PublicationGeneration string + RouteRevision int64 + State SenderQualityState + SampleTimestampMs *int64 + AcceptedAtMs int64 +} + +// Options configures a Controller. DebugRoomID empty disables the sanitised +// route debug events. TS: ControllerOptions. +type Options struct { + HostPeerID string + DebugRoomID string + EndpointMediaCopyCapacity int + OperationTimeoutMs int64 + SfuEnabled bool + QualityConvergenceEnabled bool + NatPredictionEnabled bool +} + +// Controller ports RoomRouteController. It never locks: the caller holds +// signal.Server.mu around every call. The public methods live in the files +// named after the TS helper clusters (controller.go, operation.go, +// quality.go, ...); every signature below the types is unchanged. +type Controller struct { + // hostPeerID is its own mutable field because RebindHostIdentity + // reassigns it (TS: options.hostPeerId = peerId at line 660); the other + // options are copied once and never change. + hostPeerID string + debugRoomID string + endpointMediaCopyCapacity int + operationTimeoutMs int64 + sfuEnabled bool + qualityConvergenceEnabled bool + natPredictionEnabled bool + + // Insertion order is load-bearing for participants, upstreamByViewer and + // directContinuations (map §4.1-4.3); the opportunity ledgers and the + // retiring set are ordered for the same delete-while-iterating semantics. + participants ordered.Map[string, *participant] + upstreamByViewer ordered.Map[string, *CommittedEdge] + hostPublication *HostPublication + operation *operation + revision int64 + latestRevision int64 + factVersion int64 + nextJoinOrder int64 + paused bool + routeTimings map[string]*routeTimingRecord + qualityObservations map[string]*routeQualityObservation + senderQualityObservations map[string]*senderQualityObservation + senderQualityBaselines map[string]bool // tri-state: absent / false / true (read with comma-ok) + senderQualityRegenerationBlocks map[string]senderQualityIdentity + sfuPublisherQualityObservation *sfuPublisherQualityObservation + qualityBaselinesPending map[string]struct{} + directContinuations ordered.Map[string, *directContinuation] + retiringPublicationGenerations ordered.Map[string, struct{}] + sfuBootstrapIntent *sfuBootstrapIntent + consumedSfuBootstrapOpportunities ordered.Map[string, int] + rootConvergenceRootPeerID string // "" = TS undefined +} diff --git a/internal/server/route/candidates.go b/internal/server/route/candidates.go new file mode 100644 index 0000000..5aee90a --- /dev/null +++ b/internal/server/route/candidates.go @@ -0,0 +1,562 @@ +package route + +import ( + "errors" + "slices" + "sort" + + "github.com/TNTcraftHIM/Screener/internal/server/protocol" +) + +// buildCandidates ports 2767: the full plan list filtered by the +// availability opportunity ledger. +func (c *Controller) buildCandidates(childPeerID string, sfuOnly bool) []CandidatePlan { + plans := c.buildCandidatePlans(childPeerID, sfuOnly, true) + out := make([]CandidatePlan, 0, len(plans)) + for _, plan := range plans { + if c.candidateOpportunityAvailable(childPeerID, plan) { + out = append(out, plan) + } + } + return out +} + +// buildCandidatePlans ports 2777: eligible parents sorted by (untried +// attempts when preferUntried) then depth, remaining capacity descending, +// stablePairRank, compareParticipant; the SFU tuple is spliced into index 1 +// (§4.4 #28) and the list is deduped by tupleKey (§4.4 #29). +func (c *Controller) buildCandidatePlans(childPeerID string, sfuOnly, preferUntried bool) []CandidatePlan { + descendants := c.descendantsOf(childPeerID) + parents := []*participant{} + for _, parent := range c.participants.Values() { + if parent.sessionID != "" && !parent.departureConfirmed && parent.peerID != childPeerID && + !slices.Contains(descendants, parent.peerID) && c.sourceUsable(parent.peerID) && c.targetFits(parent.peerID, childPeerID) { + parents = append(parents, parent) + } + } + sort.SliceStable(parents, func(i, j int) bool { + left, right := parents[i], parents[j] + if preferUntried { + if d := c.peerAttemptsStarted(childPeerID, left.peerID) - c.peerAttemptsStarted(childPeerID, right.peerID); d != 0 { + return d < 0 + } + } + if d := c.depth(left.peerID) - c.depth(right.peerID); d != 0 { + return d < 0 + } + if d := c.remaining(right.peerID, childPeerID) - c.remaining(left.peerID, childPeerID); d != 0 { + return d < 0 + } + if d := stablePairRank(childPeerID, left.peerID) - stablePairRank(childPeerID, right.peerID); d != 0 { + return d < 0 + } + return compareParticipant(left, right) < 0 + }) + directCandidates := make([]CandidateTuple, 0, len(parents)) + for _, parent := range parents { + directCandidates = append(directCandidates, CandidateTuple{Kind: UpstreamPeer, ParentPeerID: parent.peerID, Transport: TransportDirect}) + } + var sfuCandidate *CandidateTuple + if c.sfuEnabled { + if c.hostPublication != nil && c.hostPublication.Usable && c.hostPublication.PhysicalActive { + sfuCandidate = &CandidateTuple{Kind: UpstreamSfu, Publication: PublicationReuse} + } else if c.hostPublication != nil || c.publicationFits(childPeerID, false) { + publication := PublicationCreate + if c.hostPublication != nil { + publication = PublicationReplace + } + sfuCandidate = &CandidateTuple{Kind: UpstreamSfu, Publication: publication} + } + } + var candidates []CandidateTuple + switch { + case sfuOnly: + if sfuCandidate != nil { + candidates = []CandidateTuple{*sfuCandidate} + } + case sfuCandidate != nil && len(directCandidates) > 0: + candidates = append(candidates, directCandidates[0], *sfuCandidate) + candidates = append(candidates, directCandidates[1:]...) + case sfuCandidate != nil: + candidates = []CandidateTuple{*sfuCandidate} + default: + candidates = directCandidates + } + seen := make(map[string]struct{}, len(candidates)) + plans := make([]CandidatePlan, 0, len(candidates)) + for _, tuple := range candidates { + key := tupleKey(tuple) + if _, duplicate := seen[key]; duplicate { + continue + } + seen[key] = struct{}{} + plan := c.planCandidate(childPeerID, tuple) + if plan != nil && c.candidateValid(childPeerID, *plan, nil) { + plans = append(plans, *plan) + } + } + return plans +} + +// planCandidate ports 3947: decides none / overlap / bounded-gap from the +// producer's copy budget; nil when the producer is gone or over budget. +func (c *Controller) planCandidate(childPeerID string, tuple CandidateTuple) *CandidatePlan { + if tuple.Kind == UpstreamSfu && tuple.Publication == PublicationReuse { + // No producer on sfu/reuse (3949); endpointTransitionEquals compares it. + return &CandidatePlan{Tuple: tuple, EndpointTransition: EndpointTransition{Kind: TransitionNone}} + } + producerPeerID := c.hostPeerID + if tuple.Kind == UpstreamPeer { + producerPeerID = tuple.ParentPeerID + } + producer, _ := c.participants.Get(producerPeerID) + if producer == nil || producer.sessionID == "" || producer.departureConfirmed { + return nil + } + copies := c.physicalCopies(producerPeerID) + producer0 := producerPeerID + if copies+1 <= producer.effectiveDownstreamCapacity { + return &CandidatePlan{Tuple: tuple, EndpointTransition: EndpointTransition{Kind: TransitionNone, ProducerPeerID: &producer0}} + } + if copies+1 <= protocol.EndpointMediaCopyLimit(c.endpointMediaCopyCapacity, protocol.EndpointMediaCopyTransition) { + return &CandidatePlan{Tuple: tuple, EndpointTransition: EndpointTransition{Kind: TransitionOverlap, ProducerPeerID: &producer0}} + } + retire := c.retirementFor(childPeerID, producerPeerID, tuple) + if retire == nil || copies > producer.effectiveDownstreamCapacity { + return nil + } + return &CandidatePlan{Tuple: tuple, EndpointTransition: EndpointTransition{Kind: TransitionBoundedGap, ProducerPeerID: &producer0, Retire: retire}} +} + +// retirementFor ports 3969. +func (c *Controller) retirementFor(childPeerID, producerPeerID string, tuple CandidateTuple) *EndpointRetirement { + old, _ := c.upstreamByViewer.Get(childPeerID) + if old != nil && old.Kind == UpstreamPeer && old.ParentPeerID == producerPeerID && old.PhysicalActive { + return &EndpointRetirement{ + Kind: RetireEdge, + ChildPeerID: childPeerID, + ChildSessionID: old.ChildSessionID, + ParentPeerID: old.ParentPeerID, + ParentSessionID: old.ParentSessionID, + Transport: old.Transport, + ConnectionID: old.ConnectionID, + } + } + if producerPeerID != c.hostPeerID || c.hostPublication == nil || !c.hostPublication.PhysicalActive { + return nil + } + replacesPublication := tuple.Kind == UpstreamSfu && tuple.Publication == PublicationReplace + releasesLastPublication := tuple.Kind == UpstreamPeer && old != nil && old.Kind == UpstreamSfu && old.PhysicalActive && + c.sfuSubscriberCount() == 1 + if !replacesPublication && !releasesLastPublication { + return nil + } + return &EndpointRetirement{ + Kind: RetirePublication, + HostSessionID: c.hostPublication.HostSessionID, + Generation: c.hostPublication.Generation, + ConnectionID: c.hostPublication.ConnectionID, + } +} + +// candidateValid ports 3903; att is the live attempt when validating it. +func (c *Controller) candidateValid(childPeerID string, plan CandidatePlan, att *attempt) bool { + tuple := plan.Tuple + child, _ := c.participants.Get(childPeerID) + if child == nil || child.sessionID == "" || child.departureConfirmed || (att != nil && att.childSessionID != child.sessionID) { + return false + } + if tuple.Kind == UpstreamPeer { + parent, _ := c.participants.Get(tuple.ParentPeerID) + if parent == nil || parent.sessionID == "" || parent.departureConfirmed || (att != nil && parent.sessionID != att.parentSessionID) || + slices.Contains(c.descendantsOf(childPeerID), parent.peerID) || !c.sourceUsable(parent.peerID) || + !c.targetFits(parent.peerID, childPeerID) { + return false + } + } else { + if !c.sfuEnabled { + return false + } + if att != nil && att.hostSessionID != "" { + host, _ := c.participants.Get(c.hostPeerID) + if host == nil || !host.sessionIs(att.hostSessionID) { + return false + } + } + switch tuple.Publication { + case PublicationReuse: + if c.hostPublication == nil || !c.hostPublication.Usable || !c.hostPublication.PhysicalActive || + (att != nil && att.publicationGeneration != c.hostPublication.Generation) { + return false + } + case PublicationReplace: + if c.hostPublication == nil || c.hostPublication.Usable || !c.publicationFits(childPeerID, true) { + return false + } + default: + if c.hostPublication != nil || !c.publicationFits(childPeerID, false) { + return false + } + } + } + currentPlan := c.planCandidate(childPeerID, tuple) + return currentPlan != nil && endpointTransitionEquals(currentPlan.EndpointTransition, plan.EndpointTransition) +} + +// operationCandidateValid ports 3934. +func (c *Controller) operationCandidateValid(op *operation, plan CandidatePlan, att *attempt) bool { + return c.candidateValid(op.childPeerID, plan, att) && + ((att != nil && att.connectionAttempt != nil) || + !isAvailabilityOperation(op.reason) || + c.candidateOpportunityAvailable(op.childPeerID, plan)) +} + +// physicalCopies ports 3999. +func (c *Controller) physicalCopies(peerID string) int { + copies := 0 + for _, edge := range c.upstreamByViewer.Values() { + if edge.Kind == UpstreamPeer && edge.ParentPeerID == peerID && edge.PhysicalActive { + copies++ + } + } + if peerID == c.hostPeerID && c.hostPublication != nil && c.hostPublication.PhysicalActive { + copies++ + } + return copies +} + +// assertReservation ports 4006: panics on a reservation kind that does not +// match the tuple or a missing overlap for an overlap transition. +func assertReservation(tuple CandidateTuple, reservation CandidateReservation, overlap bool) { + expected := ReservationSfuCreate + switch { + case tuple.Kind == UpstreamPeer: + expected = ReservationDirect + case tuple.Publication == PublicationReuse: + expected = ReservationSfuReuse + } + if reservation.Kind != expected { + panic(errors.New("Candidate reservation kind does not match tuple")) + } + if overlap && reservation.Overlap == nil { + panic(errors.New("Candidate requires an endpoint overlap reservation")) + } +} + +// targetFits ports 4380; the parent must exist. +func (c *Controller) targetFits(parentPeerID, childPeerID string) bool { + parent, _ := c.participants.Get(parentPeerID) + edge, _ := c.upstreamByViewer.Get(childPeerID) + already := edge != nil && edge.Kind == UpstreamPeer && edge.ParentPeerID == parentPeerID && edge.PhysicalActive + releasesLastPublication := parent.role == protocol.RoleHost && edge != nil && edge.Kind == UpstreamSfu && edge.PhysicalActive && + c.sfuSubscriberCount() == 1 + slots := c.usedSlots(parentPeerID) + if releasesLastPublication { + slots-- + } + if !already { + slots++ + } + return slots <= parent.effectiveDownstreamCapacity +} + +// publicationFits ports 4390. +func (c *Controller) publicationFits(childPeerID string, replacing bool) bool { + host, _ := c.participants.Get(c.hostPeerID) + edge, _ := c.upstreamByViewer.Get(childPeerID) + releasesHost := edge != nil && edge.Kind == UpstreamPeer && edge.ParentPeerID == c.hostPeerID && edge.PhysicalActive + replacesPhysicalPublication := replacing && c.hostPublication != nil && c.hostPublication.PhysicalActive + nextSlots := c.usedSlots(c.hostPeerID) + if releasesHost { + nextSlots-- + } + if !replacesPhysicalPublication { + nextSlots++ + } + return host != nil && host.sessionID != "" && !host.departureConfirmed && nextSlots <= host.effectiveDownstreamCapacity +} + +// hostHasPublicationSlot ports 4400. +func (c *Controller) hostHasPublicationSlot() bool { + host, _ := c.participants.Get(c.hostPeerID) + return host != nil && c.usedSlots(c.hostPeerID)+1 <= host.effectiveDownstreamCapacity +} + +func (c *Controller) usedSlots(peerID string) int { + return c.physicalCopies(peerID) +} + +// remaining ports 4409 and may be negative; the parent must exist. +func (c *Controller) remaining(parentPeerID, childPeerID string) int64 { + parent, _ := c.participants.Get(parentPeerID) + edge, _ := c.upstreamByViewer.Get(childPeerID) + already := edge != nil && edge.Kind == UpstreamPeer && edge.ParentPeerID == parentPeerID && edge.PhysicalActive + value := int64(parent.effectiveDownstreamCapacity - c.usedSlots(parentPeerID)) + if !already { + value-- + } + return value +} + +// depth ports 4416; a cycle yields MAX_SAFE_INTEGER (not MaxInt64) so the +// comparator subtraction cannot overflow. +func (c *Controller) depth(peerID string) int64 { + if peerID == c.hostPeerID { + return 1 + } + depth := int64(1) + seen := make(map[string]struct{}) + for { + if _, visited := seen[peerID]; visited { + return maxSafeInteger + } + seen[peerID] = struct{}{} + edge, _ := c.upstreamByViewer.Get(peerID) + if edge == nil || edge.Kind == UpstreamSfu { + return depth + 1 + } + depth++ + if edge.ParentPeerID == c.hostPeerID { + return depth + } + peerID = edge.ParentPeerID + } +} + +// childrenOf ports 4352: direct peer children in upstreamByViewer order +// (§4.2 #17), the base order for every children-derived list. +func (c *Controller) childrenOf(parentPeerID string) []string { + children := []string{} + for childPeerID, edge := range c.upstreamByViewer.All() { + if edge.Kind == UpstreamPeer && edge.ParentPeerID == parentPeerID { + children = append(children, childPeerID) + } + } + return children +} + +// descendantsOf ports 4356: a DFS (the TS queue is popped from the end, +// §4.5 #49) returning the visit order without duplicates. +func (c *Controller) descendantsOf(peerID string) []string { + descendants := []string{} + seen := make(map[string]struct{}) + stack := []string{peerID} + for len(stack) > 0 { + last := stack[len(stack)-1] + stack = stack[:len(stack)-1] + for _, child := range c.childrenOf(last) { + if _, visited := seen[child]; visited { + continue + } + seen[child] = struct{}{} + descendants = append(descendants, child) + stack = append(stack, child) + } + } + return descendants +} + +// withSelf ports `this.descendantsOf(peerId).add(peerId)`: the peer itself +// is appended last unless the walk already reached it (§4.5 #50). +func withSelf(descendants []string, peerID string) []string { + if slices.Contains(descendants, peerID) { + return descendants + } + return append(descendants, peerID) +} + +// sourceUsable ports 4365: the whole upstream path is usable and active. +func (c *Controller) sourceUsable(peerID string) bool { + if peerID == c.hostPeerID { + return true + } + seen := make(map[string]struct{}) + for { + if _, visited := seen[peerID]; visited { + return false + } + seen[peerID] = struct{}{} + edge, _ := c.upstreamByViewer.Get(peerID) + if edge == nil || !edge.Usable || !edge.PhysicalActive { + return false + } + if edge.Kind == UpstreamSfu { + return c.hostPublication != nil && c.hostPublication.Usable && + c.hostPublication.PhysicalActive && c.hostPublication.Generation == edge.PublicationGeneration + } + if edge.ParentPeerID == c.hostPeerID { + return true + } + peerID = edge.ParentPeerID + } +} + +// participantBlocked ports 3862. +func (c *Controller) participantBlocked(current *participant) bool { + return current.availabilityExhausted && len(c.buildCandidates(current.peerID, false)) == 0 +} + +// availableViewers ports 3869: connected, non-departed viewers sorted by +// compareParticipant (a total order, so the map order does not leak). +func (c *Controller) availableViewers(includeBlocked bool) []*participant { + viewers := []*participant{} + for _, current := range c.participants.Values() { + if current.role == protocol.RoleViewer && current.sessionID != "" && !current.departureConfirmed && + (includeBlocked || !c.participantBlocked(current)) { + viewers = append(viewers, current) + } + } + sort.SliceStable(viewers, func(i, j int) bool { return compareParticipant(viewers[i], viewers[j]) < 0 }) + return viewers +} + +// overflowChildren ports 3876: per parent in participants order, the +// overflowing children newest-first (§4.1 #3). +func (c *Controller) overflowChildren() []string { + result := []string{} + for _, parent := range c.participants.Values() { + overflow := []string{} + for _, id := range c.overflowPeerChildren(parent.peerID) { + child, _ := c.participants.Get(id) + if child != nil && child.sessionID != "" && !child.departureConfirmed && !c.participantBlocked(child) { + overflow = append(overflow, id) + } + } + slices.Reverse(overflow) + result = append(result, overflow...) + } + return result +} + +// overflowPeerChildren ports 3889: the physically active direct children +// beyond the parent's capacity, oldest first kept. +func (c *Controller) overflowPeerChildren(parentPeerID string) []string { + parent, _ := c.participants.Get(parentPeerID) + if parent == nil { + return []string{} + } + publicationCopies := 0 + if parent.role == protocol.RoleHost && c.hostPublication != nil && c.hostPublication.PhysicalActive { + publicationCopies = 1 + } + capacity := max(0, parent.effectiveDownstreamCapacity-publicationCopies) + children := []string{} + for _, id := range c.childrenOf(parentPeerID) { + if edge, ok := c.upstreamByViewer.Get(id); ok && edge.Kind == UpstreamPeer && edge.PhysicalActive { + children = append(children, id) + } + } + sort.SliceStable(children, func(i, j int) bool { + left, _ := c.participants.Get(children[i]) + right, _ := c.participants.Get(children[j]) + return compareParticipant(left, right) < 0 + }) + if capacity >= len(children) { + return []string{} + } + return children[capacity:] +} + +// tupleKey ports 4979. +func tupleKey(tuple CandidateTuple) string { + if tuple.Kind == UpstreamPeer { + key := "peer:" + tuple.ParentPeerID + if tuple.Regenerate { + key += ":regenerate" + } + return key + } + return "sfu:" + string(tuple.Publication) +} + +// edgeTupleKey ports 4999. +func edgeTupleKey(edge *CommittedEdge) string { + if edge.Kind == UpstreamPeer { + return "peer:" + edge.ParentPeerID + } + return "sfu:reuse" +} + +// endpointTransitionRank ports 5003. +func endpointTransitionRank(transition EndpointTransition) int { + switch transition.Kind { + case TransitionNone: + return 0 + case TransitionOverlap: + return 1 + default: + return 2 + } +} + +// cloneCandidatePlan ports 5020 (deep copy of the retirement). +func cloneCandidatePlan(plan CandidatePlan) CandidatePlan { + clone := plan + if plan.EndpointTransition.ProducerPeerID != nil { + producer := *plan.EndpointTransition.ProducerPeerID + clone.EndpointTransition.ProducerPeerID = &producer + } + if plan.EndpointTransition.Retire != nil { + retire := *plan.EndpointTransition.Retire + clone.EndpointTransition.Retire = &retire + } + return clone +} + +// candidatePlanEquals ports 5029. +func candidatePlanEquals(left, right CandidatePlan) bool { + return tupleKey(left.Tuple) == tupleKey(right.Tuple) && + endpointTransitionEquals(left.EndpointTransition, right.EndpointTransition) +} + +func stringPtrEquals(left, right *string) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + return *left == *right +} + +// endpointTransitionEquals ports 5034; `undefined !== HOST` matters for the +// none producer, hence the pointer comparison. +func endpointTransitionEquals(left, right EndpointTransition) bool { + if left.Kind != right.Kind { + return false + } + if left.Kind == TransitionNone || left.Kind == TransitionOverlap { + return stringPtrEquals(left.ProducerPeerID, right.ProducerPeerID) + } + if left.Kind != TransitionBoundedGap || !stringPtrEquals(left.ProducerPeerID, right.ProducerPeerID) || + left.Retire == nil || right.Retire == nil || left.Retire.Kind != right.Retire.Kind { + return false + } + if left.Retire.Kind == RetirePublication { + return left.Retire.HostSessionID == right.Retire.HostSessionID && + left.Retire.Generation == right.Retire.Generation && + left.Retire.ConnectionID == right.Retire.ConnectionID + } + if left.Retire.Kind == RetireEdge { + return left.Retire.ChildPeerID == right.Retire.ChildPeerID && + left.Retire.ChildSessionID == right.Retire.ChildSessionID && + left.Retire.ParentPeerID == right.Retire.ParentPeerID && + left.Retire.ParentSessionID == right.Retire.ParentSessionID && + left.Retire.Transport == right.Retire.Transport && + left.Retire.ConnectionID == right.Retire.ConnectionID + } + return false +} + +// reservationResources ports 5060 in order: edge (never for a borrowed +// sfu-reuse edge, which belongs to another holder), publication, overlap. +func reservationResources(reservation CandidateReservation) []*Resource { + resources := []*Resource{} + if reservation.Kind != ReservationDirect && !(reservation.Kind == ReservationSfuReuse && reservation.Borrowed) { + resources = append(resources, reservation.Edge) + } + if reservation.Kind == ReservationSfuCreate { + resources = append(resources, reservation.Publication) + } + if reservation.Overlap != nil { + resources = append(resources, reservation.Overlap) + } + return resources +} diff --git a/internal/server/route/controller.go b/internal/server/route/controller.go new file mode 100644 index 0000000..a297587 --- /dev/null +++ b/internal/server/route/controller.go @@ -0,0 +1,995 @@ +package route + +import ( + "crypto/sha256" + "errors" + "math" + "sort" + "strings" + + "github.com/TNTcraftHIM/Screener/internal/server/ordered" + "github.com/TNTcraftHIM/Screener/internal/server/protocol" +) + +// File-local constants of room-route-controller.ts (lines 112-114). +const ( + maxDirectHeadStartMs int64 = 5_000 + maxNatConnectionAttempts = 3 + sfuOpportunityPrefix = "sfu\x00" + maxSafeInteger int64 = protocol.MaxSafeInteger + qualityEvidenceExpiryMs int64 = protocol.ViewerQualityEvidenceExpiryMs + persistentDegradedWindows = int64(protocol.PersistentNativeEdgeDegradedWindows) +) + +// candidateOpportunity is one consumed-ledger entry (TS CandidateOpportunity). +type candidateOpportunity struct { + rank int + startedAttempts int + exhausted bool +} + +// participant is a known peer (TS Participant). sessionID "" is the TS +// null: disconnected but in grace, or departed. Wire session IDs are +// validated opaque IDs, so an empty string never names a live session; +// bootstrapFailureReported and sfuFirstAtNextRoute are TS `true | undefined` +// and only ever compared to true, so a bool is exact. +type participant struct { + peerID string + role protocol.Role + sessionID string + departureConfirmed bool + joinOrder int64 + effectiveDownstreamCapacity int + availabilityExhausted bool + consumedCandidateOpportunities ordered.Map[string, candidateOpportunity] + bootstrapFailureReported bool + sfuFirstAtNextRoute bool +} + +// sessionIs ports `participant.sessionId === id`: a disconnected +// participant (TS null) never equals any session string. +func (p *participant) sessionIs(id string) bool { + return p.sessionID != "" && p.sessionID == id +} + +// senderQualityIdentity is TS SenderQualityIdentity. +type senderQualityIdentity struct { + childSessionID string + parentPeerID string + parentSessionID string + connectionID string + senderIdentity string +} + +// attempt is the live candidate (TS Attempt). The optional strings use "" +// for undefined (they are only ever set to non-empty values or compared +// after a truthiness check); the optional timestamps are pointers because +// 0 is a legal accepted/sample time and `!== undefined` is semantic. +type attempt struct { + tuple CandidateTuple + revision int64 + connectionID string + childSessionID string + parentSessionID string + hostSessionID string + publicationGeneration string + publicationConnectionID string + reservation CandidateReservation + startedAtMs int64 + transportConnected bool + mediaReady bool + connectionAttempt *ConnectionAttemptProgress + senderQualityState SenderQualityState // "" = undefined + senderQualityAcceptedAtMs *int64 + senderQualityConsecutiveHealthyWindows int64 // read only through `?? 0` + senderQualityConsecutiveDegradedWindows int64 // read only through `?? 0` + senderQualitySampleTimestampMs *int64 + senderQualityIdentity string // "" = undefined + relativeQualityApprovedAtMs *int64 +} + +// operation is the single in-flight route operation (TS ChildOperation). +type operation struct { + childPeerID string + childSessionID string + demandPeerID string + demandSessionID string + reason DemandReason + baseRevision int64 + candidates []CandidatePlan + cursor int + deadlineAtMs int64 + builtAtFactVersion int64 + deferredParentPeerIDs []string + peerAttemptStarted bool + qualitySource *senderQualityIdentity + current *attempt +} + +// planAt ports `operation.candidates[index]`: nil when out of range. It +// returns a copy because the candidate array is spliced in place and TS +// plan objects are never mutated after construction. +func (o *operation) planAt(index int) *CandidatePlan { + if index < 0 || index >= len(o.candidates) { + return nil + } + plan := o.candidates[index] + return &plan +} + +// planAtOrLast ports `operation.candidates[Math.min(cursor, length - 1)]`, +// which is undefined (nil) for an empty array (map R8). +func (o *operation) planAtOrLast() *CandidatePlan { + return o.planAt(min(o.cursor, len(o.candidates)-1)) +} + +// currentTuple ports `operation.current?.tuple ?? operation.candidates[operation.cursor]?.tuple`. +func (o *operation) currentTuple() *CandidateTuple { + if o.current != nil { + tuple := o.current.tuple + return &tuple + } + if plan := o.planAt(o.cursor); plan != nil { + return &plan.Tuple + } + return nil +} + +// directContinuation is TS DirectContinuation; parentPeerIDs is the retry +// order and is spliced in place. +type directContinuation struct { + childSessionID string + sfuConnectionID string + publicationGeneration string + parentPeerIDs []string +} + +type sfuBootstrapIntent struct { + demandPeerID string + demandSessionID string +} + +type sfuBootstrapCarrier struct { + demandPeerID string + demandSessionID string + carrierPeerID string +} + +// routeTimingRecord is TS RouteTimingRecord; the optional timestamps are +// pointers (undefined = never happened, distinct from 0). +type routeTimingRecord struct { + demandAtMs int64 + reason DemandReason + operationStartedAtMs *int64 + candidateStartedAtMs *int64 + firstDecodedFrameAtMs *int64 + finalAtMs *int64 + finalRoute FinalRoute + rejectionBucket RejectionBucket +} + +// routeQualityObservation is TS RouteQualityObservation. upstreamPeerID is +// "" for the sfu upstream (TS null); lastDecodedProgressAtMs nil is TS null. +type routeQualityObservation struct { + childSessionID string + upstreamKind UpstreamKind + upstreamPeerID string + connectionID string + presentationEpoch int64 + eligibleWindows int64 + eligibleDurationMs int64 + freezeWindows int64 + freezeCount int64 + freezeDurationMs int64 + pauseCount int64 + pauseDurationMs int64 + lastAcceptedAtMs int64 + lastDecodedProgressAtMs *int64 +} + +// senderQualityObservation is TS SenderQualityObservation. The consumed +// ledger is a pointer because `{...previous}` copies carry the same Map. +type senderQualityObservation struct { + childSessionID string + parentPeerID string + parentSessionID string + connectionID string + senderIdentity string + state SenderQualityState + consecutiveDegradedWindows int64 + lastSampleTimestampMs int64 + lastAcceptedAtMs int64 + consumedQualityCandidates *ordered.Map[string, int] +} + +type sfuPublisherQualityObservation struct { + hostSessionID string + publicationGeneration string + state SenderQualityState + consecutiveHealthyWindows int64 + lastSampleTimestampMs int64 + lastAcceptedAtMs int64 +} + +// resourceSet is a JS Set: pointer identity plus insertion order. +type resourceSet struct { + seen map[*Resource]struct{} + list []*Resource +} + +func (s *resourceSet) add(resources ...*Resource) { + if s.seen == nil { + s.seen = make(map[*Resource]struct{}) + } + for _, resource := range resources { + if _, ok := s.seen[resource]; ok { + continue + } + s.seen[resource] = struct{}{} + s.list = append(s.list, resource) + } +} + +func (s *resourceSet) has(resource *Resource) bool { + _, ok := s.seen[resource] + return ok +} + +// resources returns a fresh slice in insertion order (`[...set]`). +func (s *resourceSet) resources() []*Resource { + return append([]*Resource{}, s.list...) +} + +// concatResources ports `[...a, ...b]` into a fresh, never-nil slice. +func concatResources(parts ...[]*Resource) []*Resource { + out := []*Resource{} + for _, part := range parts { + out = append(out, part...) + } + return out +} + +// dedupeStrings ports `[...new Set(values)]`: first occurrence wins. +func dedupeStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +// New ports the constructor: panics with "Endpoint media copy capacity must +// be 1, 2, or 3" or "Route operation timeout must be a positive integer". +func New(options Options) *Controller { + if err := protocol.AssertEndpointMediaCopyCapacity(options.EndpointMediaCopyCapacity); err != nil { + panic(err) + } + if !isSafeInteger(options.OperationTimeoutMs) || options.OperationTimeoutMs <= 0 { + panic(errors.New("Route operation timeout must be a positive integer")) + } + c := &Controller{ + hostPeerID: options.HostPeerID, + debugRoomID: options.DebugRoomID, + endpointMediaCopyCapacity: options.EndpointMediaCopyCapacity, + operationTimeoutMs: options.OperationTimeoutMs, + sfuEnabled: options.SfuEnabled, + qualityConvergenceEnabled: options.QualityConvergenceEnabled, + natPredictionEnabled: options.NatPredictionEnabled, + routeTimings: make(map[string]*routeTimingRecord), + qualityObservations: make(map[string]*routeQualityObservation), + senderQualityObservations: make(map[string]*senderQualityObservation), + senderQualityBaselines: make(map[string]bool), + senderQualityRegenerationBlocks: make(map[string]senderQualityIdentity), + qualityBaselinesPending: make(map[string]struct{}), + } + c.debug("controller-created", + "endpointCapacity", options.EndpointMediaCopyCapacity, + "operationTimeoutMs", options.OperationTimeoutMs, + "sfuEnabled", options.SfuEnabled, + "qualityConvergenceEnabled", options.QualityConvergenceEnabled) + return c +} + +// DiagnosticParticipantLabel returns "host", "viewer-" or +// "viewer-unknown" for a peer ID without exposing the ID. +func (c *Controller) DiagnosticParticipantLabel(peerID string) string { + return c.debugPeer(peerID) +} + +// Revision is Snapshot().Revision without the graph copy, for the callers +// that only compare revisions before and after a mutation. +func (c *Controller) Revision() int64 { return c.revision } + +// Operation is Snapshot().Operation without the graph copy: a deep copy of +// the in-flight operation, or nil when the controller is idle. +func (c *Controller) Operation() *OperationSnapshot { return c.operationSnapshot() } + +// Publication is Snapshot().HostPublication without the graph copy: a copy +// of the single live SFU ingress, or nil when there is none. +func (c *Controller) Publication() *HostPublication { + if c.hostPublication == nil { + return nil + } + copied := *c.hostPublication + return &copied +} + +// Snapshot returns a copy of the committed graph, the publication and the +// operation snapshot; it mutates nothing. +func (c *Controller) Snapshot() RouteSnapshot { + // Ordering site: the router iterates this map to build assignments (§4.2 #9). + upstream := &ordered.Map[string, CommittedEdge]{} + for id, edge := range c.upstreamByViewer.All() { + upstream.Set(id, *edge) + } + var publication *HostPublication + if c.hostPublication != nil { + copied := *c.hostPublication + publication = &copied + } + return RouteSnapshot{ + Revision: c.revision, + Paused: c.paused, + FactVersion: c.factVersion, + UpstreamByViewer: upstream, + HostPublication: publication, + Operation: c.operationSnapshot(), + } +} + +// RouteDiagnosticSnapshot returns the privacy-safe snapshot: connected +// viewers sorted by join order with snapshot-local ordinals and relative +// durations; operation is nil unless its demand peer has an ordinal. +func (c *Controller) RouteDiagnosticSnapshot(nowMs int64) protocol.RouteDiagnosticSnapshot { + viewers := []*participant{} + for _, current := range c.participants.Values() { + if current.role == protocol.RoleViewer && current.sessionID != "" && !current.departureConfirmed { + viewers = append(viewers, current) + } + } + sort.SliceStable(viewers, func(i, j int) bool { return compareParticipant(viewers[i], viewers[j]) < 0 }) + ordinals := make(map[string]int, len(viewers)) + for index, viewer := range viewers { + ordinals[viewer.peerID] = index + 1 + } + children := make([]protocol.RouteDiagnosticChild, 0, len(viewers)) + for _, viewer := range viewers { + edge, _ := c.upstreamByViewer.Get(viewer.peerID) + record := c.routeTimings[viewer.peerID] + finalRoute := c.currentFinalRoute(viewer.peerID) + childCount := 0 + for _, childPeerID := range c.childrenOf(viewer.peerID) { + if childEdge, ok := c.upstreamByViewer.Get(childPeerID); ok && childEdge.Kind == UpstreamPeer && childEdge.PhysicalActive { + childCount++ + } + } + child := protocol.RouteDiagnosticChild{ + Ordinal: protocol.Int(ordinals[viewer.peerID]), + Parent: c.diagnosticParent(edge, ordinals), + EffectiveCapacity: protocol.Int(viewer.effectiveDownstreamCapacity), + ChildCount: protocol.Int(childCount), + FinalRoute: string(finalRoute), + RejectionBucket: string(RejectionNone), + Quality: c.diagnosticQuality(viewer.peerID, edge, nowMs), + } + if record != nil { + child.DemandAgeMs = elapsedMs(record.demandAtMs, nowMs) + if record.operationStartedAtMs != nil { + child.QueueWaitMs = elapsedMs(record.demandAtMs, *record.operationStartedAtMs) + } + if record.candidateStartedAtMs != nil { + child.CandidateStartMs = elapsedMs(record.demandAtMs, *record.candidateStartedAtMs) + } + if record.firstDecodedFrameAtMs != nil { + child.FirstDecodedFrameMs = elapsedMs(record.demandAtMs, *record.firstDecodedFrameAtMs) + } + if record.finalAtMs != nil { + child.FinalMs = elapsedMs(record.demandAtMs, *record.finalAtMs) + } + child.RejectionBucket = string(record.rejectionBucket) + } + if finalRoute == FinalRouteWaiting { + child.FinalRoute = string(FinalRouteWaiting) + if record != nil && record.finalRoute == FinalRouteFailed { + child.FinalRoute = string(FinalRouteFailed) + } + } + children = append(children, child) + } + var diagnosticOperation *protocol.RouteDiagnosticOperation + if op := c.operation; op != nil { + if childOrdinal, ok := ordinals[op.demandPeerID]; ok { + stage := "admission" + if op.current != nil { + stage = "first-frame" + if op.current.mediaReady { + stage = "quality-proof" + } + } + diagnosticOperation = &protocol.RouteDiagnosticOperation{ + ChildOrdinal: protocol.Int(childOrdinal), + Reason: string(op.reason), + Stage: stage, + Cursor: protocol.Int(op.cursor), + CandidateCount: protocol.Int(len(op.candidates)), + } + } + } + return protocol.RouteDiagnosticSnapshot{Children: children, Operation: diagnosticOperation} +} + +// UpsertParticipant adds or updates a participant, aborting an operation +// that used a replaced session; a viewer without a usable route records a +// "join" demand when nowMs is non-nil. Panics on a Host/role inconsistency. +func (c *Controller) UpsertParticipant(input ParticipantInput, nowMs *int64) []*Resource { + if (input.PeerID == c.hostPeerID) != (input.Role == protocol.RoleHost) { + panic(errors.New("Route Host identity is inconsistent")) + } + capacity := c.effectiveCapacity(input.EffectiveDownstreamCapacity) + current, _ := c.participants.Get(input.PeerID) + released := []*Resource{} + if current != nil && current.role != input.Role { + panic(errors.New("Route role cannot change")) + } + if current != nil { + changed := false + previousSessionID := current.sessionID + if previousSessionID != input.SessionID { + c.clearOpportunitiesForSessionChange(input.PeerID, input.Role) + c.clearQualityForParticipant(input.PeerID) + if c.operationUsesParticipantSession(input.PeerID) { + released = append(released, c.abortOperation(nowMs, RejectionStale)...) + } + c.directContinuations.Delete(input.PeerID) + c.clearSfuBootstrapForDemand(input.PeerID) + current.sessionID = input.SessionID + c.rebindCommittedSession(input.PeerID, input.SessionID) + changed = true + } + if current.departureConfirmed { + current.departureConfirmed = false + changed = true + } + if current.effectiveDownstreamCapacity != capacity { + current.effectiveDownstreamCapacity = capacity + changed = true + } + if changed { + c.touchFacts() + c.debug("participant-updated", + "participant", c.debugPeer(input.PeerID), + "capacity", capacity, + "sessionChanged", previousSessionID != input.SessionID) + } + if input.Role == protocol.RoleViewer && nowMs != nil && !c.usableRoute(input.PeerID) { + c.recordDemand(input.PeerID, *nowMs, DemandJoin) + } + return released + } + // A new key appends to the insertion order (TS Map.set at 573). + c.participants.Set(input.PeerID, &participant{ + peerID: input.PeerID, + role: input.Role, + sessionID: input.SessionID, + effectiveDownstreamCapacity: capacity, + joinOrder: c.nextJoinOrder, + }) + c.nextJoinOrder++ + c.debug("participant-joined", + "participant", c.debugPeer(input.PeerID), + "role", input.Role, + "capacity", capacity) + if input.Role == protocol.RoleViewer && nowMs != nil { + c.recordDemand(input.PeerID, *nowMs, DemandJoin) + } + c.touchFacts() + return released +} + +// RebindHostIdentity moves the Host to a new peer ID (same ID delegates to +// UpsertParticipant), tearing down the whole graph and returning every SFU +// subscription, the publication and the aborted reservation, deduplicated. +func (c *Controller) RebindHostIdentity(peerID, sessionID string, nowMs *int64) []*Resource { + previousPeerID := c.hostPeerID + if peerID == previousPeerID { + return c.UpsertParticipant(ParticipantInput{ + PeerID: peerID, + Role: protocol.RoleHost, + SessionID: sessionID, + EffectiveDownstreamCapacity: c.endpointMediaCopyCapacity, + }, nowMs) + } + if c.participants.Has(peerID) { + panic(errors.New("Route Host identity collides with another participant")) + } + host, _ := c.participants.Get(previousPeerID) + if host == nil || host.role != protocol.RoleHost { + panic(errors.New("Route Host identity is unavailable")) + } + + // Ordering site §4.5 #38: aborted reservation, then SFU edges in map + // order, then the publication, deduplicated. + released := &resourceSet{} + if c.operation != nil { + released.add(c.abortOperation(nowMs, RejectionStale)...) + } + for _, edge := range c.upstreamByViewer.Values() { + if edge.Kind == UpstreamSfu && edge.PhysicalActive { + released.add(edge.Resource) + } + } + if c.hostPublication != nil && c.hostPublication.PhysicalActive { + released.add(c.hostPublication.Resource) + } + c.upstreamByViewer.Clear() + c.hostPublication = nil + clear(c.routeTimings) + clear(c.qualityObservations) + clear(c.senderQualityObservations) + clear(c.senderQualityBaselines) + clear(c.senderQualityRegenerationBlocks) + clear(c.qualityBaselinesPending) + c.sfuPublisherQualityObservation = nil + c.directContinuations.Clear() + c.retiringPublicationGenerations.Clear() + c.sfuBootstrapIntent = nil + c.consumedSfuBootstrapOpportunities.Clear() + c.rootConvergenceRootPeerID = "" + // Ordering site §4.1 #2: demand is re-recorded in participants order. + for _, current := range c.participants.Values() { + current.availabilityExhausted = false + current.bootstrapFailureReported = false + current.sfuFirstAtNextRoute = false + current.consumedCandidateOpportunities.Clear() + if current.role == protocol.RoleViewer && current.sessionID != "" && nowMs != nil { + c.recordDemand(current.peerID, *nowMs, DemandEdgeUnavailable) + } + } + + // Ordering site §4.1 #1: delete + set moves the Host to the tail of the + // insertion order while it keeps its joinOrder. + c.participants.Delete(previousPeerID) + host.peerID = peerID + host.sessionID = sessionID + host.departureConfirmed = false + c.participants.Set(peerID, host) + c.hostPeerID = peerID + c.revision = c.allocateRevision() + c.touchFacts() + c.assertGraph() + return released.resources() +} + +// DisconnectSession marks the participant's exact session as gone (grace +// state, no revision change); false when the session does not match. +func (c *Controller) DisconnectSession(peerID, sessionID string) bool { + current, _ := c.participants.Get(peerID) + if current == nil || !current.sessionIs(sessionID) { + return false + } + c.clearOpportunitiesForSessionChange(peerID, current.role) + current.sessionID = "" + c.clearQualityForParticipant(peerID) + c.directContinuations.Delete(peerID) + c.clearSfuBootstrapForDemand(peerID) + c.touchFacts() + c.debug("participant-disconnected", "participant", c.debugPeer(peerID)) + return true +} + +// ConfirmDeparture finalises a viewer's departure (capacity 0, demand +// recorded for its children when nowMs is non-nil); false for the Host or +// an unknown peer; idempotent. +func (c *Controller) ConfirmDeparture(peerID string, nowMs *int64) bool { + current, _ := c.participants.Get(peerID) + if current == nil || current.role == protocol.RoleHost { + return false + } + delete(c.routeTimings, peerID) + if current.departureConfirmed && current.sessionID == "" && current.effectiveDownstreamCapacity == 0 { + return true + } + c.clearOpportunitiesForSessionChange(peerID, current.role) + current.sessionID = "" + c.clearQualityForParticipant(peerID) + current.departureConfirmed = true + current.effectiveDownstreamCapacity = 0 + c.directContinuations.Delete(peerID) + c.clearSfuBootstrapForDemand(peerID) + if nowMs != nil { + for _, childPeerID := range c.childrenOf(peerID) { + c.recordDemand(childPeerID, *nowMs, DemandParentDeparted) + } + } + c.touchFacts() + c.debug("participant-departed", "participant", c.debugPeer(peerID)) + return true +} + +// SetEffectiveCapacity clamps and stores the participant's downstream +// capacity for the matching session; true even when unchanged (facts are +// touched only on change). +func (c *Controller) SetEffectiveCapacity(peerID, sessionID string, value int, nowMs *int64) bool { + current, _ := c.participants.Get(peerID) + if current == nil || !current.sessionIs(sessionID) { + return false + } + capacity := c.effectiveCapacity(value) + if current.effectiveDownstreamCapacity == capacity { + return true + } + current.effectiveDownstreamCapacity = capacity + if nowMs != nil { + for _, childPeerID := range c.overflowPeerChildren(peerID) { + c.recordDemand(childPeerID, *nowMs, DemandCapacityReduction) + } + } + c.touchFacts() + c.debug("capacity-updated", "participant", c.debugPeer(peerID), "capacity", capacity) + return true +} + +// TouchExternalFacts reopens every SFU opportunity after an external fact +// change (for example a share generation) and bumps the fact version. +func (c *Controller) TouchExternalFacts() { + c.clearSfuCandidateOpportunities() + c.sfuBootstrapIntent = nil + c.touchFacts() +} + +// hydrateEdge installs a committed edge for a connected viewer while no +// operation is active, without changing revision or fact version. Panics +// on an active operation, a non-viewer child or a child without a session. +func (c *Controller) hydrateEdge(childPeerID string, edge committedEdgeSeed) { + if c.operation != nil { + panic(errors.New("Cannot hydrate while an operation is active")) + } + c.assertViewer(childPeerID) + child, _ := c.participants.Get(childPeerID) + childSessionID := child.sessionID + if childSessionID == "" { + panic(errors.New("Route child has no current session")) + } + committed := &CommittedEdge{ + Kind: edge.Kind, + ChildSessionID: childSessionID, + ParentPeerID: edge.ParentPeerID, + PublicationGeneration: edge.PublicationGeneration, + Transport: edge.Transport, + ConnectionID: edge.ConnectionID, + Usable: edge.Usable, + PhysicalActive: edge.PhysicalActive, + Resource: edge.Resource, + } + if edge.Kind == UpstreamPeer { + // TS 750: `this.participants.get(edge.parentPeerId)?.sessionId ?? ""`. + if parent, ok := c.participants.Get(edge.ParentPeerID); ok { + committed.ParentSessionID = parent.sessionID + } + } + // Map.set keeps the position of an existing child (§4.2 #15). + c.upstreamByViewer.Set(childPeerID, committed) + c.requireSenderQualityBaseline(childPeerID, false) + c.assertGraph() +} + +// hydrateHostPublication installs a usable, physically active publication +// while no operation is active; connectionID empty selects the TS default +// "publication:". Panics on an active operation or a Host +// without a session. +func (c *Controller) hydrateHostPublication(generation string, resource *Resource, connectionID string) { + if c.operation != nil { + panic(errors.New("Cannot hydrate while an operation is active")) + } + host, _ := c.participants.Get(c.hostPeerID) + if host == nil || host.sessionID == "" { + panic(errors.New("Route Host has no current session")) + } + if connectionID == "" { + connectionID = "publication:" + generation + } + c.hostPublication = &HostPublication{ + Generation: generation, + HostSessionID: host.sessionID, + ConnectionID: connectionID, + Usable: true, + PhysicalActive: true, + Resource: resource, + } + c.assertGraph() +} + +// InvalidateEdge marks the exactly identified edge unusable and records an +// "edge-unavailable" demand; true when the guard matched (idempotent). +func (c *Controller) InvalidateEdge(guard EdgeGuard, nowMs *int64) bool { + child, _ := c.participants.Get(guard.ChildPeerID) + edge, _ := c.upstreamByViewer.Get(guard.ChildPeerID) + if child == nil || !child.sessionIs(guard.ChildSessionID) || c.revision != guard.RouteRevision || + edge == nil || edge.ConnectionID != guard.ConnectionID || + edge.ChildSessionID != guard.ChildSessionID || + (edge.Kind == UpstreamPeer && edge.ParentSessionID != guard.ParentSessionID) { + return false + } + if edge.Usable { + c.consumeActiveEdgeOpportunity(child, edge) + c.clearQualityForParticipant(guard.ChildPeerID) + edge.Usable = false + c.directContinuations.Delete(guard.ChildPeerID) + child.availabilityExhausted = false + child.bootstrapFailureReported = false + c.touchFacts() + if nowMs != nil { + c.recordDemand(guard.ChildPeerID, *nowMs, DemandEdgeUnavailable) + } + } + return true +} + +// InvalidateDirectEdgeFromParent invalidates the first committed direct edge +// (insertion order) matching the parent-side guard. +func (c *Controller) InvalidateDirectEdgeFromParent(input ParentEdgeGuard, nowMs *int64) bool { + // Ordering site §4.2 #10: first match in insertion order wins. + for childPeerID, edge := range c.upstreamByViewer.All() { + if edge.Kind == UpstreamPeer && + edge.ParentPeerID == input.ParentPeerID && + edge.ParentSessionID == input.ParentSessionID && + edge.ConnectionID == input.ConnectionID { + return c.InvalidateEdge(EdgeGuard{ + ChildPeerID: childPeerID, + ChildSessionID: edge.ChildSessionID, + ParentSessionID: input.ParentSessionID, + RouteRevision: input.RouteRevision, + ConnectionID: input.ConnectionID, + }, nowMs) + } + } + return false +} + +// InvalidateHostPublication marks the identified publication unusable and +// records demand for every SFU child; true when the guard matched (idempotent). +func (c *Controller) InvalidateHostPublication(guard PublicationGuard, nowMs *int64) bool { + publication := c.hostPublication + if publication == nil || c.revision != guard.RouteRevision || + publication.HostSessionID != guard.HostSessionID || + publication.Generation != guard.Generation || + publication.ConnectionID != guard.ConnectionID { + return false + } + if publication.Usable { + c.clearSfuQuality() + publication.Usable = false + c.directContinuations.Clear() + c.sfuBootstrapIntent = nil + if nowMs != nil { + for childPeerID, edge := range c.upstreamByViewer.All() { + if edge.Kind == UpstreamSfu { + c.recordDemand(childPeerID, *nowMs, DemandEdgeUnavailable) + } + } + } + c.touchFacts() + } + return true +} + +// AdoptDirectConnection rewrites the connection ID of a usable, active +// direct edge the operation does not own; true when adopted (no-op when +// unchanged). +func (c *Controller) AdoptDirectConnection(input AdoptDirectConnectionInput) bool { + edge, _ := c.upstreamByViewer.Get(input.ChildPeerID) + if (c.operation != nil && c.operation.childPeerID == input.ChildPeerID) || + c.revision != input.RouteRevision || + edge == nil || + edge.Kind != UpstreamPeer || + edge.Transport != TransportDirect || + !edge.Usable || + !edge.PhysicalActive || + edge.ChildSessionID != input.ChildSessionID || + edge.ParentSessionID != input.ParentSessionID || + edge.ConnectionID != input.ConnectionID || + input.NewConnectionID == "" { + return false + } + if input.NewConnectionID == edge.ConnectionID { + return true + } + c.clearQualityForParticipant(input.ChildPeerID) + edge.ConnectionID = input.NewConnectionID + if child, ok := c.participants.Get(input.ChildPeerID); ok { + child.availabilityExhausted = false + } + c.touchFacts() + return true +} + +// RetireHostPublication retires the physically active publication and its +// subscriptions when no attempt is live and the guard matches, returning the +// subscriptions then the publication resource; empty otherwise. +func (c *Controller) RetireHostPublication(guard PublicationGuard) []*Resource { + publication := c.hostPublication + if (c.operation != nil && c.operation.current != nil) || publication == nil || !publication.PhysicalActive || + c.revision != guard.RouteRevision || + publication.HostSessionID != guard.HostSessionID || + publication.Generation != guard.Generation || + publication.ConnectionID != guard.ConnectionID { + return []*Resource{} + } + // Ordering site §4.2 #11: subscriptions in map order, then the publication. + released := []*Resource{} + for viewerPeerID, edge := range c.upstreamByViewer.All() { + if edge.Kind == UpstreamSfu && edge.PublicationGeneration == publication.Generation { + c.retireSfuEdge(viewerPeerID, edge, &released, c.retainsAnchor(viewerPeerID)) + } + } + publication.PhysicalActive = false + publication.Usable = false + c.hostPublication = nil + c.clearSfuQuality() + c.revision = c.allocateRevision() + if c.operation != nil { + c.operation.baseRevision = c.revision + } + c.touchFacts() + released = append(released, publication.Resource) + c.pruneRetiringSfuAnchors() + return released +} + +// SetPaused pauses (aborting the operation and clearing quality state) or +// resumes; returns the aborted reservation's resources. +func (c *Controller) SetPaused(paused bool, nowMs *int64) []*Resource { + if c.paused == paused { + return []*Resource{} + } + c.paused = paused + if paused { + clear(c.qualityObservations) + clear(c.qualityBaselinesPending) + clear(c.senderQualityObservations) + clear(c.senderQualityBaselines) + clear(c.senderQualityRegenerationBlocks) + c.sfuPublisherQualityObservation = nil + } + c.touchFacts() + if paused { + return c.abortOperation(nowMs, RejectionAborted) + } + return []*Resource{} +} + +// Dispose clears all state, pauses the controller and returns the live +// reservation's resources then every committed resource, deduplicated. +func (c *Controller) Dispose() []*Resource { + // Ordering site §4.5 #39: reservation first, then committed resources. + resources := &resourceSet{} + if c.operation != nil && c.operation.current != nil { + resources.add(reservationResources(c.operation.current.reservation)...) + } + resources.add(c.committedResources()...) + c.operation = nil + c.upstreamByViewer.Clear() + clear(c.qualityObservations) + clear(c.qualityBaselinesPending) + clear(c.senderQualityObservations) + clear(c.senderQualityBaselines) + clear(c.senderQualityRegenerationBlocks) + c.sfuPublisherQualityObservation = nil + c.hostPublication = nil + c.participants.Clear() + clear(c.routeTimings) + c.directContinuations.Clear() + c.retiringPublicationGenerations.Clear() + c.sfuBootstrapIntent = nil + c.consumedSfuBootstrapOpportunities.Clear() + c.rootConvergenceRootPeerID = "" + // nextJoinOrder, revision, latestRevision and factVersion survive (TS 2314-2339). + c.paused = true + return resources.resources() +} + +// abortOperation ports the private abortOperation (4050): finishes the +// demand timing when nowMs is given, releases the live reservation and +// clears the operation. +func (c *Controller) abortOperation(nowMs *int64, bucket RejectionBucket) []*Resource { + if c.operation == nil { + return []*Resource{} + } + if nowMs != nil { + c.finishTiming(c.operation.demandPeerID, *nowMs, c.currentFinalRoute(c.operation.demandPeerID), bucket, false) + } + op := c.operation + released := []*Resource{} + if op.current != nil { + released = reservationResources(op.current.reservation) + c.advanceActiveRevision(op) + } + c.operation = nil + return released +} + +// operationUsesParticipantSession ports 4072. +func (c *Controller) operationUsesParticipantSession(peerID string) bool { + op := c.operation + if op == nil { + return false + } + if op.childPeerID == peerID || op.demandPeerID == peerID { + return true + } + tuple := op.currentTuple() + if tuple != nil && tuple.Kind == UpstreamPeer { + return tuple.ParentPeerID == peerID + } + return peerID == c.hostPeerID +} + +// effectiveCapacity ports 4925: panics "Effective capacity is invalid" for +// a negative or unsafe value and clamps to the endpoint capacity. +func (c *Controller) effectiveCapacity(value int) int { + if !isSafeInteger(int64(value)) || value < 0 { + panic(errors.New("Effective capacity is invalid")) + } + return min(value, c.endpointMediaCopyCapacity) +} + +func (c *Controller) touchFacts() { c.factVersion++ } + +// allocateRevision ports 4932; the revision space is MAX_SAFE_INTEGER. +func (c *Controller) allocateRevision() int64 { + if c.latestRevision >= protocol.MaxMediaRouteRevision { + panic(errors.New("Media route revision space exhausted")) + } + c.latestRevision++ + return c.latestRevision +} + +// isSafeInteger is Number.isSafeInteger for a value that is already integral. +func isSafeInteger(value int64) bool { + return value >= -maxSafeInteger && value <= maxSafeInteger +} + +// compareParticipant ports 4939. joinOrder is allocated once per +// participant (573-577) and preserved across rebindHostIdentity, so two live +// participants never share one and the TS localeCompare (ICU) tiebreak is +// unreachable; strings.Compare stands in for it. +func compareParticipant(left, right *participant) int { + if left.joinOrder != right.joinOrder { + if left.joinOrder < right.joinOrder { + return -1 + } + return 1 + } + return strings.Compare(left.peerID, right.peerID) +} + +// stablePairRank ports 4955: the first 6 bytes, big-endian, of +// sha256(child + "\0" + parent). Byte-exact: it breaks parent ties. +func stablePairRank(childPeerID, parentPeerID string) int64 { + sum := sha256.Sum256([]byte(childPeerID + "\x00" + parentPeerID)) + var rank int64 + for _, b := range sum[:6] { + rank = rank<<8 | int64(b) + } + return rank +} + +// safeAdd ports 4969. JS Math.round is half-up while math.Round is +// half-away-from-zero; they differ only for negative halves, which the +// `rounded < 0` branch drops either way. NaN and +/-Inf drop the window. +func safeAdd(total int64, delta float64) int64 { + rounded := math.Round(delta) + if math.IsNaN(rounded) || math.IsInf(rounded, 0) || rounded < 0 || rounded > float64(maxSafeInteger) { + return total + } + value := int64(rounded) + if value > maxSafeInteger-total { + return maxSafeInteger + } + return total + value +} diff --git a/internal/server/route/controller_test.go b/internal/server/route/controller_test.go new file mode 100644 index 0000000..8b5465f --- /dev/null +++ b/internal/server/route/controller_test.go @@ -0,0 +1,2866 @@ +package route + +// Oracle port of tests/room-route-controller.test.ts. Each test keeps the TS +// title and line as a comment; every nowMs is the explicit literal the TS +// used, so nothing here needs a clock. + +import ( + "encoding/json" + "fmt" + "regexp" + "slices" + "strings" + "testing" + + "github.com/TNTcraftHIM/Screener/internal/server/protocol" +) + +// TS 359: keeps one diagnostic label until a departed relay is finally pruned +func TestKeepsOneDiagnosticLabelUntilDepartedRelayIsPruned(t *testing.T) { + routes := newController(2, Options{}) + addViewer(routes, A, 1, nil) + addViewer(routes, B, 0, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateEdge(B, peerEdge(A, "b_from_a")) + label := routes.DiagnosticParticipantLabel(A) + if !regexp.MustCompile(`^viewer-\d+$`).MatchString(label) { + t.Fatalf("label %q is not viewer-", label) + } + + routes.UpsertParticipant(viewerInput(A, "a_replacement_session", 1), nil) + eq(t, routes.DiagnosticParticipantLabel(A), label) + eq(t, routes.DisconnectSession(A, "a_replacement_session"), true) + eq(t, routes.DiagnosticParticipantLabel(A), label) + eq(t, routes.ConfirmDeparture(A, ms(0)), true) + eq(t, routes.DiagnosticParticipantLabel(A), label) + + repair := must(t, routes.Reconcile(1).Operation) + eq(t, repair.ChildPeerID, B) + eq(t, repair.Reason, DemandParentDeparted) + commitCurrent(t, routes, 2, "b_from_host") + pruned := routes.Reconcile(4) + if !slices.Contains(pruned.RemovedPeerIDs, A) { + t.Fatalf("removedPeerIds %v does not contain A", pruned.RemovedPeerIDs) + } + eq(t, routes.DiagnosticParticipantLabel(A), "viewer-unknown") + + addViewer(routes, C, 0, nil) + if routes.DiagnosticParticipantLabel(C) == label { + t.Fatalf("C reused label %q", label) + } +} + +// TS 394: clears an unknown sender run without rearming its healthy baseline +func TestClearsUnknownSenderRunWithoutRearmingHealthyBaseline(t *testing.T) { + routes := newController(2, Options{QualityConvergenceEnabled: true}) + addViewer(routes, A, 2, nil) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + for _, seed := range [][2]string{{A, "a_from_host"}, {B, "b_from_host"}} { + eq(t, observeSenderState(routes, senderState{ + childPeerID: seed[0], connectionID: seed[1], state: SenderQualityHealthy, acceptedAtMs: 0, + }).Accepted, true) + } + eq(t, observeSenderState(routes, senderState{ + childPeerID: A, connectionID: "a_from_host", state: SenderQualityDegraded, acceptedAtMs: 1, + }).Accepted, true) + eq(t, routes.ObserveSenderQualityEvidence(unknownSample(HOST, A, routes.Snapshot().Revision, "a_from_host", 2), nil).Accepted, true) + + for _, acceptedAtMs := range []int64{3, 4, 5} { + eq(t, observeSenderState(routes, senderState{ + childPeerID: A, connectionID: "a_from_host", state: SenderQualityDegraded, acceptedAtMs: acceptedAtMs, + }).Accepted, true) + } + operation := must(t, routes.Reconcile(6).Operation) + eq(t, operation.ChildPeerID, A) + eq(t, operation.Reason, DemandQualityConvergence) +} + +// TS 448: consumes a quality candidate rejected at preparation instead of rebuilding it +func TestConsumesQualityCandidateRejectedAtPreparation(t *testing.T) { + routes := newController(2, Options{QualityConvergenceEnabled: true}) + addViewer(routes, A, 2, nil) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + observePersistentDegraded(t, routes, A, "a_from_host", 0) + first := must(t, routes.Reconcile(10).Operation) + eq(t, first.ChildPeerID, A) + eq(t, first.Reason, DemandQualityConvergence) + rejected := first.Candidates[first.Cursor].Tuple + + operation := first + for round := 0; operation != nil && round < 4; round++ { + if round > 0 && slices.Contains(tuplesFrom(operation, operation.Cursor), rejected) { + t.Fatalf("round %d rebuilt the rejected tuple %+v", round, rejected) + } + eq(t, routes.SkipCurrentCandidate(cursorGuard(operation), int64(11+round), RejectionStale).Accepted, true) + operation = routes.Reconcile(int64(12 + round)).Operation + } + noOperation(t, operation) +} + +// TS 480: lets a newly committed availability edge report persistent degradation before healthy +func TestNewlyCommittedEdgeReportsPersistentDegradationBeforeHealthy(t *testing.T) { + routes := newController(2, Options{QualityConvergenceEnabled: true}) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + addViewer(routes, A, 2, ms(0)) + joined := must(t, routes.Reconcile(0).Operation) + eq(t, joined.ChildPeerID, A) + commitCurrent(t, routes, 1, "a_from_host") + + for _, acceptedAtMs := range []int64{2, 3, 4} { + eq(t, observeSenderState(routes, senderState{ + childPeerID: A, connectionID: "a_from_host", state: SenderQualityDegraded, acceptedAtMs: acceptedAtMs, + }).Accepted, true) + } + + operation := must(t, routes.Reconcile(5).Operation) + eq(t, operation.ChildPeerID, A) + eq(t, operation.Reason, DemandQualityConvergence) +} + +// TS 506: starts a fresh degradation run when an availability sender identity changes +func TestStartsFreshDegradationRunWhenSenderIdentityChanges(t *testing.T) { + routes := newController(2, Options{QualityConvergenceEnabled: true}) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + addViewer(routes, A, 2, ms(0)) + routes.Reconcile(0) + commitCurrent(t, routes, 1, "a_from_host") + + observe := func(identity string, at int64) { + t.Helper() + eq(t, observeSenderState(routes, senderState{ + childPeerID: A, connectionID: "a_from_host", senderIdentity: identity, state: SenderQualityDegraded, acceptedAtMs: at, + }).Accepted, true) + } + observe("old-rtp\x00track", 2) + observe("old-rtp\x00track", 3) + observe("new-rtp\x00track", 4) + noOperation(t, routes.Reconcile(4).Operation) + observe("new-rtp\x00track", 5) + observe("new-rtp\x00track", 6) + operation := must(t, routes.Reconcile(7).Operation) + eq(t, operation.ChildPeerID, A) + eq(t, operation.Reason, DemandQualityConvergence) +} + +// TS 566: lets a reset sender begin a new degradation run without a healthy sample +func TestResetSenderBeginsNewDegradationRunWithoutHealthySample(t *testing.T) { + routes := newController(2, Options{QualityConvergenceEnabled: true}) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + addViewer(routes, A, 2, ms(0)) + routes.Reconcile(0) + commitCurrent(t, routes, 1, "a_from_host") + eq(t, observeSenderState(routes, senderState{ + childPeerID: A, connectionID: "a_from_host", state: SenderQualityHealthy, acceptedAtMs: 2, + }).Accepted, true) + + routes.ResetSenderQuality(HOST, "host_session", 3) + for _, acceptedAtMs := range []int64{4, 5, 6} { + eq(t, observeSenderState(routes, senderState{ + childPeerID: A, connectionID: "a_from_host", state: SenderQualityDegraded, acceptedAtMs: acceptedAtMs, + }).Accepted, true) + } + operation := must(t, routes.Reconcile(7).Operation) + eq(t, operation.ChildPeerID, A) + eq(t, operation.Reason, DemandQualityConvergence) +} + +// TS 599: regenerates a persistently degraded edge on the same parent +func TestRegeneratesPersistentlyDegradedEdgeOnSameParent(t *testing.T) { + routes := newController(2, Options{QualityConvergenceEnabled: true}) + addViewer(routes, A, 2, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + observePersistentDegraded(t, routes, A, "a_from_host", 10) + + operation := must(t, routes.Reconcile(14).Operation) + eq(t, len(operation.Candidates), 1) + eq(t, operation.Candidates[0].Tuple, regenerateTuple(HOST)) + expectTransition(t, operation.Candidates[0].EndpointTransition, TransitionNone, HOST) + + begin := beginOperation(t, routes, BeginInput{NowMs: 15, ConnectionID: "a_regenerated", Reservation: direct()}) + edge := edgeOf(t, routes, A) + eq(t, edge.ParentPeerID, HOST) + eq(t, edge.ConnectionID, "a_from_host") + eq(t, edge.Usable, true) + eq(t, edge.PhysicalActive, true) + guard := guardFor(A, begin.Current.Revision, "a_regenerated") + ready := routes.CandidateReady(guard, 16, nil, CandidateProof{RelativeQualityApproved: true}) + eq(t, ready.Accepted, true) + eq(t, ready.Committed, false) + + eq(t, routes.ObserveSenderQualityEvidence(senderSample( + HOST, A, begin.Current.Revision, "a_regenerated", "a-regenerated-rtp\x00track", SenderQualityHealthy, 17), nil).Committed, true) + edge = edgeOf(t, routes, A) + eq(t, edge.Kind, UpstreamPeer) + eq(t, edge.ParentPeerID, HOST) + eq(t, edge.ConnectionID, "a_regenerated") + eq(t, observeSenderState(routes, senderState{ + childPeerID: A, connectionID: "a_regenerated", senderIdentity: "a-regenerated-rtp\x00track", + state: SenderQualityHealthy, acceptedAtMs: 18, + }).Accepted, true) + for _, acceptedAtMs := range []int64{19, 20, 21} { + eq(t, observeSenderState(routes, senderState{ + childPeerID: A, connectionID: "a_regenerated", senderIdentity: "a-regenerated-rtp\x00track", + state: SenderQualityDegraded, acceptedAtMs: acceptedAtMs, + }).Accepted, true) + } + eq(t, must(t, routes.Reconcile(22).Operation).Candidates[0].Tuple, regenerateTuple(HOST)) +} + +// TS 686: keeps same-edge regeneration damped while allowing a different parent +func TestKeepsSameEdgeRegenerationDampedWhileAllowingDifferentParent(t *testing.T) { + routes := newController(2, Options{QualityConvergenceEnabled: true}) + addViewer(routes, A, 2, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + observePersistentDegraded(t, routes, A, "a_from_host", 10) + + routes.Reconcile(14) + begin := beginOperation(t, routes, BeginInput{NowMs: 15, ConnectionID: "a_regenerated", Reservation: direct()}) + guard := guardFor(A, begin.Current.Revision, "a_regenerated") + routes.CandidateReady(guard, 16, nil, CandidateProof{RelativeQualityApproved: true}) + eq(t, routes.ObserveSenderQualityEvidence(senderSample( + HOST, A, begin.Current.Revision, "a_regenerated", "a-regenerated-rtp\x00track", SenderQualityHealthy, 17), nil).Committed, true) + + for _, acceptedAtMs := range []int64{18, 19, 20} { + eq(t, observeSenderState(routes, senderState{ + childPeerID: A, connectionID: "a_regenerated", senderIdentity: "a-regenerated-rtp\x00track", + state: SenderQualityDegraded, acceptedAtMs: acceptedAtMs, + }).Accepted, true) + } + noOperation(t, routes.Reconcile(21).Operation) + + addViewer(routes, B, 2, nil) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + eq(t, observeSenderState(routes, senderState{ + childPeerID: B, connectionID: "b_from_host", state: SenderQualityHealthy, acceptedAtMs: 22, + }).Accepted, true) + withAlternate := must(t, routes.Reconcile(23).Operation) + eq(t, withAlternate.ChildPeerID, A) + eq(t, withAlternate.Reason, DemandQualityConvergence) + eqSlice(t, tuples(withAlternate), []CandidateTuple{peerTuple(B)}) +} + +// TS 754: uses the existing one-slot overlap for same-parent regeneration +func TestUsesExistingOneSlotOverlapForSameParentRegeneration(t *testing.T) { + routes := newController(1, Options{QualityConvergenceEnabled: true}) + addViewer(routes, A, 1, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + observePersistentDegraded(t, routes, A, "a_from_host", 10) + + operation := must(t, routes.Reconcile(14).Operation) + expectTransition(t, operation.Candidates[0].EndpointTransition, TransitionOverlap, HOST) + begin := beginOperation(t, routes, BeginInput{NowMs: 15, ConnectionID: "a_regenerated", Reservation: directOverlap("a_overlap")}) + guard := guardFor(A, begin.Current.Revision, "a_regenerated") + routes.CandidateReady(guard, 16, nil, CandidateProof{RelativeQualityApproved: true}) + committed := routes.ObserveSenderQualityEvidence(senderSample( + HOST, A, begin.Current.Revision, "a_regenerated", "a-regenerated-rtp\x00track", SenderQualityHealthy, 17), nil) + eq(t, committed.Committed, true) + containsLabels(t, committed.Released, "a_overlap") +} + +// TS 794: keeps one quality experiment through inconclusive current windows +func TestKeepsOneQualityExperimentThroughInconclusiveWindows(t *testing.T) { + routes := newController(2, Options{QualityConvergenceEnabled: true}) + addViewer(routes, A, 2, nil) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + observeSenderState(routes, senderState{childPeerID: B, connectionID: "b_from_host", state: SenderQualityHealthy, acceptedAtMs: 9}) + observePersistentDegraded(t, routes, A, "a_from_host", 10) + + operation := must(t, routes.Reconcile(14).Operation) + eqSlice(t, tuples(operation), []CandidateTuple{peerTuple(B), regenerateTuple(HOST)}) + beginCandidate(t, routes, BeginInput{NowMs: 15, ConnectionID: "a_from_b_failed", Reservation: direct()}) + + currentRevision := routes.Snapshot().Revision + eq(t, routes.ObserveSenderQualityEvidence(unknownSample(HOST, A, currentRevision, "a_from_host", 16), nil).Accepted, true) + pending := must(t, routes.Snapshot().Operation) + eq(t, pending.Reason, DemandQualityConvergence) + eq(t, pending.Cursor, 0) + + afterFirst := routes.OperationExpired(must(t, routes.Snapshot().Operation).WakeAtMs) + eq(t, afterFirst.Accepted, true) + pending = must(t, routes.Snapshot().Operation) + eq(t, pending.Reason, DemandQualityConvergence) + eq(t, pending.Cursor, 1) + regeneration := beginOperation(t, routes, BeginInput{ + NowMs: 20, ConnectionID: "a_regeneration_failed", Reservation: directOverlap("a_regeneration_overlap"), + }) + eq(t, routes.CandidateFailed(guardFor(A, regeneration.Current.Revision, "a_regeneration_failed"), 21).Accepted, true) + noOperation(t, routes.Snapshot().Operation) + + for _, acceptedAtMs := range []int64{22, 23, 24} { + eq(t, observeSenderState(routes, senderState{ + childPeerID: A, connectionID: "a_from_host", state: SenderQualityDegraded, acceptedAtMs: acceptedAtMs, + }).Accepted, true) + } + noOperation(t, routes.Reconcile(25).Operation) + routes.TouchExternalFacts() + noOperation(t, routes.Reconcile(26).Operation) + + addViewer(routes, C, 2, nil) + routes.hydrateEdge(C, peerEdge(B, "c_from_b")) + observeSenderState(routes, senderState{ + childPeerID: C, connectionID: "c_from_b", parentPeerID: B, state: SenderQualityHealthy, acceptedAtMs: 27, + }) + newOpportunity := must(t, routes.Reconcile(28).Operation) + eq(t, newOpportunity.ChildPeerID, A) + eq(t, newOpportunity.Reason, DemandQualityConvergence) + eqSlice(t, tuples(newOpportunity), []CandidateTuple{peerTuple(C)}) +} + +// TS 902: does not restart a quality candidate set after its total deadline +func TestDoesNotRestartQualityCandidateSetAfterTotalDeadline(t *testing.T) { + routes := newController(2, Options{QualityConvergenceEnabled: true}) + addViewer(routes, A, 2, nil) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + observeSenderState(routes, senderState{childPeerID: B, connectionID: "b_from_host", state: SenderQualityHealthy, acceptedAtMs: 9}) + observePersistentDegraded(t, routes, A, "a_from_host", 10) + + operation := must(t, routes.Reconcile(14).Operation) + beginCandidate(t, routes, BeginInput{NowMs: 15, ConnectionID: "a_from_b_pending", Reservation: direct()}) + eq(t, routes.OperationExpired(operation.DeadlineAtMs).Accepted, true) + noOperation(t, routes.Snapshot().Operation) + noOperation(t, routes.Reconcile(operation.DeadlineAtMs+1).Operation) + routes.TouchExternalFacts() + noOperation(t, routes.Reconcile(operation.DeadlineAtMs+2).Operation) + + observeSenderState(routes, senderState{ + childPeerID: A, connectionID: "a_from_host", state: SenderQualityHealthy, acceptedAtMs: operation.DeadlineAtMs + 3, + }) + for _, acceptedAtMs := range []int64{operation.DeadlineAtMs + 4, operation.DeadlineAtMs + 5, operation.DeadlineAtMs + 6} { + observeSenderState(routes, senderState{ + childPeerID: A, connectionID: "a_from_host", state: SenderQualityDegraded, acceptedAtMs: acceptedAtMs, + }) + } + eq(t, must(t, routes.Reconcile(operation.DeadlineAtMs+7).Operation).Reason, DemandQualityConvergence) +} + +// TS 951: keeps isolated limitation windows diagnostic +func TestKeepsIsolatedLimitationWindowsDiagnostic(t *testing.T) { + routes := newController(2, Options{QualityConvergenceEnabled: true}) + addViewer(routes, A, 2, nil) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + observeSenderState(routes, senderState{childPeerID: B, connectionID: "b_from_host", state: SenderQualityHealthy, acceptedAtMs: 99}) + observeSenderState(routes, senderState{childPeerID: A, connectionID: "a_from_host", state: SenderQualityHealthy, acceptedAtMs: 99}) + observeSenderState(routes, senderState{childPeerID: A, connectionID: "a_from_host", state: SenderQualityDegraded, acceptedAtMs: 100}) + repeated := senderSample(HOST, A, routes.Snapshot().Revision, "a_from_host", "a_from_host-rtp\x00track", SenderQualityDegraded, 101) + repeated.SampleTimestampMs = ms(100) // TS: sampleTimestampMs 100 with acceptedAtMs 101 + eq(t, routes.ObserveSenderQualityEvidence(repeated, nil).Accepted, true) + noOperation(t, routes.Reconcile(101).Operation) + for _, acceptedAtMs := range []int64{102} { + observeSenderState(routes, senderState{childPeerID: A, connectionID: "a_from_host", state: SenderQualityDegraded, acceptedAtMs: acceptedAtMs}) + noOperation(t, routes.Reconcile(acceptedAtMs).Operation) + } + observeSenderState(routes, senderState{childPeerID: A, connectionID: "a_from_host", state: SenderQualityDegraded, acceptedAtMs: 103}) + operation := must(t, routes.Reconcile(104).Operation) + eq(t, operation.ChildPeerID, A) + eq(t, operation.Reason, DemandQualityConvergence) +} + +// TS 1010: never treats one degraded Host edge as SFU fanout pressure +func TestNeverTreatsOneDegradedHostEdgeAsSfuFanoutPressure(t *testing.T) { + routes := newController(2, Options{SfuEnabled: true, QualityConvergenceEnabled: true}) + addViewer(routes, A, 1, nil) + addViewer(routes, B, 0, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateHostPublication("publication_generation_12345678", res("publication_resource"), "") + routes.hydrateEdge(B, sfuEdge("publication_generation_12345678", "b_from_sfu", "b_subscription")) + observePersistentDegraded(t, routes, A, "a_from_host", 99) + observePersistentSfuHealthy(t, routes, "publication_generation_12345678", routes.Snapshot().Revision, 103) + operation := must(t, routes.Reconcile(104).Operation) + eq(t, operation.Reason, DemandQualityConvergence) + eq(t, len(operation.Candidates), 1) + eq(t, operation.Candidates[0].Tuple, regenerateTuple(HOST)) + edge := edgeOf(t, routes, A) + eq(t, edge.Kind, UpstreamPeer) + eq(t, edge.ParentPeerID, HOST) +} + +// TS 1052: commits only after the Viewer approves a P2P receive improvement +func TestCommitsOnlyAfterViewerApprovesP2PReceiveImprovement(t *testing.T) { + routes := newController(2, Options{QualityConvergenceEnabled: true}) + addViewer(routes, A, 2, nil) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + eq(t, observeSenderState(routes, senderState{ + childPeerID: B, connectionID: "b_from_host", state: SenderQualityHealthy, acceptedAtMs: 100, + }).Accepted, true) + observePersistentDegraded(t, routes, A, "a_from_host", 99) + + operation := must(t, routes.Reconcile(103).Operation) + eq(t, operation.Reason, DemandQualityConvergence) + eq(t, operation.Candidates[0].Tuple, peerTuple(B)) + begin := beginCandidate(t, routes, BeginInput{NowMs: 104, ConnectionID: "a_from_b", Reservation: direct()}) + current := must(t, begin.Operation).Current + guard := guardFor(A, current.Revision, "a_from_b") + edge := edgeOf(t, routes, A) + eq(t, edge.Kind, UpstreamPeer) + eq(t, edge.ParentPeerID, HOST) + eq(t, edge.ConnectionID, "a_from_host") + ready := routes.CandidateReady(guard, 105, nil, CandidateProof{RelativeQualityApproved: true}) + eq(t, ready.Accepted, true) + eq(t, ready.Committed, false) + eq(t, routes.ObserveSenderQualityEvidence(senderSample( + B, A, current.Revision, "a_from_b", "a-from-b-rtp\x00a-track", SenderQualityHealthy, 106), nil).Committed, true) + edge = edgeOf(t, routes, A) + eq(t, edge.Kind, UpstreamPeer) + eq(t, edge.ParentPeerID, B) + eq(t, edge.ConnectionID, "a_from_b") + + migratedRevision := routes.Snapshot().Revision + observeMigrated := func(state SenderQualityState, at int64) SenderQualityEvidenceResult { + return routes.ObserveSenderQualityEvidence(senderSample( + B, A, migratedRevision, "a_from_b", "a-from-b-rtp\x00a-track", state, at), nil) + } + for _, acceptedAtMs := range []int64{107, 108, 109} { + eq(t, observeMigrated(SenderQualityDegraded, acceptedAtMs).Accepted, true) + } + noOperation(t, routes.Reconcile(110).Operation) + routes.TouchExternalFacts() + noOperation(t, routes.Reconcile(110).Operation) + eq(t, observeMigrated(SenderQualityHealthy, 111).Accepted, true) + eq(t, observeMigrated(SenderQualityDegraded, 112).Accepted, true) + eq(t, observeMigrated(SenderQualityDegraded, 113).Accepted, true) + eq(t, observeMigrated(SenderQualityDegraded, 114).Accepted, true) + eq(t, must(t, routes.Reconcile(115).Operation).Reason, DemandQualityConvergence) +} + +// TS 1147: uses one healthy serial operation to distribute a newly committed Host root +func TestUsesOneHealthySerialOperationToDistributeNewHostRoot(t *testing.T) { + routes := newController(2, Options{QualityConvergenceEnabled: true}) + addViewer(routes, B, 2, nil) + addViewer(routes, C, 0, nil) + addViewer(routes, D, 0, nil) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + routes.hydrateEdge(C, peerEdge(B, "c_from_b")) + routes.hydrateEdge(D, peerEdge(B, "d_from_b")) + addViewer(routes, A, 2, ms(10)) + + joined := must(t, routes.Reconcile(10).Operation) + eq(t, joined.Candidates[0].Tuple, peerTuple(HOST)) + commitCurrent(t, routes, 11, "a_from_host") + + convergence := must(t, routes.Reconcile(13).Operation) + eq(t, convergence.Reason, DemandRootConvergence) + eq(t, len(convergence.Candidates), 1) + eq(t, convergence.Candidates[0].Tuple, peerTuple(A)) + eq(t, edgeOf(t, routes, convergence.ChildPeerID).ParentPeerID, B) + + prepared := beginOperation(t, routes, BeginInput{NowMs: 14, ConnectionID: "root_convergence_candidate", Reservation: direct()}) + guard := CandidateGuard{ + ChildPeerID: convergence.ChildPeerID, + ChildSessionID: convergence.ChildSessionID, + Revision: prepared.Current.Revision, + ConnectionID: "root_convergence_candidate", + } + ready := routes.CandidateReady(guard, 15, nil, CandidateProof{}) + eq(t, ready.Accepted, true) + eq(t, ready.Committed, false) + eq(t, routes.ObserveSenderQualityEvidence(senderSample( + A, convergence.ChildPeerID, prepared.Current.Revision, "root_convergence_candidate", "root-rtp\x00track", + SenderQualityHealthy, 16), nil).Committed, true) + eq(t, edgeOf(t, routes, convergence.ChildPeerID).ParentPeerID, A) + migratedChildPeerID := convergence.ChildPeerID + for _, acceptedAtMs := range []int64{17, 18, 19} { + eq(t, observeSenderState(routes, senderState{ + childPeerID: migratedChildPeerID, connectionID: "root_convergence_candidate", parentPeerID: A, + state: SenderQualityDegraded, acceptedAtMs: acceptedAtMs, + }).Accepted, true) + } + noOperation(t, routes.Reconcile(20).Operation) + eq(t, observeSenderState(routes, senderState{ + childPeerID: migratedChildPeerID, connectionID: "root_convergence_candidate", parentPeerID: A, + state: SenderQualityHealthy, acceptedAtMs: 21, + }).Accepted, true) + for _, acceptedAtMs := range []int64{22, 23, 24} { + eq(t, observeSenderState(routes, senderState{ + childPeerID: migratedChildPeerID, connectionID: "root_convergence_candidate", parentPeerID: A, + state: SenderQualityDegraded, acceptedAtMs: acceptedAtMs, + }).Accepted, true) + } + eq(t, must(t, routes.Reconcile(25).Operation).Reason, DemandQualityConvergence) +} + +// TS 1247: advances a silent Peer only when another bounded candidate remains +func TestAdvancesSilentPeerOnlyWhenAnotherBoundedCandidateRemains(t *testing.T) { + routes := newController(2, Options{OperationTimeoutMs: 20_000}) + addViewer(routes, B, 2, nil) + addViewer(routes, C, 2, nil) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + routes.hydrateEdge(C, peerEdge(HOST, "c_from_host")) + addViewer(routes, A, 0, ms(0)) + + operation := must(t, routes.Reconcile(0).Operation) + beginCandidate(t, routes, BeginInput{NowMs: 100, ConnectionID: "first_silent_candidate", Reservation: direct()}) + eq(t, must(t, routes.Snapshot().Operation).WakeAtMs, 5_100) + eq(t, routes.OperationExpired(5_100).Accepted, true) + + beginCandidate(t, routes, BeginInput{NowMs: 5_200, ConnectionID: "second_silent_candidate", Reservation: direct()}) + eq(t, must(t, routes.Snapshot().Operation).WakeAtMs, operation.DeadlineAtMs) + eq(t, must(t, routes.Snapshot().Operation).DeadlineAtMs, operation.DeadlineAtMs) + eq(t, routes.OperationExpired(10_200).Accepted, false) + eq(t, must(t, routes.Snapshot().Operation).Current.ConnectionID, "second_silent_candidate") +} + +// TS 1281: retains a peer-only direct attempt through the total operation deadline +func TestRetainsPeerOnlyDirectAttemptThroughTotalDeadline(t *testing.T) { + routes := newController(1, Options{OperationTimeoutMs: 20_000}) + addViewer(routes, A, 0, ms(0)) + + operation := must(t, routes.Reconcile(0).Operation) + beginCandidate(t, routes, BeginInput{NowMs: 100, ConnectionID: "only_direct_candidate", Reservation: direct()}) + + eq(t, must(t, routes.Snapshot().Operation).WakeAtMs, operation.DeadlineAtMs) + eq(t, routes.OperationExpired(5_100).Accepted, false) + eq(t, must(t, routes.Snapshot().Operation).Current.ConnectionID, "only_direct_candidate") + expired := routes.OperationExpired(operation.DeadlineAtMs) + eq(t, expired.Accepted, true) + eqSlice(t, expired.FailedPeerIDs, []string{A}) +} + +// TS 1305: keeps only fresh exact-edge quality shadow aggregates +func TestKeepsOnlyFreshExactEdgeQualityShadowAggregates(t *testing.T) { + routes := newController(2, Options{}) + addViewer(routes, A, 1, ms(0)) + routes.Reconcile(10) + commitCurrent(t, routes, 20, "a_quality_connection") + active := routes.Snapshot() + + eq(t, routes.ObserveQualityEvidence(qualityWindow( + A, active.Revision, "a_quality_connection", peerUpstream(HOST), 1, 1_000, qualityMetrics())), QualityEvidenceObserved) + eq(t, routes.Snapshot().Revision, active.Revision) + eq(t, routes.Snapshot().FactVersion, active.FactVersion) + addViewer(routes, B, 0, ms(1_100)) + routes.Reconcile(1_200) + commitCurrent(t, routes, 1_210, "b_quality_connection") + quality := routes.RouteDiagnosticSnapshot(1_300).Children[0].Quality + if quality == nil { + t.Fatal("expected quality aggregates") + } + eq(t, *quality, protocol.RouteDiagnosticQuality{ + EligibleWindows: 1, + EligibleDurationMs: 2_000, + FreezeWindows: 1, + FreezeCount: 1, + FreezeDurationMs: 250, + PauseCount: 0, + PauseDurationMs: 0, + }) + if routes.RouteDiagnosticSnapshot(5_999).Children[0].Quality == nil { + t.Fatal("quality expired before 6_000") + } + if routes.RouteDiagnosticSnapshot(6_000).Children[0].Quality != nil { + t.Fatal("quality did not expire at 6_000") + } + + nextRevision := routes.Snapshot().Revision + nullDecoded := qualityMetrics() + nullDecoded.FramesDecodedDelta = nil + eq(t, routes.ObserveQualityEvidence(qualityWindow( + A, nextRevision, "a_quality_connection", peerUpstream(HOST), 2, 7_000, nullDecoded)), QualityEvidenceAccepted) + if routes.RouteDiagnosticSnapshot(7_100).Children[0].Quality != nil { + t.Fatal("expected no quality after an accepted-only window") + } + eq(t, routes.ObserveQualityEvidence(qualityWindow( + A, nextRevision, "a_quality_connection", peerUpstream(HOST), 2, 7_100, qualityMetrics())), QualityEvidenceObserved) + eq(t, routes.ObserveQualityEvidence(qualityWindow( + A, nextRevision, "a_quality_connection", peerUpstream(HOST), 1, 7_200, qualityMetrics())), QualityEvidenceRejected) + + routes.SetPaused(true, ms(7_300)) + if routes.RouteDiagnosticSnapshot(7_301).Children[0].Quality != nil { + t.Fatal("expected no quality while paused") + } +} + +// TS 1390: waits for a fresh healthy candidate sender after relative proof +func TestWaitsForFreshHealthyCandidateSenderAfterRelativeProof(t *testing.T) { + routes := newController(2, Options{QualityConvergenceEnabled: true}) + addViewer(routes, A, 2, nil) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + eq(t, observeSenderState(routes, senderState{ + childPeerID: B, connectionID: "b_from_host", state: SenderQualityHealthy, acceptedAtMs: 100, + }).Accepted, true) + observePersistentDegraded(t, routes, A, "a_from_host", 99) + routes.Reconcile(103) + begin := beginCandidate(t, routes, BeginInput{NowMs: 104, ConnectionID: "a_from_b", Reservation: direct()}) + current := must(t, begin.Operation).Current + eq(t, routes.CandidateTransportConnected(guardFor(A, current.Revision, "a_from_b"), 105).Accepted, true) + eq(t, routes.ObserveSenderQualityEvidence(senderSample( + B, A, current.Revision, "a_from_b", "a-from-b-rtp\x00track", SenderQualityDegraded, 105), nil).Accepted, true) + eq(t, routes.CandidateReady(guardFor(A, current.Revision, "a_from_b"), 106, nil, CandidateProof{RelativeQualityApproved: true}).Committed, false) + eq(t, routes.ObserveSenderQualityEvidence(senderSample( + B, A, current.Revision, "a_from_b", "a-from-b-rtp\x00track", SenderQualityHealthy, 107), nil).Committed, true) +} + +// TS 1464: does not commit a relative proof after its freshness window expires +func TestDoesNotCommitRelativeProofAfterFreshnessWindowExpires(t *testing.T) { + routes := newController(2, Options{QualityConvergenceEnabled: true}) + addViewer(routes, A, 2, nil) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + observePersistentDegraded(t, routes, A, "a_from_host", 99) + routes.Reconcile(103) + begin := beginCandidate(t, routes, BeginInput{NowMs: 104, ConnectionID: "a_from_b_stale_proof", Reservation: direct()}) + current := must(t, begin.Operation).Current + guard := guardFor(A, current.Revision, "a_from_b_stale_proof") + eq(t, routes.CandidateTransportConnected(guard, 105).Accepted, true) + + eq(t, routes.CandidateReady(guard, 105, nil, CandidateProof{RelativeQualityApproved: true}).Committed, false) + eq(t, routes.CandidateReady(guard, 109, nil, CandidateProof{RelativeQualityApproved: true}).Committed, false) + for _, acceptedAtMs := range []int64{5_105, 5_106, 5_107} { + eq(t, observeSenderState(routes, senderState{ + childPeerID: A, connectionID: "a_from_host", state: SenderQualityDegraded, acceptedAtMs: acceptedAtMs, + }).Accepted, true) + } + settled := routes.ObserveSenderQualityEvidence(senderSample( + B, A, current.Revision, guard.ConnectionID, "stale-proof-rtp\x00track", SenderQualityHealthy, 5_111), nil) + eq(t, settled.Accepted, true) + eq(t, settled.Committed, false) + operation := must(t, routes.Snapshot().Operation) + eq(t, operation.Reason, DemandQualityConvergence) + eq(t, operation.Cursor, 1) + edge := edgeOf(t, routes, A) + eq(t, edge.ParentPeerID, HOST) + eq(t, edge.ConnectionID, "a_from_host") +} + +// TS 1528: abandons a pending quality move when the incumbent recovers +func TestAbandonsPendingQualityMoveWhenIncumbentRecovers(t *testing.T) { + routes := newController(2, Options{QualityConvergenceEnabled: true}) + addViewer(routes, A, 2, nil) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + observePersistentDegraded(t, routes, A, "a_from_host", 99) + routes.Reconcile(103) + begin := beginCandidate(t, routes, BeginInput{NowMs: 104, ConnectionID: "a_from_b_incumbent_recovery", Reservation: direct()}) + current := must(t, begin.Operation).Current + guard := guardFor(A, current.Revision, "a_from_b_incumbent_recovery") + eq(t, routes.CandidateReady(guard, 105, nil, CandidateProof{RelativeQualityApproved: true}).Committed, false) + + eq(t, observeSenderState(routes, senderState{ + childPeerID: A, connectionID: "a_from_host", state: SenderQualityHealthy, acceptedAtMs: 106, + }).Accepted, true) + noOperation(t, routes.Snapshot().Operation) + eq(t, routes.ObserveSenderQualityEvidence(senderSample( + B, A, current.Revision, guard.ConnectionID, "late-healthy-rtp\x00track", SenderQualityHealthy, 107), nil).Accepted, false) + edge := edgeOf(t, routes, A) + eq(t, edge.ParentPeerID, HOST) + eq(t, edge.ConnectionID, "a_from_host") +} + +// TS 2086-2095: when candidateReady abandons a quality move because the +// incumbent is no longer degraded, activeRevision is the revision that was +// active before the abort (the TS literal reads this.revision before +// abortOperation advances it), even though the abort itself advances it. +func TestReportsPreAbortRevisionWhenCandidateReadyAbandonsQualityMove(t *testing.T) { + routes := newController(2, Options{QualityConvergenceEnabled: true}) + addViewer(routes, A, 2, nil) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + observePersistentDegraded(t, routes, A, "a_from_host", 99) + routes.Reconcile(103) + begin := beginCandidate(t, routes, BeginInput{NowMs: 104, ConnectionID: "a_from_b", Reservation: direct()}) + current := must(t, begin.Operation).Current + before := routes.Snapshot().Revision + eq(t, routes.ObserveSenderQualityEvidence(unknownSample(HOST, A, before, "a_from_host", 105), nil).Accepted, true) + settled := routes.CandidateReady(guardFor(A, current.Revision, "a_from_b"), 106, nil, CandidateProof{}) + eq(t, settled.Accepted, true) + eq(t, settled.Committed, false) + noOperation(t, routes.Snapshot().Operation) + eq(t, settled.ActiveRevision, before) + if routes.Snapshot().Revision <= before { + t.Fatalf("expected the abort to advance the revision past %d, got %d", before, routes.Snapshot().Revision) + } +} + +// TS 1582: persists relative proof while candidate sender evidence is unknown +func TestPersistsRelativeProofWhileCandidateSenderEvidenceIsUnknown(t *testing.T) { + routes := newController(2, Options{QualityConvergenceEnabled: true}) + addViewer(routes, A, 2, nil) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + observeSenderState(routes, senderState{childPeerID: B, connectionID: "b_from_host", state: SenderQualityHealthy, acceptedAtMs: 99}) + observePersistentDegraded(t, routes, A, "a_from_host", 99) + routes.Reconcile(103) + begin := beginCandidate(t, routes, BeginInput{NowMs: 104, ConnectionID: "a_from_b", Reservation: direct()}) + current := must(t, begin.Operation).Current + eq(t, routes.ObserveSenderQualityEvidence(unknownSample(B, A, current.Revision, "a_from_b", 105), nil).Accepted, true) + eq(t, routes.CandidateReady(guardFor(A, current.Revision, "a_from_b"), 106, nil, CandidateProof{RelativeQualityApproved: true}).Committed, false) + eq(t, routes.ObserveSenderQualityEvidence(senderSample( + B, A, current.Revision, "a_from_b", "a-from-b-rtp\x00track", SenderQualityHealthy, 107), nil).Committed, true) + edge := edgeOf(t, routes, A) + eq(t, edge.Kind, UpstreamPeer) + eq(t, edge.ParentPeerID, B) + eq(t, edge.ConnectionID, "a_from_b") +} + +// TS 1648: advances past a persistently limited quality candidate +func TestAdvancesPastPersistentlyLimitedQualityCandidate(t *testing.T) { + routes := newController(3, Options{QualityConvergenceEnabled: true}) + for _, seed := range [][2]string{{A, "a_from_host"}, {B, "b_from_host"}, {C, "c_from_host"}} { + addViewer(routes, seed[0], 3, nil) + routes.hydrateEdge(seed[0], peerEdge(HOST, seed[1])) + } + observeSenderState(routes, senderState{childPeerID: B, connectionID: "b_from_host", state: SenderQualityHealthy, acceptedAtMs: 99}) + observeSenderState(routes, senderState{childPeerID: C, connectionID: "c_from_host", state: SenderQualityHealthy, acceptedAtMs: 99}) + observePersistentDegraded(t, routes, A, "a_from_host", 99) + + operation := must(t, routes.Reconcile(103).Operation) + deadlineAtMs := operation.DeadlineAtMs + eq(t, operation.Candidates[0].Tuple.Kind, UpstreamPeer) + firstParent := operation.Candidates[0].Tuple.ParentPeerID + first := beginOperation(t, routes, BeginInput{NowMs: 104, ConnectionID: "first_quality_candidate", Reservation: direct()}) + var lastResult SenderQualityEvidenceResult + for _, acceptedAtMs := range []int64{105, 106, 107} { + lastResult = routes.ObserveSenderQualityEvidence(senderSample( + firstParent, A, first.Current.Revision, "first_quality_candidate", "first-quality-rtp\x00track", + SenderQualityDegraded, acceptedAtMs), nil) + eq(t, lastResult.Accepted, true) + } + eq(t, lastResult.Committed, false) + edge := edgeOf(t, routes, A) + eq(t, edge.ParentPeerID, HOST) + eq(t, edge.ConnectionID, "a_from_host") + pending := must(t, routes.Snapshot().Operation) + eq(t, pending.Cursor, 1) + eq(t, pending.DeadlineAtMs, deadlineAtMs) + if pending.Current != nil { + t.Fatal("expected no live attempt") + } + + secondParent := pending.Candidates[1].Tuple.ParentPeerID + if secondParent == firstParent { + t.Fatalf("second parent %q repeats the first", secondParent) + } + second := beginOperation(t, routes, BeginInput{NowMs: 108, ConnectionID: "second_quality_candidate", Reservation: direct()}) + eq(t, routes.ObserveSenderQualityEvidence(unknownSample( + secondParent, A, second.Current.Revision, "second_quality_candidate", 109), nil).Accepted, true) + eq(t, routes.CandidateReady(guardFor(A, second.Current.Revision, "second_quality_candidate"), 110, nil, + CandidateProof{RelativeQualityApproved: true}).Committed, false) + eq(t, routes.ObserveSenderQualityEvidence(senderSample( + secondParent, A, second.Current.Revision, "second_quality_candidate", "second-quality-rtp\x00track", + SenderQualityHealthy, 111), nil).Committed, true) + edge = edgeOf(t, routes, A) + eq(t, edge.ParentPeerID, secondParent) + eq(t, edge.ConnectionID, "second_quality_candidate") +} + +// TS 1768: preempts quality work for a Viewer without media +func TestPreemptsQualityWorkForViewerWithoutMedia(t *testing.T) { + routes := newController(2, Options{QualityConvergenceEnabled: true}) + addViewer(routes, A, 2, nil) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + observeSenderState(routes, senderState{childPeerID: B, connectionID: "b_from_host", state: SenderQualityHealthy, acceptedAtMs: 100}) + observePersistentDegraded(t, routes, A, "a_from_host", 99) + eq(t, must(t, routes.Reconcile(103).Operation).Reason, DemandQualityConvergence) + addViewer(routes, C, 0, ms(104)) + join := must(t, routes.Reconcile(105).Operation) + eq(t, join.ChildPeerID, C) + eq(t, join.Reason, DemandJoin) + commitCurrent(t, routes, 106, "c_join_connection") + observeSenderState(routes, senderState{childPeerID: B, connectionID: "b_from_host", state: SenderQualityHealthy, acceptedAtMs: 107}) + observePersistentDegraded(t, routes, A, "a_from_host", 107) + eq(t, must(t, routes.Reconcile(111).Operation).Reason, DemandQualityConvergence) + edge := edgeOf(t, routes, A) + eq(t, edge.ParentPeerID, HOST) + eq(t, edge.ConnectionID, "a_from_host") +} + +// TS 1806: keeps a quality operation reason immutable until availability preempts it +func TestKeepsQualityOperationReasonImmutableUntilAvailabilityPreempts(t *testing.T) { + routes := newController(2, Options{QualityConvergenceEnabled: true}) + addViewer(routes, A, 2, nil) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + observeSenderState(routes, senderState{childPeerID: B, connectionID: "b_from_host", state: SenderQualityHealthy, acceptedAtMs: 99}) + observePersistentDegraded(t, routes, A, "a_from_host", 99) + eq(t, must(t, routes.Reconcile(103).Operation).Reason, DemandQualityConvergence) + + eq(t, routes.InvalidateEdge(edgeGuard(A, "host_session", routes.Snapshot().Revision, "a_from_host"), ms(104)), true) + eq(t, must(t, routes.Snapshot().Operation).Reason, DemandQualityConvergence) + preempted := must(t, routes.Reconcile(105).Operation) + eq(t, preempted.ChildPeerID, A) + eq(t, preempted.Reason, DemandEdgeUnavailable) +} + +// TS 1842: keeps a direct convergence reason immutable until availability preempts it +func TestKeepsDirectConvergenceReasonImmutableUntilAvailabilityPreempts(t *testing.T) { + routes := newController(2, Options{SfuEnabled: true}) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + routes.hydrateHostPublication("publication", res("publication_resource"), "") + addViewer(routes, A, 0, ms(0)) + acquisition := must(t, routes.Reconcile(0).Operation) + beginCandidate(t, routes, BeginInput{NowMs: 1, ConnectionID: "a_head_start", Reservation: direct()}) + routes.OperationExpired(acquisition.WakeAtMs) + sfu := beginOperation(t, routes, BeginInput{NowMs: acquisition.WakeAtMs + 1, ConnectionID: "a_sfu", Reservation: sfuReuse("a_subscription")}) + eq(t, routes.CandidateReady(guardFor(A, sfu.Current.Revision, "a_sfu"), acquisition.WakeAtMs+2, nil, CandidateProof{}).Committed, true) + eq(t, must(t, routes.Reconcile(acquisition.WakeAtMs+3).Operation).Reason, DemandDirectConvergence) + + eq(t, routes.InvalidateEdge(edgeGuard(A, "", routes.Snapshot().Revision, "a_sfu"), ms(acquisition.WakeAtMs+4)), true) + eq(t, must(t, routes.Snapshot().Operation).Reason, DemandDirectConvergence) + preempted := must(t, routes.Reconcile(acquisition.WakeAtMs+5).Operation) + eq(t, preempted.ChildPeerID, A) + eq(t, preempted.Reason, DemandEdgeUnavailable) +} + +// TS 1893: keeps a root convergence reason immutable until availability preempts it +func TestKeepsRootConvergenceReasonImmutableUntilAvailabilityPreempts(t *testing.T) { + routes := newController(2, Options{QualityConvergenceEnabled: true}) + addViewer(routes, B, 2, nil) + addViewer(routes, C, 0, nil) + addViewer(routes, D, 0, nil) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + routes.hydrateEdge(C, peerEdge(B, "c_from_b")) + routes.hydrateEdge(D, peerEdge(B, "d_from_b")) + addViewer(routes, A, 2, ms(10)) + routes.Reconcile(10) + commitCurrent(t, routes, 11, "a_from_host") + convergence := must(t, routes.Reconcile(13).Operation) + eq(t, convergence.Reason, DemandRootConvergence) + oldEdge := edgeOf(t, routes, convergence.ChildPeerID) + eq(t, oldEdge.Kind, UpstreamPeer) + + eq(t, routes.InvalidateEdge(EdgeGuard{ + ChildPeerID: convergence.ChildPeerID, + ChildSessionID: convergence.ChildSessionID, + ParentSessionID: oldEdge.ParentSessionID, + RouteRevision: routes.Snapshot().Revision, + ConnectionID: oldEdge.ConnectionID, + }, ms(14)), true) + eq(t, must(t, routes.Snapshot().Operation).Reason, DemandRootConvergence) + preempted := must(t, routes.Reconcile(15).Operation) + eq(t, preempted.ChildPeerID, convergence.ChildPeerID) + eq(t, preempted.Reason, DemandEdgeUnavailable) +} + +// TS 1931: keeps a usable parent eligible before native state becomes clear +func TestKeepsUsableParentEligibleBeforeNativeStateBecomesClear(t *testing.T) { + routes := newController(2, Options{QualityConvergenceEnabled: true}) + addViewer(routes, A, 2, nil) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + observePersistentDegraded(t, routes, A, "a_from_host", 99) + operation := must(t, routes.Reconcile(103).Operation) + eq(t, operation.ChildPeerID, A) + eq(t, operation.Reason, DemandQualityConvergence) + eqSlice(t, tuples(operation), []CandidateTuple{peerTuple(B), regenerateTuple(HOST)}) +} + +// TS 1957: waits for a fresh candidate sender after prior evidence expires +func TestWaitsForFreshCandidateSenderAfterPriorEvidenceExpires(t *testing.T) { + routes := newController(2, Options{QualityConvergenceEnabled: true}) + addViewer(routes, A, 2, nil) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + observeCurrent := func(childPeerID, connectionID string, state SenderQualityState, acceptedAtMs int64) SenderQualityEvidenceResult { + return routes.ObserveSenderQualityEvidence(senderSample( + HOST, childPeerID, routes.Snapshot().Revision, connectionID, connectionID+"-rtp\x00track", state, acceptedAtMs), nil) + } + eq(t, observeCurrent(B, "b_from_host", SenderQualityHealthy, 100).Accepted, true) + eq(t, observeCurrent(A, "a_from_host", SenderQualityHealthy, 99).Accepted, true) + eq(t, observeCurrent(A, "a_from_host", SenderQualityDegraded, 100).Accepted, true) + eq(t, observeCurrent(A, "a_from_host", SenderQualityDegraded, 101).Accepted, true) + eq(t, observeCurrent(A, "a_from_host", SenderQualityDegraded, 102).Accepted, true) + routes.Reconcile(103) + begin := beginCandidate(t, routes, BeginInput{NowMs: 104, ConnectionID: "a_from_b_stale", Reservation: direct()}) + current := must(t, begin.Operation).Current + eq(t, routes.CandidateTransportConnected(guardFor(A, current.Revision, "a_from_b_stale"), 105).Accepted, true) + eq(t, routes.ObserveSenderQualityEvidence(senderSample( + B, A, current.Revision, "a_from_b_stale", "a-stale-rtp\x00a-track", SenderQualityHealthy, 104), nil).Accepted, true) + for _, acceptedAtMs := range []int64{5_101, 5_102, 5_103} { + eq(t, observeCurrent(A, "a_from_host", SenderQualityDegraded, acceptedAtMs).Accepted, true) + } + eq(t, routes.CandidateReady(guardFor(A, current.Revision, "a_from_b_stale"), 5_104, nil, + CandidateProof{RelativeQualityApproved: true}).Committed, false) + eq(t, routes.ObserveSenderQualityEvidence(senderSample( + B, A, current.Revision, "a_from_b_stale", "a-stale-rtp\x00a-track", SenderQualityHealthy, 5_105), nil).Committed, true) + edge := edgeOf(t, routes, A) + eq(t, edge.ParentPeerID, B) + eq(t, edge.ConnectionID, "a_from_b_stale") +} + +// TS 2063: keeps a usable candidate when its native source category expires +func TestKeepsUsableCandidateWhenNativeSourceCategoryExpires(t *testing.T) { + routes := newController(2, Options{QualityConvergenceEnabled: true}) + addViewer(routes, A, 2, nil) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + observeSenderState(routes, senderState{childPeerID: B, connectionID: "b_from_host", state: SenderQualityHealthy, acceptedAtMs: 0}) + observePersistentDegraded(t, routes, A, "a_from_host", 0) + eq(t, must(t, routes.Reconcile(4).Operation).Reason, DemandQualityConvergence) + begin := beginCandidate(t, routes, BeginInput{NowMs: 5_001, ConnectionID: "expired_candidate", Reservation: direct()}) + eq(t, begin.Accepted, true) + eq(t, must(t, begin.Operation).Current.ConnectionID, "expired_candidate") +} + +// TS 2086: keeps bounded SFU after direct candidates when every Host root is degraded +func TestKeepsBoundedSfuAfterDirectCandidatesWhenEveryHostRootIsDegraded(t *testing.T) { + routes := newController(2, Options{SfuEnabled: true, QualityConvergenceEnabled: true}) + addViewer(routes, A, 1, nil) + addViewer(routes, B, 0, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + observePersistentDegraded(t, routes, A, "a_from_host", 99) + firstRegeneration := must(t, routes.Reconcile(103).Operation) + eq(t, firstRegeneration.Candidates[0].Tuple, regenerateTuple(HOST)) + firstAttempt := beginOperation(t, routes, BeginInput{ + NowMs: 104, ConnectionID: "a_regeneration_failed", Reservation: directOverlap("a_regeneration_overlap"), + }) + eq(t, routes.CandidateFailed(guardFor(A, firstAttempt.Current.Revision, "a_regeneration_failed"), 105).Accepted, true) + eq(t, routes.SetEffectiveCapacity(B, sessionOf(B), 1, nil), true) + observePersistentDegraded(t, routes, B, "b_from_host", 104) + operation := must(t, routes.Reconcile(108).Operation) + childPeerID := operation.ChildPeerID + eq(t, childPeerID, A) + eqSlice(t, tuples(operation), []CandidateTuple{peerTuple(B), sfuTuple(PublicationCreate)}) + directAttempt := beginOperation(t, routes, BeginInput{NowMs: 109, ConnectionID: "quality_peer_candidate", Reservation: direct()}) + failed := routes.CandidateFailed(guardFor(childPeerID, directAttempt.Current.Revision, "quality_peer_candidate"), 110) + eq(t, failed.Accepted, true) + eqSlice(t, failed.FailedPeerIDs, []string{}) + + begin := beginCandidate(t, routes, BeginInput{ + NowMs: 111, + ConnectionID: "quality_sfu_candidate", + Reservation: sfuCreateOverlap("sfu-edge", "sfu-publication", "host-overlap"), + PublicationGeneration: "publication_generation_12345678", + PublicationConnectionID: "publication_connection_12345678", + HostSessionID: "host_session", + }) + current := must(t, begin.Operation).Current + guard := guardFor(childPeerID, current.Revision, "quality_sfu_candidate") + ready := routes.CandidateReady(guard, 112, nil, CandidateProof{}) + eq(t, ready.Accepted, true) + eq(t, ready.Committed, false) + for _, acceptedAtMs := range []int64{113, 114} { + observed := routes.ObserveSfuPublisherQualityEvidence(publisherSample( + "publication_generation_12345678", current.Revision, SenderQualityHealthy, acceptedAtMs), nil) + eq(t, observed.Accepted, true) + eq(t, observed.Committed, false) + ready = routes.CandidateReady(guard, acceptedAtMs, nil, CandidateProof{}) + eq(t, ready.Accepted, true) + eq(t, ready.Committed, false) + } + eq(t, routes.ObserveSfuPublisherQualityEvidence(publisherSample( + "publication_generation_12345678", current.Revision, SenderQualityHealthy, 115), nil).Committed, true) + edge := edgeOf(t, routes, childPeerID) + eq(t, edge.Kind, UpstreamSfu) + eq(t, edge.ConnectionID, "quality_sfu_candidate") +} + +// TS 2201: rejects an SFU quality candidate on degraded publisher evidence +func TestRejectsSfuQualityCandidateOnDegradedPublisherEvidence(t *testing.T) { + routes := newController(2, Options{SfuEnabled: true, QualityConvergenceEnabled: true}) + addViewer(routes, A, 1, nil) + addViewer(routes, B, 0, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + observePersistentDegraded(t, routes, A, "a_from_host", 99) + firstRegeneration := must(t, routes.Reconcile(103).Operation) + eq(t, firstRegeneration.Candidates[0].Tuple, regenerateTuple(HOST)) + firstAttempt := beginOperation(t, routes, BeginInput{ + NowMs: 104, ConnectionID: "a_regeneration_failed", Reservation: directOverlap("a_regeneration_overlap"), + }) + eq(t, routes.CandidateFailed(guardFor(A, firstAttempt.Current.Revision, "a_regeneration_failed"), 105).Accepted, true) + eq(t, routes.SetEffectiveCapacity(B, sessionOf(B), 1, nil), true) + observePersistentDegraded(t, routes, B, "b_from_host", 104) + operation := must(t, routes.Reconcile(108).Operation) + childPeerID := operation.ChildPeerID + eq(t, childPeerID, A) + peerAttempt := beginOperation(t, routes, BeginInput{NowMs: 109, ConnectionID: "quality_peer_candidate", Reservation: direct()}) + eq(t, routes.CandidateFailed(guardFor(childPeerID, peerAttempt.Current.Revision, "quality_peer_candidate"), 110).Accepted, true) + sfuAttempt := beginOperation(t, routes, BeginInput{ + NowMs: 111, + ConnectionID: "quality_sfu_degraded", + Reservation: sfuCreateOverlap("sfu-edge", "sfu-publication", "host-overlap"), + PublicationGeneration: "publication_generation_degraded", + PublicationConnectionID: "publication_connection_degraded", + HostSessionID: "host_session", + }) + current := sfuAttempt.Current + ready := routes.CandidateReady(guardFor(childPeerID, current.Revision, current.ConnectionID), 112, nil, CandidateProof{}) + eq(t, ready.Accepted, true) + eq(t, ready.Committed, false) + degraded := routes.ObserveSfuPublisherQualityEvidence(publisherSample( + "publication_generation_degraded", current.Revision, SenderQualityDegraded, 113), nil) + eq(t, degraded.Accepted, true) + eq(t, degraded.Committed, false) + noOperation(t, routes.Reconcile(114).Operation) + edge := edgeOf(t, routes, childPeerID) + eq(t, edge.Kind, UpstreamPeer) + eq(t, edge.ParentPeerID, HOST) +} + +// TS 2297: abandons Host relief when another Host root recovers during the canary +func TestAbandonsHostReliefWhenAnotherHostRootRecoversDuringCanary(t *testing.T) { + routes := newController(2, Options{SfuEnabled: true, QualityConvergenceEnabled: true}) + addViewer(routes, A, 1, nil) + addViewer(routes, B, 0, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + observePersistentDegraded(t, routes, A, "a_from_host", 99) + observePersistentDegraded(t, routes, B, "b_from_host", 99) + routes.Reconcile(103) + begin := beginCandidate(t, routes, BeginInput{ + NowMs: 104, + ConnectionID: "a_from_sfu", + Reservation: sfuCreateOverlap("sfu-edge", "sfu-publication", "host-overlap"), + PublicationGeneration: "publication_generation_12345678", + PublicationConnectionID: "publication_connection_12345678", + HostSessionID: "host_session", + }) + current := must(t, begin.Operation).Current + eq(t, observeSenderState(routes, senderState{ + childPeerID: B, connectionID: "b_from_host", state: SenderQualityHealthy, acceptedAtMs: 105, + }).Accepted, true) + eq(t, routes.CandidateReady(guardFor(A, current.Revision, "a_from_sfu"), 106, nil, CandidateProof{}).Committed, false) + noOperation(t, routes.Snapshot().Operation) + edge := edgeOf(t, routes, A) + eq(t, edge.Kind, UpstreamPeer) + eq(t, edge.ParentPeerID, HOST) + eq(t, edge.ConnectionID, "a_from_host") +} + +// TS 2350: uses SFU reuse only for multi-edge Host relief with healthy ingress +func TestUsesSfuReuseOnlyForMultiEdgeHostReliefWithHealthyIngress(t *testing.T) { + routes := newController(3, Options{SfuEnabled: true, QualityConvergenceEnabled: true}) + addViewer(routes, A, 0, nil) + addViewer(routes, B, 0, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + routes.hydrateHostPublication("publication_generation_12345678", res("publication_resource"), "") + revision := routes.Snapshot().Revision + observePersistentDegraded(t, routes, A, "a_from_host", 99) + observePersistentDegraded(t, routes, B, "b_from_host", 99) + noOperation(t, routes.Reconcile(103).Operation) + observePersistentSfuHealthy(t, routes, "publication_generation_12345678", revision, 104) + eq(t, must(t, routes.Reconcile(105).Operation).Candidates[0].Tuple, sfuTuple(PublicationReuse)) + begin := beginCandidate(t, routes, BeginInput{NowMs: 106, ConnectionID: "a_from_sfu_reuse", Reservation: sfuReuse("a_subscription")}) + current := must(t, begin.Operation).Current + unknown := publisherSample("publication_generation_12345678", revision, SenderQualityUnknown, 107) + unknown.SampleTimestampMs = nil + eq(t, routes.ObserveSfuPublisherQualityEvidence(unknown, nil).Accepted, true) + eq(t, routes.CandidateReady(guardFor(A, current.Revision, "a_from_sfu_reuse"), 108, nil, CandidateProof{}).Committed, false) + for _, acceptedAtMs := range []int64{2_100, 4_100, 6_100} { + observeSenderState(routes, senderState{childPeerID: A, connectionID: "a_from_host", state: SenderQualityDegraded, acceptedAtMs: acceptedAtMs}) + observeSenderState(routes, senderState{childPeerID: B, connectionID: "b_from_host", state: SenderQualityDegraded, acceptedAtMs: acceptedAtMs}) + settled := routes.ObserveSfuPublisherQualityEvidence(publisherSample( + "publication_generation_12345678", revision, SenderQualityHealthy, acceptedAtMs), nil) + eq(t, settled.Committed, acceptedAtMs == 6_100) + } + edge := edgeOf(t, routes, A) + eq(t, edge.Kind, UpstreamSfu) + eq(t, edge.ConnectionID, "a_from_sfu_reuse") +} + +// TS 2435: accepts an SFU-fed Peer parent from decoded progress without freeze stats +func TestAcceptsSfuFedPeerParentFromDecodedProgressWithoutFreezeStats(t *testing.T) { + routes := newController(2, Options{SfuEnabled: true, QualityConvergenceEnabled: true}) + addViewer(routes, A, 1, nil) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateHostPublication("publication_generation_12345678", res("publication_resource"), "") + routes.hydrateEdge(B, sfuEdge("publication_generation_12345678", "b_from_sfu", "b_subscription")) + revision := routes.Snapshot().Revision + observePersistentSfuHealthy(t, routes, "publication_generation_12345678", revision, 100) + metrics := qualityMetrics() + metrics.FreezeCountDelta = nil + metrics.FreezeDurationMsDelta = nil + metrics.PauseCountDelta = nil + metrics.PauseDurationMsDelta = nil + eq(t, routes.ObserveQualityEvidence(qualityWindow(B, revision, "b_from_sfu", sfuUpstream(), 1, 100, metrics)), QualityEvidenceAccepted) + observePersistentDegraded(t, routes, A, "a_from_host", 99) + eq(t, must(t, routes.Reconcile(103).Operation).Candidates[0].Tuple, peerTuple(B)) +} + +// TS 2489: clears quality shadow for a relay subtree when its source changes +func TestClearsQualityShadowForRelaySubtreeWhenSourceChanges(t *testing.T) { + routes := newController(2, Options{}) + addViewer(routes, A, 2, nil) + addViewer(routes, B, 2, nil) + addViewer(routes, C, 0, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateEdge(B, peerEdge(A, "b_from_a")) + routes.hydrateEdge(C, peerEdge(B, "c_from_b")) + revision := routes.Snapshot().Revision + + relays := [][3]string{{B, A, "b_from_a"}, {C, B, "c_from_b"}} + for _, relay := range relays { + eq(t, routes.ObserveQualityEvidence(qualityWindow( + relay[0], revision, relay[2], peerUpstream(relay[1]), 1, 1_000, qualityMetrics())), QualityEvidenceObserved) + } + withQuality := func(nowMs int64) int { + count := 0 + for _, child := range routes.RouteDiagnosticSnapshot(nowMs).Children { + if child.Quality != nil { + count++ + } + } + return count + } + eq(t, withQuality(1_001), 2) + + eq(t, routes.AdoptDirectConnection(AdoptDirectConnectionInput{ + EdgeGuard: edgeGuard(A, "host_session", revision, "a_from_host"), + NewConnectionID: "a_from_host_recovered", + }), true) + eq(t, withQuality(1_002), 0) + + for _, step := range []struct { + at int64 + expected RouteQualityEvidenceResult + }{{1_100, QualityEvidenceAccepted}, {3_100, QualityEvidenceObserved}} { + for _, relay := range relays { + eq(t, routes.ObserveQualityEvidence(qualityWindow( + relay[0], revision, relay[2], peerUpstream(relay[1]), 1, step.at, qualityMetrics())), step.expected) + } + } + eq(t, withQuality(3_101), 2) +} + +// TS 2569: retains one latest timing sample for a 20-Viewer burst +func TestRetainsOneLatestTimingSampleForTwentyViewerBurst(t *testing.T) { + routes := newController(2, Options{}) + viewerPeerIDs := make([]string, 20) + for index := range viewerPeerIDs { + viewerPeerIDs[index] = fmt.Sprintf("burst_%02d_12345678", index) + } + for _, peerID := range viewerPeerIDs { + addViewer(routes, peerID, 2, ms(0)) + } + + for index, peerID := range viewerPeerIDs { + operationStartedAtMs := int64(index+1) * 10 + eq(t, must(t, routes.Reconcile(operationStartedAtMs).Operation).ChildPeerID, peerID) + commitCurrent(t, routes, operationStartedAtMs+1, peerID+"_connection") + } + + snapshot := routes.RouteDiagnosticSnapshot(250) + eq(t, len(snapshot.Children), 20) + for index, child := range snapshot.Children { + eqDeep(t, child.QueueWaitMs, optInt(int64(index+1)*10)) + eq(t, child.FinalRoute, string(FinalRouteDirect)) + } +} + +// TS 2598: projects latest route timing through snapshot-local ordinals +func TestProjectsLatestRouteTimingThroughSnapshotLocalOrdinals(t *testing.T) { + routes := newController(2, Options{}) + addViewer(routes, A, 1, ms(100)) + + eqDeep(t, routes.RouteDiagnosticSnapshot(150), protocol.RouteDiagnosticSnapshot{ + Children: []protocol.RouteDiagnosticChild{{ + Ordinal: 1, + Parent: protocol.RouteDiagnosticParent{Kind: "none"}, + EffectiveCapacity: 1, + ChildCount: 0, + DemandAgeMs: optInt(50), + QueueWaitMs: nil, + CandidateStartMs: nil, + FirstDecodedFrameMs: nil, + FinalMs: nil, + FinalRoute: "waiting", + RejectionBucket: "none", + Quality: nil, + }}, + Operation: nil, + }) + + operation := must(t, routes.Reconcile(160).Operation) + eqDeep(t, routes.RouteDiagnosticSnapshot(165).Operation, &protocol.RouteDiagnosticOperation{ + ChildOrdinal: 1, + Reason: "join", + Stage: "admission", + Cursor: 0, + CandidateCount: protocol.Int(len(operation.Candidates)), + }) + prepared := beginOperation(t, routes, BeginInput{NowMs: 170, ConnectionID: "a_connection", Reservation: direct()}) + pending := routes.RouteDiagnosticSnapshot(180) + eq(t, len(pending.Children), 1) + eqDeep(t, pending.Children[0].QueueWaitMs, optInt(60)) + eqDeep(t, pending.Children[0].CandidateStartMs, optInt(70)) + eqDeep(t, pending.Children[0].FirstDecodedFrameMs, (*protocol.Int)(nil)) + eqDeep(t, pending.Children[0].FinalMs, (*protocol.Int)(nil)) + if pending.Operation == nil { + t.Fatal("expected a diagnostic operation") + } + eq(t, pending.Operation.ChildOrdinal, 1) + eq(t, pending.Operation.Stage, "first-frame") + eq(t, routes.CandidateReady(guardFor(A, prepared.Current.Revision, "a_connection"), 230, nil, CandidateProof{}).Accepted, true) + settled := routes.RouteDiagnosticSnapshot(240) + eq(t, len(settled.Children), 1) + eq(t, settled.Children[0].Parent, protocol.RouteDiagnosticParent{Kind: "host"}) + eqDeep(t, settled.Children[0].QueueWaitMs, optInt(60)) + eqDeep(t, settled.Children[0].CandidateStartMs, optInt(70)) + eqDeep(t, settled.Children[0].FirstDecodedFrameMs, optInt(130)) + eqDeep(t, settled.Children[0].FinalMs, optInt(130)) + eq(t, settled.Children[0].FinalRoute, "direct") + eq(t, settled.Children[0].RejectionBucket, "none") + if settled.Operation != nil { + t.Fatal("expected no diagnostic operation") + } + serialized, err := json.Marshal(settled) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(serialized), A) { + t.Fatalf("diagnostic snapshot leaks the peer ID: %s", serialized) + } + if strings.Contains(string(serialized), "a_connection") { + t.Fatalf("diagnostic snapshot leaks the connection ID: %s", serialized) + } + + eq(t, routes.InvalidateEdge(edgeGuard(A, "host_session", routes.Snapshot().Revision, "a_connection"), ms(300)), true) + waiting := routes.RouteDiagnosticSnapshot(320).Children[0] + eqDeep(t, waiting.DemandAgeMs, optInt(20)) + eqDeep(t, waiting.QueueWaitMs, (*protocol.Int)(nil)) + eqDeep(t, waiting.CandidateStartMs, (*protocol.Int)(nil)) + eqDeep(t, waiting.FirstDecodedFrameMs, (*protocol.Int)(nil)) + eqDeep(t, waiting.FinalMs, (*protocol.Int)(nil)) + eq(t, waiting.FinalRoute, "waiting") + eq(t, waiting.RejectionBucket, "none") + + eq(t, routes.ConfirmDeparture(A, ms(330)), true) + departed := routes.RouteDiagnosticSnapshot(330) + eq(t, len(departed.Children), 0) + if departed.Operation != nil { + t.Fatal("expected no diagnostic operation") + } +} + +// TS 2704: records a bounded candidate failure without retaining route identity +func TestRecordsBoundedCandidateFailureWithoutRetainingRouteIdentity(t *testing.T) { + routes := newController(1, Options{}) + addViewer(routes, A, 0, ms(0)) + routes.Reconcile(10) + prepared := beginOperation(t, routes, BeginInput{NowMs: 20, ConnectionID: "failed_candidate", Reservation: direct()}) + failed := routes.CandidateFailed(guardFor(A, prepared.Current.Revision, "failed_candidate"), 30) + eq(t, failed.Accepted, true) + eqSlice(t, failed.FailedPeerIDs, []string{A}) + child := routes.RouteDiagnosticSnapshot(30).Children[0] + eqDeep(t, child.FinalMs, optInt(30)) + eq(t, child.FinalRoute, "failed") + eq(t, child.RejectionBucket, "candidate-failed") + + routes.Dispose() + disposed := routes.RouteDiagnosticSnapshot(40) + eq(t, len(disposed.Children), 0) + if disposed.Operation != nil { + t.Fatal("expected no diagnostic operation after dispose") + } +} + +// TS 2737: reports a current child that has no usable candidate +func TestReportsCurrentChildThatHasNoUsableCandidate(t *testing.T) { + routes := newController(1, Options{}) + addViewer(routes, A, 0, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_connection")) + addViewer(routes, B, 0, ms(0)) + + result := routes.Reconcile(10) + noOperation(t, result.Operation) + eqSlice(t, result.FailedPeerIDs, []string{B}) + child := routes.RouteDiagnosticSnapshot(10).Children[1] + eqDeep(t, child.FinalMs, optInt(10)) + eq(t, child.FinalRoute, "failed") + eq(t, child.RejectionBucket, "candidate-failed") +} + +// TS 2753: records SFU admission waiting without advancing the route cursor +func TestRecordsSfuAdmissionWaitingWithoutAdvancingRouteCursor(t *testing.T) { + routes := newController(1, Options{SfuEnabled: true}) + addViewer(routes, A, 0, ms(0)) + directOp := must(t, routes.Reconcile(10).Operation) + eqSlice(t, tupleKinds(directOp), []UpstreamKind{UpstreamPeer, UpstreamSfu}) + eq(t, routes.SkipCurrentCandidate(cursorGuard(directOp), 20, RejectionCandidateFailed).Accepted, true) + sfu := must(t, routes.Snapshot().Operation) + eq(t, routes.NoteCurrentCandidateRejection(cursorGuard(sfu), RejectionSfuAdmission), true) + snapshot := routes.RouteDiagnosticSnapshot(30) + eq(t, len(snapshot.Children), 1) + eq(t, snapshot.Children[0].FinalRoute, "waiting") + eq(t, snapshot.Children[0].RejectionBucket, "sfu-admission") + if snapshot.Operation == nil { + t.Fatal("expected a diagnostic operation") + } + eq(t, *snapshot.Operation, protocol.RouteDiagnosticOperation{ + ChildOrdinal: 1, Reason: "join", Stage: "admission", Cursor: 1, CandidateCount: 2, + }) +} + +// TS 2791: distinguishes first-frame timeout from the total operation deadline +func TestDistinguishesFirstFrameTimeoutFromTotalOperationDeadline(t *testing.T) { + routes := newController(1, Options{SfuEnabled: true}) + addViewer(routes, A, 0, ms(0)) + directOp := must(t, routes.Reconcile(10).Operation) + beginCandidate(t, routes, BeginInput{NowMs: 20, ConnectionID: "silent_direct", Reservation: direct()}) + + eq(t, routes.OperationExpired(directOp.WakeAtMs).Accepted, true) + snapshot := routes.RouteDiagnosticSnapshot(directOp.WakeAtMs) + eq(t, len(snapshot.Children), 1) + eqDeep(t, snapshot.Children[0].CandidateStartMs, (*protocol.Int)(nil)) + eqDeep(t, snapshot.Children[0].FinalMs, (*protocol.Int)(nil)) + eq(t, snapshot.Children[0].FinalRoute, "waiting") + eq(t, snapshot.Children[0].RejectionBucket, "first-frame-timeout") + if snapshot.Operation == nil { + t.Fatal("expected a diagnostic operation") + } + eq(t, snapshot.Operation.Cursor, 1) + eq(t, snapshot.Operation.Stage, "admission") + + deadline := must(t, routes.Snapshot().Operation).DeadlineAtMs + expired := routes.OperationExpired(deadline) + eq(t, expired.Accepted, true) + eqSlice(t, expired.FailedPeerIDs, []string{A}) + child := routes.RouteDiagnosticSnapshot(deadline).Children[0] + eqDeep(t, child.FinalMs, optInt(deadline)) + eq(t, child.FinalRoute, "failed") + eq(t, child.RejectionBucket, "operation-deadline") +} + +// TS 2828: admits 20 Viewers through one bounded event-driven graph at C=%i +func TestAdmitsTwentyViewersThroughOneBoundedGraph(t *testing.T) { + for _, capacity := range []int{1, 2, 3} { + t.Run(fmt.Sprintf("C=%d", capacity), func(t *testing.T) { + routes := newController(capacity, Options{}) + viewerPeerIDs := make([]string, 20) + for index := range viewerPeerIDs { + viewerPeerIDs[index] = fmt.Sprintf("viewer_%02d_12345678", index) + } + for index, peerID := range viewerPeerIDs { + addViewer(routes, peerID, capacity, nil) + operation := must(t, routes.Reconcile(int64(index*2)).Operation) + eq(t, operation.ChildPeerID, peerID) + commitCurrent(t, routes, int64(index*2+1), peerID+"_connection") + } + snapshot := routes.Snapshot() + eq(t, snapshot.UpstreamByViewer.Len(), 20) + for _, parentPeerID := range append([]string{HOST}, viewerPeerIDs...) { + childCount := 0 + for _, edge := range snapshot.UpstreamByViewer.Values() { + if edge.Kind == UpstreamPeer && edge.PhysicalActive && edge.ParentPeerID == parentPeerID { + childCount++ + } + } + if childCount > capacity { + t.Fatalf("parent %q has %d children, capacity %d", parentPeerID, childCount, capacity) + } + } + }) + } +} + +// TS 2856: admits waiting Viewers through existing SFU before direct convergence +func TestAdmitsWaitingViewersThroughExistingSfuBeforeDirectConvergence(t *testing.T) { + routes := newController(2, Options{SfuEnabled: true}) + addViewer(routes, B, 2, nil) + routes.hydrateHostPublication("publication_generation", res("publication_resource"), "") + routes.hydrateEdge(B, sfuEdge("publication_generation", "b_sfu", "b_subscription")) + addViewer(routes, C, 0, nil) + routes.hydrateEdge(C, peerEdge(HOST, "c_from_host")) + waiting := make([]string, 18) + for index := range waiting { + waiting[index] = fmt.Sprintf("silent_%02d_12345678", index) + } + for _, peerID := range waiting { + addViewer(routes, peerID, 0, nil) + } + + nowMs := int64(0) + for index, peerID := range waiting { + directOperation := must(t, routes.Reconcile(nowMs).Operation) + eq(t, directOperation.ChildPeerID, peerID) + eq(t, directOperation.Candidates[0].Tuple.Kind, UpstreamPeer) + beginCandidate(t, routes, BeginInput{NowMs: nowMs + 1, ConnectionID: peerID + "_direct", Reservation: direct()}) + routes.OperationExpired(directOperation.WakeAtMs) + sfuOperation := must(t, routes.Snapshot().Operation) + eq(t, sfuOperation.Candidates[sfuOperation.Cursor].Tuple, sfuTuple(PublicationReuse)) + prepared := beginOperation(t, routes, BeginInput{ + NowMs: directOperation.WakeAtMs + 1, ConnectionID: peerID + "_sfu", Reservation: sfuReuse(peerID + "_subscription"), + }) + eq(t, routes.CandidateReady(guardFor(peerID, prepared.Current.Revision, peerID+"_sfu"), + directOperation.WakeAtMs+2, nil, CandidateProof{}).Accepted, true) + nowMs = directOperation.WakeAtMs + 3 + + bEdge := edgeOf(t, routes, B) + eq(t, bEdge.Kind, UpstreamSfu) + eq(t, bEdge.ConnectionID, "b_sfu") + cEdge := edgeOf(t, routes, C) + eq(t, cEdge.Kind, UpstreamPeer) + eq(t, cEdge.ParentPeerID, HOST) + eq(t, cEdge.ConnectionID, "c_from_host") + admitted := edgeOf(t, routes, peerID) + eq(t, admitted.Kind, UpstreamSfu) + eq(t, admitted.Usable, true) + eq(t, routes.Snapshot().UpstreamByViewer.Len(), index+3) + } + + eq(t, routes.Snapshot().UpstreamByViewer.Len(), 20) + publication := routes.Snapshot().HostPublication + if publication == nil { + t.Fatal("expected a host publication") + } + eq(t, publication.Generation, "publication_generation") + eq(t, publication.Usable, true) + eq(t, must(t, routes.Reconcile(nowMs).Operation).Reason, DemandDirectConvergence) +} + +// TS 2943: builds a deterministic acyclic route without a depth cap at C=%i +func TestBuildsDeterministicAcyclicRouteWithoutDepthCap(t *testing.T) { + for _, capacity := range []int{1, 2, 3} { + t.Run(fmt.Sprintf("C=%d", capacity), func(t *testing.T) { + routes := newController(capacity, Options{}) + for _, peerID := range []string{A, B, C, D} { + addViewer(routes, peerID, capacity, nil) + result := routes.Reconcile(0) + eq(t, must(t, result.Operation).ChildPeerID, peerID) + commitCurrent(t, routes, 1, peerID+"_connection") + } + + snapshot := routes.Snapshot() + eq(t, snapshot.UpstreamByViewer.Len(), 4) + for childPeerID, edge := range snapshot.UpstreamByViewer.All() { + eq(t, edge.Kind, UpstreamPeer) + if edge.ParentPeerID == childPeerID { + t.Fatalf("%q is its own parent", childPeerID) + } + } + if capacity == 1 { + // Pins the stablePairRank/depth ordering: at C=1 D hangs below C. + dEdge := edgeOf(t, routes, D) + eq(t, dEdge.Kind, UpstreamPeer) + eq(t, dEdge.ParentPeerID, C) + } + }) + } +} + +// TS 2969: keeps the old edge until exact candidate first-frame ready +func TestKeepsOldEdgeUntilExactCandidateFirstFrameReady(t *testing.T) { + routes := newController(2, Options{}) + addViewer(routes, A, 2, nil) + addViewer(routes, B, 2, nil) + routes.Reconcile(0) + commitCurrent(t, routes, 1, "old_connection") + routes.Reconcile(2) + commitCurrent(t, routes, 3, "b_connection") + revision := routes.Snapshot().Revision + eq(t, routes.InvalidateEdge(edgeGuard(A, "host_session", revision, "old_connection"), nil), true) + + routes.Reconcile(100) + prepared := beginOperation(t, routes, BeginInput{NowMs: 101, ConnectionID: "new_connection", Reservation: direct()}) + eq(t, edgeOf(t, routes, A).ConnectionID, "old_connection") + eq(t, routes.CandidateReady(guardFor(A, prepared.Current.Revision-1, "new_connection"), 102, nil, CandidateProof{}).Accepted, false) + eq(t, routes.CandidateReady(guardFor(A, prepared.Current.Revision, "new_connection"), 102, nil, CandidateProof{}).Accepted, true) + edge := edgeOf(t, routes, A) + eq(t, edge.ConnectionID, "new_connection") + eq(t, edge.ParentPeerID, B) + eq(t, edge.Usable, true) +} + +// TS 3020: invalidates one exact active Peer edge from its parent idempotently +func TestInvalidatesOneExactActivePeerEdgeFromParentIdempotently(t *testing.T) { + routes := newController(2, Options{}) + addViewer(routes, A, 0, nil) + addViewer(routes, B, 1, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_from_host")) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + report := ParentEdgeGuard{ + ParentPeerID: HOST, + ParentSessionID: "host_session", + RouteRevision: routes.Snapshot().Revision, + ConnectionID: "a_from_host", + } + + eq(t, routes.InvalidateDirectEdgeFromParent(report, ms(10)), true) + afterFirst := routes.Snapshot() + aEdge, _ := afterFirst.UpstreamByViewer.Get(A) + eq(t, aEdge.Usable, false) + eq(t, aEdge.ConnectionID, "a_from_host") + bEdge, _ := afterFirst.UpstreamByViewer.Get(B) + eq(t, bEdge.Usable, true) + eq(t, bEdge.ConnectionID, "b_from_host") + eq(t, routes.InvalidateDirectEdgeFromParent(report, ms(11)), true) + eq(t, routes.Snapshot().FactVersion, afterFirst.FactVersion) + unknown := report + unknown.ConnectionID = "unknown_connection" + eq(t, routes.InvalidateDirectEdgeFromParent(unknown, nil), false) + operation := must(t, routes.Reconcile(12).Operation) + eq(t, operation.ChildPeerID, A) + eq(t, operation.Reason, DemandEdgeUnavailable) + eqSlice(t, tuples(operation), []CandidateTuple{peerTuple(B)}) +} + +// TS 3062: advances one candidate cursor without resetting the operation deadline +func TestAdvancesOneCandidateCursorWithoutResettingOperationDeadline(t *testing.T) { + routes := newController(2, Options{SfuEnabled: true}) + addViewer(routes, A, 2, nil) + operation := must(t, routes.Reconcile(1_000).Operation) + deadline := operation.DeadlineAtMs + first := beginOperation(t, routes, BeginInput{NowMs: 1_001, ConnectionID: "candidate_1", Reservation: direct()}) + failed := routes.CandidateFailed(guardFor(A, first.Current.Revision, "candidate_1"), 1_001) + eq(t, failed.Accepted, true) + if failed.ActiveRevision <= first.Current.Revision { + t.Fatalf("activeRevision %d did not advance past %d", failed.ActiveRevision, first.Current.Revision) + } + eqSlice(t, failed.FailedPeerIDs, []string{}) + eqLabels(t, failed.Released) + pending := must(t, routes.Snapshot().Operation) + eq(t, pending.Cursor, 1) + eq(t, pending.DeadlineAtMs, deadline) + + second := beginOperation(t, routes, BeginInput{ + NowMs: 1_002, + ConnectionID: "candidate_2", + PublicationGeneration: "publication_2", + PublicationConnectionID: "publication_connection_2", + Reservation: sfuCreate("candidate_2_edge", "candidate_2_publication"), + }) + lateReady := routes.CandidateReady(guardFor(A, second.Current.Revision, "candidate_2"), deadline, nil, CandidateProof{}) + eq(t, lateReady.Accepted, false) + eqSlice(t, lateReady.FailedPeerIDs, []string{A}) + eqLabels(t, lateReady.Released, "candidate_2_edge", "candidate_2_publication") + noOperation(t, routes.Snapshot().Operation) + eq(t, routes.CandidateReady(guardFor(A, second.Current.Revision, "candidate_2"), deadline+1, nil, CandidateProof{}).Accepted, false) + noOperation(t, routes.Reconcile(deadline+1).Operation) + routes.TouchExternalFacts() + eq(t, must(t, routes.Reconcile(deadline+2).Operation).ChildPeerID, A) +} + +// TS 3123: reserves one derived deadline stage for direct and SFU +func TestReservesOneDerivedDeadlineStageForDirectAndSfu(t *testing.T) { + routes := newController(2, Options{SfuEnabled: true}) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + addViewer(routes, A, 0, nil) + + operation := must(t, routes.Reconcile(0).Operation) + eq(t, operation.DeadlineAtMs, 10_000) + eq(t, operation.WakeAtMs, 5_000) + for _, want := range []CandidateTuple{peerTuple(B), sfuTuple(PublicationCreate)} { + if !slices.Contains(tuples(operation), want) { + t.Fatalf("candidates %+v do not contain %+v", tuples(operation), want) + } + } + + beginCandidate(t, routes, BeginInput{NowMs: 1, ConnectionID: "silent_direct", Reservation: direct()}) + directExpired := routes.OperationExpired(operation.WakeAtMs) + eq(t, directExpired.Accepted, true) + eqSlice(t, directExpired.FailedPeerIDs, []string{}) + pending := must(t, routes.Snapshot().Operation) + eq(t, pending.DeadlineAtMs, 10_000) + eq(t, pending.WakeAtMs, 10_000) + if pending.Current != nil { + t.Fatal("expected no live attempt") + } + eq(t, pending.Candidates[pending.Cursor].Tuple.Kind, UpstreamSfu) + + prepared := beginOperation(t, routes, BeginInput{ + NowMs: 7_000, + ConnectionID: "sfu_subscription", + PublicationGeneration: "publication_generation", + PublicationConnectionID: "publication_connection", + Reservation: sfuCreate("sfu_edge", "sfu_publication"), + }) + eq(t, routes.CandidateReady(guardFor(A, prepared.Current.Revision, "sfu_subscription"), 9_000, nil, CandidateProof{}).Accepted, true) + edge := edgeOf(t, routes, A) + eq(t, edge.Kind, UpstreamSfu) + eq(t, edge.Usable, true) +} + +// TS 3186: uses the same direct head-start after an early hard failure +func TestUsesSameDirectHeadStartAfterEarlyHardFailure(t *testing.T) { + routes := newController(2, Options{SfuEnabled: true}) + addViewer(routes, D, 0, nil) + routes.hydrateEdge(D, peerEdge(HOST, "host_full")) + routes.hydrateHostPublication("publication_1", res("publication_resource"), "") + for _, peerID := range []string{B, C} { + addViewer(routes, peerID, 1, nil) + routes.hydrateEdge(peerID, sfuEdge("publication_1", peerID+"_sfu", peerID+"_subscription")) + } + addViewer(routes, A, 0, nil) + + operation := must(t, routes.Reconcile(0).Operation) + eqSlice(t, tupleKinds(operation), []UpstreamKind{UpstreamPeer, UpstreamSfu, UpstreamPeer}) + first := beginOperation(t, routes, BeginInput{NowMs: 1, ConnectionID: "first_direct", Reservation: direct()}) + eq(t, routes.CandidateFailed(guardFor(A, first.Current.Revision, "first_direct"), 2).Accepted, true) + pending := must(t, routes.Snapshot().Operation) + eq(t, pending.Cursor, 1) + eq(t, pending.WakeAtMs, operation.WakeAtMs) + eq(t, pending.Candidates[1].Tuple.Kind, UpstreamPeer) +} + +// TS 3231: keeps a transport-connected direct candidate until the total deadline +func TestKeepsTransportConnectedDirectCandidateUntilTotalDeadline(t *testing.T) { + routes := newController(2, Options{SfuEnabled: true}) + addViewer(routes, D, 0, nil) + routes.hydrateEdge(D, peerEdge(HOST, "host_full")) + routes.hydrateHostPublication("publication", res("publication_resource"), "") + addViewer(routes, B, 1, nil) + routes.hydrateEdge(B, sfuEdge("publication", "b_sfu", "b_subscription")) + addViewer(routes, A, 0, nil) + + operation := must(t, routes.Reconcile(0).Operation) + directOp := beginOperation(t, routes, BeginInput{NowMs: 1, ConnectionID: "a_direct", Reservation: direct()}) + eq(t, routes.CandidateTransportConnected(guardFor(A, directOp.Current.Revision, "a_direct"), 1_000).Accepted, true) + eq(t, routes.CandidateTransportConnected(guardFor(A, directOp.Current.Revision, "a_direct"), 1_001).Accepted, true) + eq(t, routes.CandidateTransportConnected(guardFor(A, directOp.Current.Revision, "stale_direct"), 1_002).Accepted, false) + eq(t, routes.Snapshot().Revision, directOp.Current.Revision-1) + eq(t, must(t, routes.Snapshot().Operation).WakeAtMs, operation.DeadlineAtMs) + eq(t, routes.OperationExpired(operation.WakeAtMs).Accepted, false) + eq(t, must(t, routes.Snapshot().Operation).Current.ConnectionID, "a_direct") + eq(t, routes.CandidateReady(guardFor(A, directOp.Current.Revision, "a_direct"), operation.WakeAtMs+1, nil, CandidateProof{}).Accepted, true) + edge := edgeOf(t, routes, A) + eq(t, edge.Kind, UpstreamPeer) + eq(t, edge.ConnectionID, "a_direct") +} + +// TS 3288: keeps SFU active while converging through an SFU-fed parent +func TestKeepsSfuActiveWhileConvergingThroughSfuFedParent(t *testing.T) { + routes := newController(2, Options{SfuEnabled: true}) + addViewer(routes, B, 2, nil) + routes.hydrateHostPublication("publication_generation", res("publication_resource"), "") + routes.hydrateEdge(B, sfuEdge("publication_generation", "b_sfu", "b_subscription")) + addViewer(routes, C, 0, nil) + routes.hydrateEdge(C, peerEdge(HOST, "c_from_host")) + addViewer(routes, A, 0, nil) + + acquisition := must(t, routes.Reconcile(0).Operation) + eq(t, acquisition.Candidates[0].Tuple, peerTuple(B)) + beginCandidate(t, routes, BeginInput{NowMs: 1, ConnectionID: "a_direct_head_start", Reservation: direct()}) + routes.OperationExpired(acquisition.WakeAtMs) + prepared := beginOperation(t, routes, BeginInput{ + NowMs: acquisition.WakeAtMs + 1, ConnectionID: "a_sfu_reuse", Reservation: sfuReuse("a_subscription"), + }) + eq(t, routes.CandidateReady(guardFor(A, prepared.Current.Revision, "a_sfu_reuse"), acquisition.WakeAtMs+2, nil, CandidateProof{}).Accepted, true) + publication := routes.Snapshot().HostPublication + if publication == nil { + t.Fatal("expected a host publication") + } + eq(t, publication.Generation, "publication_generation") + eq(t, publication.Usable, true) + edge := edgeOf(t, routes, A) + eq(t, edge.Kind, UpstreamSfu) + eq(t, edge.PublicationGeneration, "publication_generation") + eq(t, edge.Usable, true) + convergence := must(t, routes.Reconcile(acquisition.WakeAtMs+3).Operation) + eq(t, convergence.ChildPeerID, A) + eq(t, convergence.Reason, DemandDirectConvergence) + eq(t, convergence.Candidates[0].Tuple, peerTuple(B)) + directOp := beginOperation(t, routes, BeginInput{NowMs: acquisition.WakeAtMs + 4, ConnectionID: "a_from_b", Reservation: direct()}) + eq(t, must(t, routes.Snapshot().Operation).WakeAtMs, convergence.DeadlineAtMs) + edge = edgeOf(t, routes, A) + eq(t, edge.Kind, UpstreamSfu) + eq(t, edge.ConnectionID, "a_sfu_reuse") + eq(t, routes.CandidateReady(guardFor(A, directOp.Current.Revision, "a_from_b"), acquisition.WakeAtMs+5, nil, CandidateProof{}).Accepted, true) + edge = edgeOf(t, routes, A) + eq(t, edge.Kind, UpstreamPeer) + eq(t, edge.ParentPeerID, B) + eq(t, edge.ConnectionID, "a_from_b") +} + +// TS 3380: advances exact direct convergence failures without failing SFU +func TestAdvancesExactDirectConvergenceFailuresWithoutFailingSfu(t *testing.T) { + routes := newController(2, Options{SfuEnabled: true}) + addViewer(routes, D, 0, nil) + routes.hydrateEdge(D, peerEdge(HOST, "host_full")) + routes.hydrateHostPublication("publication_1", res("publication_resource"), "") + for _, peerID := range []string{B, C} { + addViewer(routes, peerID, 1, nil) + routes.hydrateEdge(peerID, sfuEdge("publication_1", peerID+"_sfu", peerID+"_subscription")) + } + addViewer(routes, A, 0, ms(0)) + acquisition := must(t, routes.Reconcile(0).Operation) + headStartTuple := acquisition.Candidates[0].Tuple + eq(t, headStartTuple.Kind, UpstreamPeer) + headStartParent := headStartTuple.ParentPeerID + otherParent := B + if headStartParent == B { + otherParent = C + } + beginCandidate(t, routes, BeginInput{NowMs: 1, ConnectionID: "a_direct_head_start", Reservation: direct()}) + routes.OperationExpired(acquisition.WakeAtMs) + sfu := beginOperation(t, routes, BeginInput{NowMs: acquisition.WakeAtMs + 1, ConnectionID: "a_sfu", Reservation: sfuReuse("a_subscription")}) + eq(t, routes.CandidateReady(guardFor(A, sfu.Current.Revision, "a_sfu"), acquisition.WakeAtMs+2, nil, CandidateProof{}).Accepted, true) + + first := must(t, routes.Reconcile(acquisition.WakeAtMs+3).Operation) + eq(t, first.Candidates[0].Tuple.Kind, UpstreamPeer) + eq(t, first.Candidates[0].Tuple.ParentPeerID, otherParent) + failed := beginOperation(t, routes, BeginInput{NowMs: acquisition.WakeAtMs + 4, ConnectionID: "a_direct_followup", Reservation: direct()}) + eqSlice(t, routes.CandidateFailed(guardFor(A, failed.Current.Revision, "a_direct_followup"), acquisition.WakeAtMs+5).FailedPeerIDs, []string{}) + edge := edgeOf(t, routes, A) + eq(t, edge.Kind, UpstreamSfu) + eq(t, edge.ConnectionID, "a_sfu") + retry := must(t, routes.Reconcile(acquisition.WakeAtMs+6).Operation) + eq(t, retry.Candidates[0].Tuple.Kind, UpstreamPeer) + eq(t, retry.Candidates[0].Tuple.ParentPeerID, headStartParent) +} + +// TS 3451: round-robins background direct convergence between SFU Viewers +func TestRoundRobinsBackgroundDirectConvergenceBetweenSfuViewers(t *testing.T) { + routes := newController(2, Options{SfuEnabled: true}) + routes.hydrateHostPublication("publication", res("publication_resource"), "") + for _, peerID := range []string{B, C} { + addViewer(routes, peerID, 2, nil) + routes.hydrateEdge(peerID, sfuEdge("publication", peerID+"_sfu", peerID+"_subscription")) + } + + nowMs := int64(0) + for _, peerID := range []string{A, D} { + addViewer(routes, peerID, 0, ms(nowMs)) + acquisition := must(t, routes.Reconcile(nowMs).Operation) + beginCandidate(t, routes, BeginInput{NowMs: nowMs + 1, ConnectionID: peerID + "_head_start", Reservation: direct()}) + routes.OperationExpired(acquisition.WakeAtMs) + sfu := beginOperation(t, routes, BeginInput{ + NowMs: acquisition.WakeAtMs + 1, ConnectionID: peerID + "_sfu", Reservation: sfuReuse(peerID + "_subscription"), + }) + eq(t, routes.CandidateReady(guardFor(peerID, sfu.Current.Revision, peerID+"_sfu"), acquisition.WakeAtMs+2, nil, CandidateProof{}).Accepted, true) + nowMs = acquisition.WakeAtMs + 3 + } + + first := must(t, routes.Reconcile(nowMs).Operation) + eq(t, first.ChildPeerID, A) + eq(t, first.Reason, DemandDirectConvergence) + firstAttempt := beginOperation(t, routes, BeginInput{NowMs: nowMs + 1, ConnectionID: "a_background_direct", Reservation: direct()}) + eq(t, routes.CandidateFailed(guardFor(A, firstAttempt.Current.Revision, "a_background_direct"), nowMs+2).Accepted, true) + next := must(t, routes.Reconcile(nowMs+3).Operation) + eq(t, next.ChildPeerID, D) + eq(t, next.Reason, DemandDirectConvergence) +} + +// TS 3516: preempts direct convergence when another Viewer needs a route +func TestPreemptsDirectConvergenceWhenAnotherViewerNeedsRoute(t *testing.T) { + routes := newController(2, Options{SfuEnabled: true}) + addViewer(routes, B, 2, nil) + routes.hydrateHostPublication("publication_1", res("publication_resource"), "") + routes.hydrateEdge(B, sfuEdge("publication_1", "b_sfu", "b_subscription")) + addViewer(routes, C, 0, nil) + routes.hydrateEdge(C, peerEdge(HOST, "c_from_host")) + addViewer(routes, A, 0, ms(0)) + acquisition := must(t, routes.Reconcile(0).Operation) + beginCandidate(t, routes, BeginInput{NowMs: 1, ConnectionID: "a_direct_head_start", Reservation: direct()}) + routes.OperationExpired(acquisition.WakeAtMs) + sfu := beginOperation(t, routes, BeginInput{NowMs: acquisition.WakeAtMs + 1, ConnectionID: "a_sfu", Reservation: sfuReuse("a_subscription")}) + routes.CandidateReady(guardFor(A, sfu.Current.Revision, "a_sfu"), acquisition.WakeAtMs+2, nil, CandidateProof{}) + eq(t, must(t, routes.Reconcile(acquisition.WakeAtMs+3).Operation).Reason, DemandDirectConvergence) + beginCandidate(t, routes, BeginInput{NowMs: acquisition.WakeAtMs + 4, ConnectionID: "a_direct_pending", Reservation: directOverlap("a_overlap")}) + + activeRevision := routes.Snapshot().Revision + eq(t, routes.InvalidateEdge(edgeGuard(C, "host_session", activeRevision, "c_from_host"), ms(acquisition.WakeAtMs+5)), true) + reconciled := routes.Reconcile(acquisition.WakeAtMs + 6) + eqLabels(t, reconciled.Released, "a_overlap") + edge := edgeOf(t, routes, A) + eq(t, edge.Kind, UpstreamSfu) + eq(t, edge.ConnectionID, "a_sfu") + preempted := must(t, reconciled.Operation) + eq(t, preempted.ChildPeerID, C) + eq(t, preempted.Reason, DemandEdgeUnavailable) +} + +// TS 3579: owns Host-full SFU bootstrap failure by the waiting demand +func TestOwnsHostFullSfuBootstrapFailureByWaitingDemand(t *testing.T) { + routes := newController(2, Options{SfuEnabled: true}) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + addViewer(routes, C, 0, nil) + routes.hydrateEdge(C, peerEdge(HOST, "c_from_host")) + addViewer(routes, A, 0, nil) + addViewer(routes, D, 0, nil) + + operation := must(t, routes.Reconcile(0).Operation) + eq(t, hasTuple(operation, isSfu), false) + eq(t, operation.WakeAtMs, 5_000) + beginCandidate(t, routes, BeginInput{NowMs: 1, ConnectionID: "a_silent_direct", Reservation: direct()}) + blocked := routes.OperationExpired(operation.WakeAtMs) + eq(t, blocked.Accepted, true) + eqSlice(t, blocked.FailedPeerIDs, []string{}) + eqLabels(t, blocked.Released) + noOperation(t, routes.Snapshot().Operation) + + bootstrap := must(t, routes.Reconcile(operation.WakeAtMs+1).Operation) + eq(t, bootstrap.ChildPeerID, C) + eq(t, bootstrap.DemandPeerID, A) + eq(t, bootstrap.Candidates[0].Tuple, sfuTuple(PublicationCreate)) + eq(t, bootstrap.Candidates[0].EndpointTransition.Kind, TransitionOverlap) + firstCarrier := beginOperation(t, routes, BeginInput{ + NowMs: operation.WakeAtMs + 2, + ConnectionID: "c_bootstrap_sfu", + PublicationGeneration: "c_bootstrap_publication", + PublicationConnectionID: "c_bootstrap_ingress", + Reservation: sfuCreateOverlap("c_bootstrap_subscription", "c_bootstrap_publication_resource", "c_bootstrap_overlap"), + }) + eqSlice(t, routes.CandidateFailed(guardFor(C, firstCarrier.Current.Revision, "c_bootstrap_sfu"), operation.WakeAtMs+3).FailedPeerIDs, []string{}) + cEdge := edgeOf(t, routes, C) + eq(t, cEdge.ConnectionID, "c_from_host") + eq(t, cEdge.Usable, true) + eq(t, cEdge.PhysicalActive, true) + + secondBootstrap := must(t, routes.Reconcile(operation.WakeAtMs+4).Operation) + eq(t, secondBootstrap.ChildPeerID, B) + eq(t, secondBootstrap.DemandPeerID, A) + eq(t, secondBootstrap.Reason, DemandSfuBootstrap) + secondCarrier := beginOperation(t, routes, BeginInput{ + NowMs: operation.WakeAtMs + 5, + ConnectionID: "b_bootstrap_sfu", + PublicationGeneration: "b_bootstrap_publication", + PublicationConnectionID: "b_bootstrap_ingress", + Reservation: sfuCreateOverlap("b_bootstrap_subscription", "b_bootstrap_publication_resource", "b_bootstrap_overlap"), + }) + eqSlice(t, routes.CandidateFailed(guardFor(B, secondCarrier.Current.Revision, "b_bootstrap_sfu"), operation.WakeAtMs+6).FailedPeerIDs, []string{A}) + for _, seed := range [][2]string{{B, "b_from_host"}, {C, "c_from_host"}} { + edge := edgeOf(t, routes, seed[0]) + eq(t, edge.ConnectionID, seed[1]) + eq(t, edge.Usable, true) + eq(t, edge.PhysicalActive, true) + } + nextDemand := must(t, routes.Reconcile(operation.WakeAtMs+7).Operation) + eq(t, nextDemand.ChildPeerID, D) + eq(t, nextDemand.Reason, DemandJoin) + beginCandidate(t, routes, BeginInput{NowMs: operation.WakeAtMs + 8, ConnectionID: "d_direct", Reservation: direct()}) + eqSlice(t, routes.OperationExpired(nextDemand.DeadlineAtMs).FailedPeerIDs, []string{D}) + noOperation(t, routes.Reconcile(nextDemand.DeadlineAtMs+1).Operation) +} + +// TS 3680: abandons bootstrap when a Host slot becomes available +func TestAbandonsBootstrapWhenHostSlotBecomesAvailable(t *testing.T) { + routes := newController(2, Options{SfuEnabled: true}) + eq(t, routes.SetEffectiveCapacity(HOST, "host_session", 1, nil), true) + addViewer(routes, B, 0, nil) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + addViewer(routes, A, 0, nil) + + bootstrap := must(t, routes.Reconcile(0).Operation) + eq(t, bootstrap.ChildPeerID, B) + eq(t, bootstrap.DemandPeerID, A) + eq(t, bootstrap.Reason, DemandSfuBootstrap) + prepared := beginOperation(t, routes, BeginInput{ + NowMs: 1, + ConnectionID: "stale_bootstrap", + PublicationGeneration: "stale_publication", + PublicationConnectionID: "stale_ingress", + Reservation: sfuCreateOverlap("stale_subscription", "stale_publication_resource", "stale_overlap"), + }) + + eq(t, routes.SetEffectiveCapacity(HOST, "host_session", 2, nil), true) + stale := routes.CandidateReady(guardFor(B, prepared.Current.Revision, "stale_bootstrap"), 2, nil, CandidateProof{}) + eq(t, stale.Accepted, false) + containsLabels(t, stale.Released, "stale_subscription", "stale_publication_resource", "stale_overlap") + bEdge := edgeOf(t, routes, B) + eq(t, bEdge.ConnectionID, "b_from_host") + eq(t, bEdge.Usable, true) + eq(t, bEdge.PhysicalActive, true) + join := must(t, routes.Reconcile(3).Operation) + eq(t, join.ChildPeerID, A) + eq(t, join.DemandPeerID, A) + eq(t, join.Reason, DemandJoin) +} + +// TS 3731: tries SFU first after bootstrap and retains the direct suffix +func TestTriesSfuFirstAfterBootstrapAndRetainsDirectSuffix(t *testing.T) { + routes := newController(2, Options{SfuEnabled: true}) + for _, seed := range [][2]string{{B, "b_from_host"}, {C, "c_from_host"}} { + addViewer(routes, seed[0], 1, nil) + routes.hydrateEdge(seed[0], peerEdge(HOST, seed[1])) + } + addViewer(routes, A, 0, nil) + + acquisition := must(t, routes.Reconcile(0).Operation) + beginCandidate(t, routes, BeginInput{NowMs: 1, ConnectionID: "a_direct_head_start", Reservation: direct()}) + routes.OperationExpired(acquisition.WakeAtMs) + bootstrap := must(t, routes.Reconcile(acquisition.WakeAtMs+1).Operation) + carrier := beginOperation(t, routes, BeginInput{ + NowMs: acquisition.WakeAtMs + 2, + ConnectionID: "bootstrap_sfu", + PublicationGeneration: "bootstrap_publication", + PublicationConnectionID: "bootstrap_ingress", + Reservation: sfuCreateOverlap("carrier_subscription", "bootstrap_publication_resource", "bootstrap_overlap"), + }) + eq(t, routes.CandidateReady(CandidateGuard{ + ChildPeerID: bootstrap.ChildPeerID, + ChildSessionID: bootstrap.ChildSessionID, + Revision: carrier.Current.Revision, + ConnectionID: "bootstrap_sfu", + }, acquisition.WakeAtMs+3, nil, CandidateProof{}).Accepted, true) + + demand := must(t, routes.Reconcile(acquisition.WakeAtMs+4).Operation) + eq(t, demand.ChildPeerID, A) + eq(t, demand.DemandPeerID, A) + eq(t, demand.Reason, DemandJoin) + eq(t, demand.Candidates[0].Tuple, sfuTuple(PublicationReuse)) + for _, tuple := range tuplesFrom(demand, 1) { + eq(t, tuple.Kind, UpstreamPeer) + } + subscription := beginOperation(t, routes, BeginInput{NowMs: acquisition.WakeAtMs + 5, ConnectionID: "a_sfu", Reservation: sfuReuse("a_subscription")}) + eq(t, routes.CandidateFailed(guardFor(A, subscription.Current.Revision, "a_sfu"), acquisition.WakeAtMs+6).Accepted, true) + pending := must(t, routes.Snapshot().Operation) + eq(t, pending.Candidates[pending.Cursor].Tuple.Kind, UpstreamPeer) + directOp := beginOperation(t, routes, BeginInput{NowMs: acquisition.WakeAtMs + 7, ConnectionID: "a_deferred_direct", Reservation: direct()}) + eq(t, routes.CandidateReady(guardFor(A, directOp.Current.Revision, "a_deferred_direct"), acquisition.WakeAtMs+8, nil, CandidateProof{}).Accepted, true) + edge := edgeOf(t, routes, A) + eq(t, edge.Kind, UpstreamPeer) + eq(t, edge.ConnectionID, "a_deferred_direct") + convergence := must(t, routes.Reconcile(acquisition.WakeAtMs+9).Operation) + eq(t, convergence.ChildPeerID, bootstrap.ChildPeerID) + eq(t, convergence.Reason, DemandDirectConvergence) +} + +// TS 3819: replans when the current candidate parent fails +func TestReplansWhenCurrentCandidateParentFails(t *testing.T) { + routes := newController(3, Options{}) + addViewer(routes, A, 1, nil) + addViewer(routes, B, 1, nil) + addViewer(routes, C, 1, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_old")) + routes.hydrateEdge(B, peerEdge(HOST, "b_old")) + routes.hydrateEdge(C, peerEdge(HOST, "c_old")) + revision := routes.Snapshot().Revision + eq(t, routes.InvalidateEdge(edgeGuard(A, "host_session", revision, "a_old"), nil), true) + routes.Reconcile(0) + current := beginOperation(t, routes, BeginInput{NowMs: 1, ConnectionID: "a_new", Reservation: direct()}) + currentTuple := current.Current.Tuple + eq(t, currentTuple.Kind, UpstreamPeer) + failedParentPeerID := currentTuple.ParentPeerID + failedParentConnectionID := "c_old" + if failedParentPeerID == B { + failedParentConnectionID = "b_old" + } + eq(t, routes.InvalidateEdge(edgeGuard(failedParentPeerID, "host_session", revision, failedParentConnectionID), nil), true) + eq(t, routes.CandidateReady(guardFor(A, current.Current.Revision, "a_new"), 2, nil, CandidateProof{}).Accepted, false) + replacement := beginOperation(t, routes, BeginInput{NowMs: 2, ConnectionID: "a_new_2", Reservation: direct()}) + eq(t, routes.CandidateReady(guardFor(A, replacement.Current.Revision, "a_new_2"), 3, nil, CandidateProof{}).Accepted, true) + eq(t, must(t, routes.Reconcile(3).Operation).ChildPeerID, failedParentPeerID) +} + +// TS 3886: keeps an active candidate when an unrelated session is replaced +func TestKeepsActiveCandidateWhenUnrelatedSessionIsReplaced(t *testing.T) { + routes := newController(2, Options{}) + for _, seed := range [][2]string{{B, "b_from_host"}, {C, "c_from_host"}} { + addViewer(routes, seed[0], 1, nil) + routes.hydrateEdge(seed[0], peerEdge(HOST, seed[1])) + } + addViewer(routes, A, 0, nil) + operation := must(t, routes.Reconcile(0).Operation) + active := beginOperation(t, routes, BeginInput{NowMs: 1, ConnectionID: "a_active_candidate", Reservation: direct()}) + tuple := active.Current.Tuple + eq(t, tuple.Kind, UpstreamPeer) + unrelatedPeerID := B + if tuple.ParentPeerID == B { + unrelatedPeerID = C + } + + eqLabels(t, routes.UpsertParticipant(viewerInput(unrelatedPeerID, unrelatedPeerID+"_replacement", 1), ms(2))) + current := must(t, routes.Snapshot().Operation).Current + if current == nil { + t.Fatal("expected the live attempt to survive") + } + eq(t, current.Revision, operation.BaseRevision+1) + eq(t, current.ConnectionID, "a_active_candidate") +} + +// TS 3920: uses a freed Host slot for new demand without moving healthy descendants +func TestUsesFreedHostSlotForNewDemandWithoutMovingHealthyDescendants(t *testing.T) { + routes := newController(2, Options{}) + addViewer(routes, A, 2, nil) + addViewer(routes, B, 2, nil) + addViewer(routes, C, 0, nil) + addViewer(routes, D, 0, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_root")) + routes.hydrateEdge(B, peerEdge(HOST, "b_root")) + routes.hydrateEdge(C, peerEdge(A, "c_from_a")) + routes.hydrateEdge(D, peerEdge(B, "d_from_b")) + + eq(t, routes.ConfirmDeparture(B, nil), true) + repair := must(t, routes.Reconcile(0).Operation) + eq(t, repair.ChildPeerID, D) + eq(t, repair.Candidates[0].Tuple, peerTuple(A)) + commitCurrent(t, routes, 1, "d_from_a") + noOperation(t, routes.Reconcile(2).Operation) + eq(t, hasEdge(routes, B), false) + + addViewer(routes, E, 0, nil) + join := must(t, routes.Reconcile(3).Operation) + eq(t, join.ChildPeerID, E) + eq(t, join.Candidates[0].Tuple, peerTuple(HOST)) + commitCurrent(t, routes, 4, "e_from_host") + + cEdge := edgeOf(t, routes, C) + eq(t, cEdge.ParentPeerID, A) + eq(t, cEdge.ConnectionID, "c_from_a") + dEdge := edgeOf(t, routes, D) + eq(t, dEdge.ParentPeerID, A) + eq(t, dEdge.ConnectionID, "d_from_a") + eEdge := edgeOf(t, routes, E) + eq(t, eEdge.ParentPeerID, HOST) + eq(t, eEdge.ConnectionID, "e_from_host") +} + +// TS 3967: keeps grace media, then reparents a confirmed relay departure +func TestKeepsGraceMediaThenReparentsConfirmedRelayDeparture(t *testing.T) { + routes := newController(2, Options{}) + addViewer(routes, A, 2, nil) + addViewer(routes, B, 1, nil) + addViewer(routes, C, 0, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_connection")) + routes.hydrateEdge(B, peerEdge(A, "b_connection")) + routes.hydrateEdge(C, peerEdge(B, "c_connection")) + + eq(t, routes.DisconnectSession(A, sessionOf(A)), true) + noOperation(t, routes.Reconcile(0).Operation) + eq(t, routes.ConfirmDeparture(A, nil), true) + eq(t, must(t, routes.Reconcile(1).Operation).ChildPeerID, B) + commitCurrent(t, routes, 2, "b_reparented") + beforePrune := routes.Snapshot().Revision + after := routes.Reconcile(3) + if !slices.Contains(after.RemovedPeerIDs, A) { + t.Fatalf("removedPeerIds %v does not contain A", after.RemovedPeerIDs) + } + if routes.Snapshot().Revision <= beforePrune { + t.Fatalf("revision %d did not advance past %d", routes.Snapshot().Revision, beforePrune) + } + cEdge := edgeOf(t, routes, C) + eq(t, cEdge.ParentPeerID, B) + eq(t, cEdge.ConnectionID, "c_connection") +} + +// TS 3991: moves newest overflow children and supports effective capacity zero +func TestMovesNewestOverflowChildrenAndSupportsEffectiveCapacityZero(t *testing.T) { + routes := newController(3, Options{}) + addViewer(routes, A, 3, nil) + routes.hydrateEdge(A, peerEdge(HOST, "a_connection")) + for _, peerID := range []string{B, C, D} { + addViewer(routes, peerID, 0, nil) + routes.hydrateEdge(peerID, peerEdge(A, peerID+"_connection")) + } + eq(t, routes.SetEffectiveCapacity(A, sessionOf(A), 1, nil), true) + eq(t, must(t, routes.Reconcile(0).Operation).ChildPeerID, D) + eqLabels(t, routes.SetPaused(true, nil)) + eq(t, routes.SetEffectiveCapacity(A, sessionOf(A), 0, nil), true) + eqLabels(t, routes.SetPaused(false, nil)) + eq(t, must(t, routes.Reconcile(1).Operation).ChildPeerID, D) + + blocked := newController(1, Options{}) + addViewer(blocked, A, 0, nil) + addViewer(blocked, B, 0, nil) + blocked.hydrateEdge(A, peerEdge(HOST, "blocked_a")) + blocked.hydrateEdge(B, peerEdge(A, "blocked_b")) + noOperation(t, blocked.Reconcile(0).Operation) + noOperation(t, blocked.Reconcile(1).Operation) +} + +// TS 4015: aborts pending SFU resources on pause and repairs publication +func TestAbortsPendingSfuResourcesOnPauseAndRepairsPublication(t *testing.T) { + routes := newController(1, Options{SfuEnabled: true}) + addViewer(routes, A, 1, nil) + addViewer(routes, B, 0, nil) + routes.hydrateHostPublication("publication_1", res("publication_resource"), "") + routes.hydrateEdge(A, sfuEdge("publication_1", "a_sfu", "a_subscription")) + operation := must(t, routes.Reconcile(0).Operation) + eq(t, operation.Candidates[0].Tuple, peerTuple(A)) + beginCandidate(t, routes, BeginInput{NowMs: 1, ConnectionID: "b_direct_head_start", Reservation: direct()}) + routes.OperationExpired(operation.WakeAtMs) + beginCandidate(t, routes, BeginInput{NowMs: operation.WakeAtMs + 1, ConnectionID: "b_candidate", Reservation: sfuReuse("b_subscription")}) + eqLabels(t, routes.SetPaused(true, nil), "b_subscription") + noOperation(t, routes.Snapshot().Operation) + eq(t, edgeOf(t, routes, A).ConnectionID, "a_sfu") + routes.SetPaused(false, nil) + eq(t, routes.InvalidateHostPublication(PublicationGuard{ + HostSessionID: "host_session", + RouteRevision: routes.Snapshot().Revision, + Generation: "publication_1", + ConnectionID: "publication:publication_1", + }, nil), true) + routes.TouchExternalFacts() + repair := must(t, routes.Reconcile(2).Operation) + eq(t, repair.ChildPeerID, A) + eq(t, repair.Candidates[0].Tuple, peerTuple(HOST)) + skipCandidate(t, routes, 3) + eq(t, must(t, routes.Snapshot().Operation).Candidates[1].Tuple, sfuTuple(PublicationReplace)) + expectPanic(t, "endpoint overlap reservation", func() { + beginCandidate(t, routes, BeginInput{ + NowMs: 3, + ConnectionID: "a_sfu_2", + PublicationGeneration: "publication_2", + PublicationConnectionID: "publication_connection_2", + Reservation: sfuCreate("a_subscription_2", "publication_resource_2"), + }) + }) + publicationRepair := beginOperation(t, routes, BeginInput{ + NowMs: 3, + ConnectionID: "a_sfu_2", + PublicationGeneration: "publication_2", + PublicationConnectionID: "publication_connection_2", + Reservation: sfuCreateOverlap("a_subscription_2", "publication_resource_2", "publication_overlap"), + }) + repaired := routes.CandidateReady(guardFor(A, publicationRepair.Current.Revision, "a_sfu_2"), 4, nil, CandidateProof{}) + eq(t, repaired.Accepted, true) + eq(t, len(repaired.Released), 3) + containsLabels(t, repaired.Released, "publication_overlap", "a_subscription", "publication_resource") + publication := routes.Snapshot().HostPublication + if publication == nil { + t.Fatal("expected a repaired publication") + } + eq(t, publication.Generation, "publication_2") + eq(t, publication.Usable, true) + eq(t, publication.PhysicalActive, true) + + bootstrap := newController(1, Options{SfuEnabled: true}) + addViewer(bootstrap, A, 0, nil) + addViewer(bootstrap, B, 0, nil) + bootstrap.hydrateEdge(A, peerEdge(HOST, "bootstrap_a")) + bootstrapOperation := must(t, bootstrap.Reconcile(0).Operation) + eq(t, bootstrapOperation.ChildPeerID, A) + eq(t, bootstrapOperation.Candidates[0].Tuple, sfuTuple(PublicationCreate)) + expectPanic(t, "endpoint overlap reservation", func() { + beginCandidate(t, bootstrap, BeginInput{ + NowMs: 2, + ConnectionID: "bootstrap_sfu", + PublicationGeneration: "bootstrap_publication", + PublicationConnectionID: "bootstrap_ingress_connection", + Reservation: sfuCreate("bootstrap_subscription", "bootstrap_ingress"), + }) + }) + preparedBootstrap := beginOperation(t, bootstrap, BeginInput{ + NowMs: 2, + ConnectionID: "bootstrap_sfu", + PublicationGeneration: "bootstrap_publication", + PublicationConnectionID: "bootstrap_ingress_connection", + Reservation: sfuCreateOverlap("bootstrap_subscription", "bootstrap_ingress", "bootstrap_overlap"), + }) + eq(t, bootstrap.CandidateReady(guardFor(A, preparedBootstrap.Current.Revision, "bootstrap_sfu"), 3, nil, CandidateProof{}).Accepted, true) + eq(t, must(t, bootstrap.Reconcile(4).Operation).ChildPeerID, B) +} + +// TS 4153: reopens SFU bootstrap after the physical publication retires +func TestReopensSfuBootstrapAfterPhysicalPublicationRetires(t *testing.T) { + routes := newController(2, Options{SfuEnabled: true}) + addViewer(routes, A, 0, nil) + routes.hydrateHostPublication("retired_publication", res("publication_resource"), "") + routes.hydrateEdge(A, sfuEdge("retired_publication", "a_from_sfu", "a_subscription")) + + publication := routes.Snapshot().HostPublication + if publication == nil { + t.Fatal("expected a host publication") + } + containsLabels(t, routes.RetireHostPublication(PublicationGuard{ + HostSessionID: publication.HostSessionID, + RouteRevision: routes.Snapshot().Revision, + Generation: publication.Generation, + ConnectionID: publication.ConnectionID, + }), "a_subscription", "publication_resource") + if routes.Snapshot().HostPublication != nil { + t.Fatal("expected no host publication") + } + + for _, seed := range [][2]string{{B, "b_from_host"}, {C, "c_from_host"}} { + addViewer(routes, seed[0], 0, nil) + routes.hydrateEdge(seed[0], peerEdge(HOST, seed[1])) + } + reconciled := routes.Reconcile(1) + eqSlice(t, reconciled.FailedPeerIDs, []string{}) + operation := must(t, reconciled.Operation) + eq(t, operation.DemandPeerID, A) + eq(t, operation.Reason, DemandSfuBootstrap) + eqSlice(t, tuples(operation), []CandidateTuple{sfuTuple(PublicationCreate)}) +} + +// TS 4199: binds reserve and skip results to the exact candidate cursor +func TestBindsReserveAndSkipResultsToExactCandidateCursor(t *testing.T) { + reserve := newController(2, Options{}) + addViewer(reserve, A, 2, nil) + reserve.hydrateEdge(A, peerEdge(HOST, "a_active")) + addViewer(reserve, B, 0, nil) + first := must(t, reserve.Reconcile(0).Operation) + eq(t, first.Candidates[0].Tuple.Kind, UpstreamPeer) + eq(t, first.Candidates[0].Tuple.ParentPeerID, HOST) + reserve.SetEffectiveCapacity(HOST, "host_session", 0, nil) + staleReserve := reserve.BeginCurrentCandidate(BeginInput{ + Guard: cursorGuard(first), + NowMs: 1, + ConnectionID: "reserved_for_old_cursor", + Reservation: directOverlap("stale_reservation"), + }) + eq(t, staleReserve.Accepted, false) + eqLabels(t, staleReserve.Released, "stale_reservation") + rebuilt := must(t, reserve.Snapshot().Operation) + eq(t, rebuilt.Cursor, 0) + eqSlice(t, tuples(rebuilt), []CandidateTuple{peerTuple(A)}) + if rebuilt.Current != nil { + t.Fatal("expected no live attempt") + } + + skip := newController(2, Options{}) + addViewer(skip, A, 2, nil) + skip.hydrateEdge(A, peerEdge(HOST, "a_active")) + addViewer(skip, B, 0, nil) + staleCursor := must(t, skip.Reconcile(0).Operation) + skip.SetEffectiveCapacity(HOST, "host_session", 0, nil) + eq(t, skip.SkipCurrentCandidate(cursorGuard(staleCursor), 1, RejectionStale).Accepted, false) + pending := must(t, skip.Snapshot().Operation) + eq(t, pending.Cursor, 0) + eq(t, pending.Candidates[0].Tuple.Kind, UpstreamPeer) + eq(t, pending.Candidates[0].Tuple.ParentPeerID, A) +} + +// TS 4243: keeps only old SFU roots that still anchor peer descendants +func TestKeepsOnlyOldSfuRootsThatStillAnchorPeerDescendants(t *testing.T) { + routes := newController(1, Options{SfuEnabled: true}) + addViewer(routes, A, 0, nil) + addViewer(routes, B, 1, nil) + addViewer(routes, C, 0, nil) + addViewer(routes, D, 0, nil) + routes.hydrateHostPublication("publication_1", res("publication_resource_1"), "") + for _, seed := range [][2]string{{A, "subscription_a"}, {B, "subscription_b"}, {D, "subscription_d"}} { + routes.hydrateEdge(seed[0], sfuEdge("publication_1", seed[0]+"_sfu_1", seed[1])) + } + routes.hydrateEdge(C, peerEdge(B, "c_from_b")) + eq(t, routes.InvalidateHostPublication(PublicationGuard{ + HostSessionID: "host_session", + RouteRevision: 0, + Generation: "publication_1", + ConnectionID: "publication:publication_1", + }, ms(10)), true) + routes.TouchExternalFacts() + operation := must(t, routes.Reconcile(20).Operation) + eq(t, operation.Candidates[0].Tuple, sfuTuple(PublicationReplace)) + expectTransition(t, operation.Candidates[0].EndpointTransition, TransitionOverlap, HOST) + prepared := beginOperation(t, routes, BeginInput{ + NowMs: 21, + ConnectionID: "a_sfu_2", + PublicationGeneration: "publication_2", + PublicationConnectionID: "publication_connection_2", + Reservation: sfuCreateOverlap("subscription_a_2", "publication_resource_2", "publication_overlap"), + }) + settled := routes.CandidateReady(guardFor(A, prepared.Current.Revision, "a_sfu_2"), 22, nil, CandidateProof{}) + eq(t, settled.Accepted, true) + containsLabels(t, settled.Released, "publication_overlap", "subscription_a", "subscription_b", "subscription_d", "publication_resource_1") + eq(t, edgeOf(t, routes, A).PublicationGeneration, "publication_2") + bEdge := edgeOf(t, routes, B) + eq(t, bEdge.Kind, UpstreamSfu) + eq(t, bEdge.PublicationGeneration, "publication_1") + eq(t, bEdge.Usable, false) + eq(t, bEdge.PhysicalActive, false) + cEdge := edgeOf(t, routes, C) + eq(t, cEdge.Kind, UpstreamPeer) + eq(t, cEdge.ParentPeerID, B) + eq(t, hasEdge(routes, D), false) + child := routes.RouteDiagnosticSnapshot(22).Children[1] + eqDeep(t, child.DemandAgeMs, optInt(12)) + eq(t, child.FinalRoute, "waiting") + next := must(t, routes.Reconcile(23).Operation) + eq(t, next.ChildPeerID, B) + eq(t, next.Reason, DemandEdgeUnavailable) +} + +// TS 4328: rebinds direct and SFU media to replacement sessions +func TestRebindsDirectAndSfuMediaToReplacementSessions(t *testing.T) { + peerRoutes := newController(2, Options{}) + addViewer(peerRoutes, A, 1, nil) + addViewer(peerRoutes, B, 0, nil) + peerRoutes.hydrateEdge(A, peerEdge(HOST, "a_direct")) + peerRoutes.hydrateEdge(B, peerEdge(A, "b_direct")) + eqLabels(t, peerRoutes.UpsertParticipant(viewerInput(A, "a_replacement", 1), nil)) + aEdge := edgeOf(t, peerRoutes, A) + eq(t, aEdge.ChildSessionID, "a_replacement") + eq(t, aEdge.Usable, true) + eq(t, aEdge.PhysicalActive, true) + eq(t, edgeOf(t, peerRoutes, B).ParentSessionID, "a_replacement") + eqLabels(t, peerRoutes.UpsertParticipant(viewerInput(B, "b_replacement", 0), nil)) + bEdge := edgeOf(t, peerRoutes, B) + eq(t, bEdge.ChildSessionID, "b_replacement") + eq(t, bEdge.Usable, true) + eq(t, bEdge.PhysicalActive, true) + + sfuRoutes := newController(1, Options{SfuEnabled: true}) + addViewer(sfuRoutes, A, 0, nil) + sfuRoutes.hydrateHostPublication("publication", res("publication_resource"), "publication_connection") + sfuRoutes.hydrateEdge(A, sfuEdge("publication", "subscription_connection", "subscription_resource")) + eqLabels(t, sfuRoutes.UpsertParticipant(hostInput("host_replacement", 1), nil)) + publication := sfuRoutes.Snapshot().HostPublication + if publication == nil { + t.Fatal("expected a host publication") + } + eq(t, publication.HostSessionID, "host_replacement") + eq(t, publication.Usable, true) + eq(t, publication.PhysicalActive, true) + eqLabels(t, sfuRoutes.UpsertParticipant(viewerInput(A, "a_replacement", 0), nil)) + sfuEdgeA := edgeOf(t, sfuRoutes, A) + eq(t, sfuEdgeA.ChildSessionID, "a_replacement") + eq(t, sfuEdgeA.PhysicalActive, true) +} + +// TS 4399: retries a bootstrap carrier only when its transition improves +func TestRetriesBootstrapCarrierOnlyWhenTransitionImproves(t *testing.T) { + routes := newController(1, Options{SfuEnabled: true}) + addViewer(routes, A, 0, nil) + routes.hydrateEdge(A, peerEdge(HOST, "failed_direct")) + eq(t, routes.InvalidateEdge(edgeGuard(A, "host_session", 0, "failed_direct"), nil), true) + operation := must(t, routes.Reconcile(0).Operation) + prepared := beginOperation(t, routes, BeginInput{ + NowMs: 1, + ConnectionID: "failed_sfu", + PublicationGeneration: "failed_publication", + PublicationConnectionID: "failed_publication_connection", + Reservation: sfuCreateOverlap("failed_subscription_resource", "failed_publication_resource", "failed_overlap"), + }) + failed := routes.CandidateFailed(CandidateGuard{ + ChildPeerID: operation.ChildPeerID, + ChildSessionID: operation.ChildSessionID, + Revision: prepared.Current.Revision, + ConnectionID: "failed_sfu", + }, 2) + eq(t, failed.Accepted, true) + eqSlice(t, failed.FailedPeerIDs, []string{A}) + improved := must(t, routes.Reconcile(3).Operation) + eq(t, improved.Candidates[0].Tuple, sfuTuple(PublicationCreate)) + expectTransition(t, improved.Candidates[0].EndpointTransition, TransitionNone, HOST) + improvedAttempt := beginOperation(t, routes, BeginInput{ + NowMs: 4, + ConnectionID: "improved_sfu", + PublicationGeneration: "improved_publication", + PublicationConnectionID: "improved_publication_connection", + Reservation: sfuCreate("improved_subscription_resource", "improved_publication_resource"), + }) + failedAgain := routes.CandidateFailed(CandidateGuard{ + ChildPeerID: improved.ChildPeerID, + ChildSessionID: improved.ChildSessionID, + Revision: improvedAttempt.Current.Revision, + ConnectionID: "improved_sfu", + }, 5) + eq(t, failedAgain.Accepted, true) + eqSlice(t, failedAgain.FailedPeerIDs, []string{A}) + factVersion := routes.Snapshot().FactVersion + noOperation(t, routes.Reconcile(6).Operation) + eq(t, routes.SetEffectiveCapacity(A, sessionOf(A), 0, nil), true) + eq(t, routes.Snapshot().FactVersion, factVersion) + noOperation(t, routes.Reconcile(7).Operation) + eq(t, routes.InvalidateEdge(edgeGuard(A, "host_session", routes.Snapshot().Revision, "failed_direct"), nil), true) + eq(t, routes.Snapshot().FactVersion, factVersion) + noOperation(t, routes.Reconcile(8).Operation) + + addViewer(routes, B, 0, ms(9)) + eq(t, must(t, routes.Reconcile(9).Operation).ChildPeerID, B) + // TS: `while (routes.snapshot().operation) skipCandidate(routes, 10);` bounded here so a broken port cannot hang. + for rounds := 0; routes.Snapshot().Operation != nil; rounds++ { + if rounds > 100 { + t.Fatal("skipping never exhausted the operation") + } + skipCandidate(t, routes, 10) + } + noOperation(t, routes.Reconcile(11).Operation) +} + +// TS 4472: requires one overlap slot for an SFU-to-Host transition at C=%i +func TestRequiresOneOverlapSlotForSfuToHostTransition(t *testing.T) { + for _, capacity := range []int{1, 2} { + t.Run(fmt.Sprintf("C=%d", capacity), func(t *testing.T) { + routes := newController(capacity, Options{SfuEnabled: true}) + addViewer(routes, A, 0, nil) + if capacity == 2 { + addViewer(routes, E, 0, nil) + routes.hydrateEdge(E, peerEdge(HOST, "e_direct")) + } + routes.hydrateHostPublication("publication_1", res("publication_resource_1"), "") + routes.hydrateEdge(A, sfuEdge("publication_1", "a_sfu", "a_subscription")) + routes.InvalidateEdge(edgeGuard(A, "", 0, "a_sfu"), nil) + operation := must(t, routes.Reconcile(0).Operation) + eq(t, operation.Candidates[0].Tuple, peerTuple(HOST)) + expectTransition(t, operation.Candidates[0].EndpointTransition, TransitionOverlap, HOST) + expectPanic(t, "endpoint overlap reservation", func() { + beginCandidate(t, routes, BeginInput{NowMs: 1, ConnectionID: "a_host_direct", Reservation: direct()}) + }) + }) + } +} + +// TS 4510: uses one bounded gap instead of a fourth Host copy at C=3 +func TestUsesOneBoundedGapInsteadOfFourthHostCopyAtCapacityThree(t *testing.T) { + routes := newController(3, Options{SfuEnabled: true}) + addViewer(routes, A, 0, nil) + for _, peerID := range []string{E, F} { + addViewer(routes, peerID, 0, nil) + routes.hydrateEdge(peerID, peerEdge(HOST, peerID+"_direct")) + } + routes.hydrateHostPublication("publication_1", res("publication_resource_1"), "") + routes.hydrateEdge(A, sfuEdge("publication_1", "a_sfu", "a_subscription")) + routes.InvalidateEdge(edgeGuard(A, "", 0, "a_sfu"), nil) + operation := must(t, routes.Reconcile(10).Operation) + deadline := operation.DeadlineAtMs + eq(t, operation.Candidates[0].Tuple, peerTuple(HOST)) + transition := operation.Candidates[0].EndpointTransition + expectTransition(t, transition, TransitionBoundedGap, HOST) + if transition.Retire == nil { + t.Fatal("expected a retirement") + } + eq(t, transition.Retire.Kind, RetirePublication) + eq(t, transition.Retire.Generation, "publication_1") + gap := routes.RetireCurrentCandidateProducer(cursorGuard(operation), 11) + eq(t, gap.Accepted, true) + eqLabels(t, gap.Released, "a_subscription", "publication_resource_1") + replanned := must(t, gap.Operation) + eq(t, replanned.DeadlineAtMs, deadline) + eq(t, len(replanned.Candidates), 1) + expectTransition(t, replanned.Candidates[0].EndpointTransition, TransitionNone, HOST) + prepared := beginOperation(t, routes, BeginInput{NowMs: 12, ConnectionID: "a_host_direct", Reservation: direct()}) + eq(t, routes.CandidateReady(guardFor(A, prepared.Current.Revision, "a_host_direct"), 13, nil, CandidateProof{}).Accepted, true) + if routes.Snapshot().HostPublication != nil { + t.Fatal("expected no host publication") + } +} + +// TS 4564: does not reopen consumed none transitions after capacity reduction +func TestDoesNotReopenConsumedNoneTransitionsAfterCapacityReduction(t *testing.T) { + routes := newController(2, Options{SfuEnabled: true}) + addViewer(routes, A, 0, nil) + routes.hydrateHostPublication("publication", res("publication_resource"), "") + routes.hydrateEdge(A, sfuEdge("publication", "a_from_sfu", "a_subscription")) + eq(t, routes.InvalidateEdge(edgeGuard(A, "", 0, "a_from_sfu"), nil), true) + operation := must(t, routes.Reconcile(0).Operation) + eq(t, operation.Candidates[0].Tuple, peerTuple(HOST)) + expectTransition(t, operation.Candidates[0].EndpointTransition, TransitionNone, HOST) + + eq(t, routes.SetEffectiveCapacity(HOST, "host_session", 1, nil), true) + eqSlice(t, routes.OperationExpired(operation.DeadlineAtMs).FailedPeerIDs, []string{A}) + noOperation(t, routes.Reconcile(operation.DeadlineAtMs+1).Operation) +} + +// TS 4596: does not unblock an exhausted demand for an unrelated join +func TestDoesNotUnblockExhaustedDemandForUnrelatedJoin(t *testing.T) { + routes := newController(1, Options{}) + addViewer(routes, B, 0, nil) + routes.hydrateEdge(B, peerEdge(HOST, "b_from_host")) + addViewer(routes, A, 0, ms(0)) + eqSlice(t, routes.Reconcile(0).FailedPeerIDs, []string{A}) + + eq(t, routes.SetEffectiveCapacity(A, sessionOf(A), 1, nil), true) + noOperation(t, routes.Reconcile(1).Operation) + + addViewer(routes, C, 0, ms(2)) + eqSlice(t, routes.Reconcile(2).FailedPeerIDs, []string{C}) + noOperation(t, routes.Snapshot().Operation) + + eq(t, routes.SetEffectiveCapacity(B, sessionOf(B), 1, nil), true) + operation := must(t, routes.Reconcile(3).Operation) + eq(t, operation.ChildPeerID, A) + eqSlice(t, tuples(operation), []CandidateTuple{peerTuple(B)}) +} + +// TS 4621: does not revive an exact failed Host tuple when a new parent joins +func TestDoesNotReviveExactFailedHostTupleWhenNewParentJoins(t *testing.T) { + routes := newController(1, Options{}) + addViewer(routes, A, 0, nil) + routes.hydrateEdge(A, peerEdge(HOST, "failed_direct")) + eq(t, routes.InvalidateEdge(edgeGuard(A, "host_session", 0, "failed_direct"), nil), true) + noOperation(t, routes.Reconcile(0).Operation) + + addViewer(routes, B, 1, ms(1)) + join := must(t, routes.Reconcile(2).Operation) + eq(t, join.ChildPeerID, B) + eq(t, join.Reason, DemandJoin) + commitCurrent(t, routes, 2, "b_from_host") + retry := must(t, routes.Reconcile(4).Operation) + eq(t, retry.ChildPeerID, A) + eqSlice(t, tuples(retry), []CandidateTuple{peerTuple(B)}) + eq(t, hasTuple(retry, func(tuple CandidateTuple) bool { return isPeer(tuple) && tuple.ParentPeerID == HOST }), false) +} + +// TS 4660: does not unblock an exhausted Viewer when its candidate set shrinks +func TestDoesNotUnblockExhaustedViewerWhenCandidateSetShrinks(t *testing.T) { + routes := newController(1, Options{}) + addViewer(routes, A, 0, nil) + routes.hydrateEdge(A, peerEdge(HOST, "failed_host")) + eq(t, routes.InvalidateEdge(edgeGuard(A, "host_session", 0, "failed_host"), nil), true) + noOperation(t, routes.Reconcile(0).Operation) + + addViewer(routes, B, 1, ms(1)) + routes.Reconcile(1) + commitCurrent(t, routes, 2, "b_from_host") + candidate := must(t, routes.Reconcile(3).Operation) + attempt := beginOperation(t, routes, BeginInput{NowMs: 4, ConnectionID: "a_from_b", Reservation: direct()}) + eqSlice(t, routes.CandidateFailed(guardFor(A, attempt.Current.Revision, "a_from_b"), 5).FailedPeerIDs, []string{A}) + eq(t, len(candidate.Candidates), 1) + + eq(t, routes.DisconnectSession(B, sessionOf(B)), true) + noOperation(t, routes.Reconcile(6).Operation) +} + +// TS 4694: treats a replacement child session as a new exact opportunity +func TestTreatsReplacementChildSessionAsNewExactOpportunity(t *testing.T) { + routes := newController(1, Options{}) + addViewer(routes, A, 0, nil) + routes.hydrateEdge(A, peerEdge(HOST, "failed_host")) + eq(t, routes.InvalidateEdge(edgeGuard(A, "host_session", 0, "failed_host"), nil), true) + noOperation(t, routes.Reconcile(0).Operation) + + routes.UpsertParticipant(viewerInput(A, "replacement_session", 0), nil) + operation := must(t, routes.Reconcile(1).Operation) + eq(t, operation.ChildPeerID, A) + eq(t, operation.ChildSessionID, "replacement_session") + eqSlice(t, tuples(operation), []CandidateTuple{peerTuple(HOST)}) +} + +// TS 4724: does not retry a failed bootstrap carrier when another carrier leaves +func TestDoesNotRetryFailedBootstrapCarrierWhenAnotherCarrierLeaves(t *testing.T) { + routes := newController(2, Options{SfuEnabled: true}) + for _, seed := range [][2]string{{B, "b_from_host"}, {C, "c_from_host"}} { + addViewer(routes, seed[0], 1, nil) + routes.hydrateEdge(seed[0], peerEdge(HOST, seed[1])) + } + addViewer(routes, A, 0, ms(0)) + acquisition := must(t, routes.Reconcile(0).Operation) + beginCandidate(t, routes, BeginInput{NowMs: 1, ConnectionID: "a_head_start", Reservation: direct()}) + routes.OperationExpired(acquisition.WakeAtMs) + + bootstrap := must(t, routes.Reconcile(acquisition.WakeAtMs+1).Operation) + eq(t, bootstrap.Reason, DemandSfuBootstrap) + failedCarrier := bootstrap.ChildPeerID + otherCarrier := B + if failedCarrier == B { + otherCarrier = C + } + attempt := beginOperation(t, routes, BeginInput{ + NowMs: acquisition.WakeAtMs + 2, + ConnectionID: "failed_bootstrap", + PublicationGeneration: "failed_publication", + PublicationConnectionID: "failed_publication_connection", + Reservation: sfuCreateOverlap("failed_subscription", "failed_publication_resource", "failed_overlap"), + }) + eq(t, routes.CandidateFailed(guardFor(failedCarrier, attempt.Current.Revision, "failed_bootstrap"), acquisition.WakeAtMs+3).Accepted, true) + + eq(t, routes.DisconnectSession(otherCarrier, sessionOf(otherCarrier)), true) + next := routes.Reconcile(acquisition.WakeAtMs + 4).Operation + if next != nil && next.Reason == DemandSfuBootstrap && next.ChildPeerID == failedCarrier { + t.Fatalf("retried the failed bootstrap carrier %q", failedCarrier) + } +} + +// TS 4775: does not bootstrap a new SFU generation after the last reuse opportunity expires +func TestDoesNotBootstrapNewSfuGenerationAfterLastReuseOpportunityExpires(t *testing.T) { + routes, nowMs := retireLastSfuPublicationAfterReuseDeadline(t) + + reconciled := routes.Reconcile(nowMs) + noOperation(t, reconciled.Operation) + eqSlice(t, reconciled.FailedPeerIDs, []string{}) +} + +// TS 4783: reopens the exhausted SFU opportunity for a new %s authority +func TestReopensExhaustedSfuOpportunityForNewAuthority(t *testing.T) { + for _, authority := range []string{"child-session", "host-session", "external-resource"} { + t.Run(authority, func(t *testing.T) { + routes, nowMs := retireLastSfuPublicationAfterReuseDeadline(t) + switch authority { + case "child-session": + routes.UpsertParticipant(viewerInput(A, "replacement_child_session", 0), nil) + case "host-session": + routes.UpsertParticipant(hostInput("replacement_host_session", 2), nil) + default: + routes.TouchExternalFacts() + } + + operation := must(t, routes.Reconcile(nowMs).Operation) + eq(t, operation.DemandPeerID, A) + eq(t, operation.Reason, DemandSfuBootstrap) + eqSlice(t, tuples(operation), []CandidateTuple{sfuTuple(PublicationCreate)}) + }) + } +} + +// TS 4817: consumes the canonical SFU opportunity when an active edge fails +func TestConsumesCanonicalSfuOpportunityWhenActiveEdgeFails(t *testing.T) { + routes := invalidateActiveSfuEdge(t) + + // TS: `operation?.candidates.some(sfu)` toBe(false) needs an operation. + operation := must(t, routes.Reconcile(0).Operation) + eq(t, hasTuple(operation, isSfu), false) +} + +// TS 4826: reopens an active-edge SFU opportunity for a new %s authority +func TestReopensActiveEdgeSfuOpportunityForNewAuthority(t *testing.T) { + for _, authority := range []string{"child-session", "host-session", "external-resource"} { + t.Run(authority, func(t *testing.T) { + routes := invalidateActiveSfuEdge(t) + switch authority { + case "child-session": + routes.UpsertParticipant(viewerInput(A, "active_edge_child_replacement", 0), nil) + case "host-session": + routes.UpsertParticipant(hostInput("active_edge_host_replacement", 1), nil) + default: + routes.TouchExternalFacts() + } + + operation := must(t, routes.Reconcile(0).Operation) + eq(t, hasTuple(operation, func(tuple CandidateTuple) bool { + return isSfu(tuple) && tuple.Publication == PublicationReuse + }), true) + }) + } +} + +// TS 4860: does not turn a failed same-rank SFU replace into a create retry +func TestDoesNotTurnFailedSameRankSfuReplaceIntoCreateRetry(t *testing.T) { + routes := newController(2, Options{SfuEnabled: true}) + routes.hydrateHostPublication("failed_publication", res("failed_publication_resource"), "") + eq(t, routes.InvalidateHostPublication(PublicationGuard{ + HostSessionID: "host_session", + RouteRevision: routes.Snapshot().Revision, + Generation: "failed_publication", + ConnectionID: "publication:failed_publication", + }, nil), true) + addViewer(routes, A, 0, nil) + operation := must(t, routes.Reconcile(0).Operation) + eq(t, operation.Candidates[0].Tuple.Kind, UpstreamPeer) + skipCandidate(t, routes, 1) + replaceCandidate := must(t, routes.Snapshot().Operation).Candidates[1] + eq(t, replaceCandidate.Tuple, sfuTuple(PublicationReplace)) + expectTransition(t, replaceCandidate.EndpointTransition, TransitionNone, HOST) + replace := beginOperation(t, routes, BeginInput{ + NowMs: 2, + ConnectionID: "failed_replace", + PublicationGeneration: "replacement_publication", + PublicationConnectionID: "replacement_ingress", + Reservation: sfuCreate("replacement_subscription", "replacement_publication_resource"), + }) + eqSlice(t, routes.CandidateFailed(guardFor(A, replace.Current.Revision, "failed_replace"), 3).FailedPeerIDs, []string{A}) + + addViewer(routes, B, 0, nil) + eq(t, routes.ConfirmDeparture(B, nil), true) + reconciled := routes.Reconcile(4) + if routes.Snapshot().HostPublication != nil { + t.Fatal("expected no host publication") + } + noOperation(t, reconciled.Operation) + eqSlice(t, reconciled.FailedPeerIDs, []string{}) +} + +// TS 4913: reopens only SFU opportunities for a new external fact +func TestReopensOnlySfuOpportunitiesForNewExternalFact(t *testing.T) { + routes := newController(1, Options{SfuEnabled: true}) + addViewer(routes, A, 0, ms(0)) + routes.Reconcile(0) + directAttempt := beginOperation(t, routes, BeginInput{NowMs: 1, ConnectionID: "failed_direct", Reservation: direct()}) + eq(t, routes.CandidateFailed(guardFor(A, directAttempt.Current.Revision, "failed_direct"), 2).Accepted, true) + sfuAttempt := beginOperation(t, routes, BeginInput{ + NowMs: 3, + ConnectionID: "failed_sfu", + PublicationGeneration: "failed_publication", + PublicationConnectionID: "failed_publication_connection", + Reservation: sfuCreate("failed_subscription", "failed_publication_resource"), + }) + eq(t, routes.CandidateFailed(guardFor(A, sfuAttempt.Current.Revision, "failed_sfu"), 4).Accepted, true) + noOperation(t, routes.Snapshot().Operation) + noOperation(t, routes.Reconcile(5).Operation) + + routes.TouchExternalFacts() + retried := must(t, routes.Reconcile(6).Operation) + eq(t, len(retried.Candidates), 1) + eq(t, retried.Candidates[0].Tuple, sfuTuple(PublicationCreate)) + expectTransition(t, retried.Candidates[0].EndpointTransition, TransitionNone, HOST) + eq(t, hasTuple(retried, isPeer), false) +} + +// TS 4963: fails all waiting demands without cutting capacity-three Host children +func TestFailsAllWaitingDemandsWithoutCuttingCapacityThreeHostChildren(t *testing.T) { + routes := newController(3, Options{SfuEnabled: true}) + seeds := [][2]string{{A, "a_direct"}, {B, "b_direct"}, {C, "c_direct"}} + for _, seed := range seeds { + addViewer(routes, seed[0], 0, nil) + routes.hydrateEdge(seed[0], peerEdge(HOST, seed[1])) + } + addViewer(routes, D, 0, nil) + addViewer(routes, E, 0, nil) + reconciled := routes.Reconcile(0) + noOperation(t, reconciled.Operation) + eqSlice(t, reconciled.FailedPeerIDs, []string{D, E}) + for _, seed := range seeds { + edge := edgeOf(t, routes, seed[0]) + eq(t, edge.ConnectionID, seed[1]) + eq(t, edge.Usable, true) + eq(t, edge.PhysicalActive, true) + } +} + +// TS 4991: does not promote when synchronous admission commit fails +func TestDoesNotPromoteWhenSynchronousAdmissionCommitFails(t *testing.T) { + routes := newController(1, Options{}) + addViewer(routes, A, 0, nil) + operation := must(t, routes.Reconcile(0).Operation) + prepared := beginOperation(t, routes, BeginInput{NowMs: 1, ConnectionID: "candidate_connection", Reservation: direct()}) + settled := routes.CandidateReady(CandidateGuard{ + ChildPeerID: operation.ChildPeerID, + ChildSessionID: operation.ChildSessionID, + Revision: prepared.Current.Revision, + ConnectionID: "candidate_connection", + }, 2, func(CandidateReservation) bool { return false }, CandidateProof{}) + eq(t, settled.Accepted, false) + if settled.ActiveRevision <= prepared.Current.Revision { + t.Fatalf("activeRevision %d did not advance past %d", settled.ActiveRevision, prepared.Current.Revision) + } + eq(t, hasEdge(routes, A), false) +} + +// --- describe("bounded NAT candidate acquisition") ------------------------- + +// TS 5033: runs three real generations on %s, then exhausts until a new session +func TestRunsThreeRealGenerationsThenExhaustsUntilNewSession(t *testing.T) { + for _, failure := range []string{"failed", "timeout"} { + t.Run(failure, func(t *testing.T) { + routes := newController(2, Options{NatPredictionEnabled: true, OperationTimeoutMs: 20_000}) + addViewer(routes, A, 0, nil) + nowMs := int64(0) + var previous *CandidateGuard + for attempt := 1; attempt <= 3; attempt++ { + operation, prepared, guard := preparePeer(t, routes, nowMs, fmt.Sprintf("nat_%d", attempt)) + eqDeep(t, prepared.Current.ConnectionAttempt, &ConnectionAttemptProgress{Current: attempt, Total: 3}) + eq(t, operation.DeadlineAtMs-nowMs, 20_000) + eq(t, prepared.WakeAtMs, operation.DeadlineAtMs) + if previous != nil { + eq(t, routes.CandidateFailed(*previous, nowMs).Accepted, false) + } + eq(t, must(t, routes.Snapshot().Operation).Current.ConnectionID, fmt.Sprintf("nat_%d", attempt)) + var result SettleResult + if failure == "timeout" { + nowMs = operation.DeadlineAtMs + result = routes.OperationExpired(nowMs) + } else { + nowMs++ + result = routes.CandidateFailed(guard, nowMs) + } + if attempt == 3 { + eqSlice(t, result.FailedPeerIDs, []string{A}) + } else { + eqSlice(t, result.FailedPeerIDs, []string{}) + } + eq(t, routes.CandidateFailed(guard, nowMs).Accepted, false) + previous = &guard + nowMs++ + } + noOperation(t, routes.Reconcile(nowMs).Operation) + routes.UpsertParticipant(viewerInput(A, "replacement_session", 0), ms(nowMs)) + _, prepared, guard := preparePeer(t, routes, nowMs+1, "new_session") + eq(t, attemptCurrent(t, prepared), 1) + eq(t, routes.CandidateReady(guard, nowMs+2, nil, CandidateProof{}).Committed, true) + }) + } +} + +// TS 5067: gives waiting viewers their first opportunity before another viewer retries +func TestGivesWaitingViewersFirstOpportunityBeforeAnotherViewerRetries(t *testing.T) { + routes := newController(2, Options{NatPredictionEnabled: true}) + addViewer(routes, A, 0, nil) + addViewer(routes, B, 0, nil) + for index, childPeerID := range []string{A, B, A, B, A, B} { + _, prepared, guard := preparePeer(t, routes, int64(index*2), fmt.Sprintf("fair_%d", index)) + eq(t, prepared.ChildPeerID, childPeerID) + eq(t, attemptCurrent(t, prepared), index/2+1) + routes.CandidateFailed(guard, int64(index*2+1)) + } + noOperation(t, routes.Reconcile(20).Operation) +} + +// TS 5080: tries a newly usable parent before spending another attempt on an old parent +func TestTriesNewlyUsableParentBeforeSpendingAnotherAttemptOnOldParent(t *testing.T) { + routes := newController(2, Options{NatPredictionEnabled: true}) + addViewer(routes, B, 2, nil) + routes.hydrateEdge(B, peerEdge(HOST, "existing_b")) + addViewer(routes, A, 0, nil) + _, firstPrepared, firstGuard := preparePeer(t, routes, 0, "first_host") + eq(t, firstPrepared.Current.Tuple.ParentPeerID, HOST) + routes.CandidateFailed(firstGuard, 1) + _, secondPrepared, secondGuard := preparePeer(t, routes, 2, "first_b") + eq(t, secondPrepared.Current.Tuple.ParentPeerID, B) + routes.CandidateFailed(secondGuard, 3) + addViewer(routes, C, 2, ms(4)) + routes.hydrateEdge(C, peerEdge(HOST, "existing_c")) + _, thirdPrepared, _ := preparePeer(t, routes, 5, "first_c") + eq(t, thirdPrepared.Current.Tuple.ParentPeerID, C) + eq(t, attemptCurrent(t, thirdPrepared), 1) +} + +// TS 5098: keeps SFU's foreground timing and shares the attempt budget with round-robin direct continuations +func TestKeepsSfuForegroundTimingAndSharesAttemptBudgetWithDirectContinuations(t *testing.T) { + routes := newController(2, Options{NatPredictionEnabled: true, SfuEnabled: true, OperationTimeoutMs: 20_000}) + nowMs := int64(0) + for _, childPeerID := range []string{A, B} { + addViewer(routes, childPeerID, 0, ms(nowMs)) + _, directPrepared, _ := preparePeer(t, routes, nowMs, childPeerID+"_first") + eq(t, directPrepared.WakeAtMs, nowMs+5_000) + nowMs += 5_000 + eqSlice(t, routes.OperationExpired(nowMs).FailedPeerIDs, []string{}) + reservation := sfuReuse("subscription_b") + if childPeerID == A { + reservation = sfuCreate("subscription_a", "publication") + } + sfu := beginOperation(t, routes, BeginInput{ + NowMs: nowMs, + ConnectionID: childPeerID + "_sfu", + PublicationGeneration: "sfu_generation", + PublicationConnectionID: "sfu_publisher", + Reservation: reservation, + }) + if sfu.Current.ConnectionAttempt != nil { + t.Fatal("SFU attempts carry no NAT connection attempt") + } + nowMs++ + routes.CandidateReady(CandidateGuard{ + ChildPeerID: childPeerID, + ChildSessionID: sfu.ChildSessionID, + Revision: sfu.Current.Revision, + ConnectionID: childPeerID + "_sfu", + }, nowMs, nil, CandidateProof{}) + nowMs++ + } + for index, childPeerID := range []string{A, B, A, B} { + _, retry, guard := preparePeer(t, routes, nowMs, fmt.Sprintf("background_%d", index)) + eq(t, retry.ChildPeerID, childPeerID) + eq(t, retry.Reason, DemandDirectConvergence) + eqDeep(t, retry.Current.ConnectionAttempt, &ConnectionAttemptProgress{Current: index/2 + 2, Total: 3}) + eq(t, retry.WakeAtMs-nowMs, 20_000) + nowMs++ + eqSlice(t, routes.CandidateFailed(guard, nowMs).FailedPeerIDs, []string{}) + edge := edgeOf(t, routes, childPeerID) + eq(t, edge.Kind, UpstreamSfu) + eq(t, edge.Usable, true) + nowMs++ + } + noOperation(t, routes.Reconcile(nowMs).Operation) +} + +// TS 5139: does not retry without NAT policy or when no candidate was created +func TestDoesNotRetryWithoutNatPolicyOrWhenNoCandidateWasCreated(t *testing.T) { + ordinary := newController(2, Options{}) + addViewer(ordinary, A, 0, nil) + _, firstPrepared, firstGuard := preparePeer(t, ordinary, 0, "ordinary") + if firstPrepared.Current.ConnectionAttempt != nil { + t.Fatal("expected no connection attempt without NAT prediction") + } + eqSlice(t, ordinary.CandidateFailed(firstGuard, 1).FailedPeerIDs, []string{A}) + noOperation(t, ordinary.Reconcile(2).Operation) + + routes := newController(2, Options{NatPredictionEnabled: true}) + addViewer(routes, A, 0, nil) + operation := must(t, routes.Reconcile(0).Operation) + routes.SkipCurrentCandidate(cursorGuard(operation), 1, RejectionCandidateFailed) + noOperation(t, routes.Reconcile(2).Operation) +} diff --git a/internal/server/route/debug.go b/internal/server/route/debug.go new file mode 100644 index 0000000..2d74eee --- /dev/null +++ b/internal/server/route/debug.go @@ -0,0 +1,72 @@ +package route + +import ( + "log/slog" + "os" + "strconv" + "strings" + + "github.com/TNTcraftHIM/Screener/internal/server/protocol" +) + +// debugEnabled is the port of `debuglog("screener-route")`: read once, +// enabled by SCREENER_DEBUG=route (comma-separated like NODE_DEBUG). +var debugEnabled = routeDebugEnabled(os.Getenv("SCREENER_DEBUG")) + +func routeDebugEnabled(value string) bool { + for _, section := range strings.Split(value, ",") { + if strings.TrimSpace(section) == "route" { + return true + } + } + return false +} + +// debugCandidate is the `{route, transition}` item of "operation-started". +type debugCandidate struct { + Route string + Transition TransitionKind +} + +// debug ports 4911: one sanitised event with the room's debug ID, the +// current revision and fact version, then the caller's key/value details. +// Never pass raw peer IDs, session IDs or connection IDs; use debugPeer and +// debugTuple. It is a no-op without the env flag or a DebugRoomID; the +// level is Info because the env flag is the gate, as NODE_DEBUG was. +func (c *Controller) debug(event string, details ...any) { + if !debugEnabled || c.debugRoomID == "" { + return + } + args := make([]any, 0, 8+len(details)) + args = append(args, + "event", event, + "roomId", c.debugRoomID, + "revision", c.revision, + "factVersion", c.factVersion) + args = append(args, details...) + slog.Info("screener-route", args...) +} + +// debugPeer ports 4895: "host", "viewer-" or "viewer-unknown". +func (c *Controller) debugPeer(peerID string) string { + current, _ := c.participants.Get(peerID) + if peerID == c.hostPeerID || (current != nil && current.role == protocol.RoleHost) { + return "host" + } + if current == nil { + return "viewer-unknown" + } + return "viewer-" + strconv.FormatInt(current.joinOrder, 10) +} + +// debugTuple ports 4903: "p2p: @@ -155,6 +155,23 @@ export function ClientLauncherPage() { )} + ) : error === "load" ? ( +
+ + + window.location.reload()} + /> +
) : (
@@ -202,7 +219,7 @@ export function ClientLauncherPage() { title={vis ? undefined : t(choice.hint)} onClick={() => { setMode(choice.mode); - setError(false); + setError(null); }} > @@ -237,7 +254,7 @@ export function ClientLauncherPage() { aria-label={t("client.launch.siteAddress")} onChange={(event) => { setSite(event.target.value); - setError(false); + setError(null); }} /> @@ -253,7 +270,7 @@ export function ClientLauncherPage() { ) ) : null} - {error ? ( + {error === "launch" ? ( (null); const [switchingSource, setSwitchingSource] = useState(false); const [changingQuality, setChangingQuality] = useState(false); const [sharingPaused, setSharingPaused] = useState(false); @@ -531,6 +532,13 @@ export function HostPage({ const signalRef = useRef(null); const displayNameRef = useRef(displayName); const hostClientIdRef = useRef(null); + // Share start awaits capture, codec probe and room creation; the host can + // switch expression mode meanwhile, so the name is derived from the current + // mode rather than the render that began the start. + const visRef = useRef(vis); + useEffect(() => { + visRef.current = vis; + }, [vis]); useEffect(() => { if (hasCustomDisplayName) return; const fallback = defaultHostDisplayName( @@ -556,7 +564,6 @@ export function HostPage({ new Map(), ); const viewerQualityEvidenceRenderFrameRef = useRef(null); - const peerAssistedRef = useRef(false); const activeRouteRevisionRef = useRef(0); const generationRef = useRef(0); const activeGenerationRef = useRef(null); @@ -692,6 +699,10 @@ export function HostPage({ activeHostChildPeerIdsRef.current = []; endpointMediaCopyCapacityRef.current = MAX_ENDPOINT_MEDIA_CHILDREN; hostPeerIdRef.current = null; + if (copiedResetTimerRef.current !== null) { + window.clearTimeout(copiedResetTimerRef.current); + copiedResetTimerRef.current = null; + } viewerQualityEvidenceTimersRef.current.forEach((timer) => window.clearTimeout(timer), ); @@ -807,14 +818,6 @@ export function HostPage({ return route; } - function clearHostSfuRoute(): void { - const route = hostSfuRouteRef.current; - hostSfuRouteRef.current = null; - setHostSfuQualityWarning(null); - sfuStandbyPrewarmerRef.current?.setUrl(null); - void route?.disconnect(); - } - function setSfuStandbyUrl(url: string | null | undefined): void { if (!url) { sfuStandbyPrewarmerRef.current?.setUrl(null); @@ -878,7 +881,6 @@ export function HostPage({ streamRef.current = null; retiringStreamRef.current = null; iceConfigRef.current = null; - peerAssistedRef.current = false; setStream(null); setDetails(null); setMaxViewers(null); @@ -943,6 +945,9 @@ export function HostPage({ function endSharing( message: string | { key: CopyKey; vars?: Record }, notifyServer = true, + // Involuntary endings keep the failure tone: the notice pill reads its + // icon/tone from the comic, so a bare key would render as green success. + comic: ComicKind | null = null, ): void { const generation = activeGenerationRef.current; if (generation === null || generationRef.current !== generation) { @@ -955,11 +960,12 @@ export function HostPage({ writePreferredRoom(currentRoom.roomId); } disposeResources(notifyServer); - if (typeof message === "string") { - setNotice(message); - } else { - setNoticeKey(message.key, message.vars); - } + setNoticeValue( + typeof message === "string" + ? { kind: "text", text: message } + : { kind: "key", key: message.key, vars: message.vars }, + ); + setNoticeComic(comic); setPhase("ended"); } @@ -1038,7 +1044,10 @@ export function HostPage({ } } if (forgetRoom(activeRoom)) { - endSharing({ key: "host.roomInvalid" }, false); + // endSharing already stated the recovery path; the raw room error + // would only overwrite it with the same 404 in server wording. + endSharing({ key: "host.roomInvalid" }, false, "warning"); + return; } } setNoticeError(error, "room"); @@ -1167,10 +1176,7 @@ export function HostPage({ activeRouteRevisionRef.current, directSnapshot, ); - if ( - !evidenceSource || - (evidenceSource === "peer-relayed" && !peerAssistedRef.current) - ) { + if (!evidenceSource) { return; } commitViewerQualityEvidence( @@ -1231,7 +1237,7 @@ export function HostPage({ nativeMediaBridgeRef.current === bridge && isCurrentShare(generation, shareGeneration) ) { - endSharing({ key: "host.shareEnded" }); + endSharing({ key: "host.shareEnded" }, true, "warning"); } }, started.audio, @@ -1250,9 +1256,11 @@ export function HostPage({ isCurrentShare(generation, shareGeneration) && nativeClientRef.current === client ) { - endSharing({ - key: event.failed ? "host.shareEnded" : "host.stopNotice", - }); + endSharing( + { key: event.failed ? "host.shareEnded" : "host.stopNotice" }, + true, + event.failed ? "warning" : null, + ); } }); nativeEventCleanupRef.current = nativeEventCleanup; @@ -1293,7 +1301,7 @@ export function HostPage({ if (nativeMediaIngressRef.current) { recoverBrowserFanout(nativeMediaIngressRef.current); } else if (nativeModeRef.current && activeGenerationRef.current !== null) { - endSharing({ key: "host.shareEnded" }); + endSharing({ key: "host.shareEnded" }, true, "warning"); } }); } @@ -1640,9 +1648,7 @@ export function HostPage({ } else if (captureChanged) { setDetails(captureDetails(activeStream)); } - if (peerAssistedRef.current) { - signalRef.current?.setHostQualitySettings(appliedProfile); - } + signalRef.current?.setHostQualitySettings(appliedProfile); const activeSfuRoute = hostSfuRouteRef.current; const [results, sfuUpdated] = await Promise.all([ Promise.all( @@ -1732,9 +1738,13 @@ export function HostPage({ .setPaused(nativeShareGeneration, nextPaused) .then(() => { if (!isCurrentGeneration(generation)) return; - if (signalRef.current?.setSharingPaused(nextPaused) !== true) { - void nativeClient.setPaused(nativeShareGeneration, !nextPaused); - setNoticeKey("host.pause.signalRecovering"); + const sent = signalRef.current?.setSharingPaused(nextPaused) === true; + if (!sent && !nextPaused) { + // Mirror the Browser branch: a resume the server did not hear + // rolls back to paused and restores the wire intent. + void nativeClient.setPaused(nativeShareGeneration, true); + signalRef.current?.confirmSharingPaused(); + setNoticeErrorKey("host.pause.signalRecovering"); return; } if (activeStream) { @@ -1747,7 +1757,11 @@ export function HostPage({ hostSfuRouteRef.current?.setPaused(nextPaused); sharingPausedRef.current = nextPaused; setSharingPaused(nextPaused); - setNoticeKey(nextPaused ? "host.pauseNotice" : "host.resumeNotice"); + if (!sent) { + setNoticeErrorKey("host.pause.signalRecovering"); + } else { + setNoticeKey(nextPaused ? "host.pauseNotice" : "host.resumeNotice"); + } }) .catch((error: unknown) => { if (isCurrentGeneration(generation)) { @@ -1761,7 +1775,7 @@ export function HostPage({ } if (sharingPausedRef.current) { if (!setMediaPaused(activeStream, false)) { - setNoticeKey("host.pause.noTracksResume"); + setNoticeErrorKey("host.pause.noTracksResume"); return; } nativeMediaIngressRef.current?.setPaused(false); @@ -1775,7 +1789,7 @@ export function HostPage({ hostProvisionalChildRef.current?.setPaused(true); hostSfuRouteRef.current?.setPaused(true); signalRef.current?.confirmSharingPaused(); - setNoticeKey("host.pause.signalRecovering"); + setNoticeErrorKey("host.pause.signalRecovering"); return; } sharingPausedRef.current = false; @@ -1784,7 +1798,7 @@ export function HostPage({ return; } if (!setMediaPaused(activeStream, true)) { - setNoticeKey("host.pause.noTracksPause"); + setNoticeErrorKey("host.pause.noTracksPause"); return; } nativeMediaIngressRef.current?.setPaused(true); @@ -1797,7 +1811,7 @@ export function HostPage({ if (signalRef.current?.setSharingPaused(true) === true) { setNoticeKey("host.pauseNotice"); } else { - setNoticeKey("host.pause.signalRecovering"); + setNoticeErrorKey("host.pause.signalRecovering"); } } @@ -1963,7 +1977,6 @@ export function HostPage({ async function startPeer( peerId: string, generation: number, - attempt = 0, ): Promise { if (!isCurrentGeneration(generation) || !hostChildIsAssigned(peerId)) { return; @@ -2052,31 +2065,8 @@ export function HostPage({ } const connectionId = peer.connectionId; - if (peerAssistedRef.current || attempt >= 1) { - reportHostChildFailure(peerId, connectionId, generation); - removePeer(peerId); - return; - } + reportHostChildFailure(peerId, connectionId, generation); removePeer(peerId); - window.setTimeout(() => { - if ( - isCurrentGeneration(generation) && - !peerAssistedRef.current && - hostChildIsAssigned(peerId) && - !peersRef.current.has(peerId) - ) { - void startPeer(peerId, generation, attempt + 1).catch( - (error: unknown) => { - if ( - isCurrentGeneration(generation) && - hostChildIsAssigned(peerId) - ) { - setNoticeError(error, "connection"); - } - }, - ); - } - }, 500); } async function recoverPeer( @@ -2148,7 +2138,6 @@ export function HostPage({ ): boolean { return Boolean( isCurrentGeneration(generation) && - peerAssistedRef.current && hostChildIsAssigned(peerId) && signalRef.current?.send({ type: "route-failed", @@ -2199,49 +2188,38 @@ export function HostPage({ message.codeEntryPolicy, ), ); - if ( - "mediaMode" in message && - message.mediaMode === "peer-assisted" - ) { - const currentQualitySettings = - pendingQualitySettings ?? message.qualitySettings; - activeRouteRevisionRef.current = message.routeRevision; - peerAssistedRef.current = true; - if (reauthenticated) { - commitQuality(currentQualitySettings); - const endpointUpdates = [ - ...[...peersRef.current.values()].map((peer) => - peer.updateProfile(currentQualitySettings), - ), - ...(hostProvisionalChildRef.current - ? [ - hostProvisionalChildRef.current.updateProfile( - currentQualitySettings, - ), - ] - : []), - ]; - void Promise.allSettled(endpointUpdates); - } - const route = ensureHostSfuRoute(generation); - void route - .resyncAuthoritative({ - revision: message.routeRevision, - phase: "active", - assignment: message.routeAssignment, - }) - .then(async () => { - if (reauthenticated && hostSfuRouteRef.current === route) { - await route.updateProfile(currentQualitySettings); - } - syncHostSfuQualityWarning(route, generation); - }); - return; + const currentQualitySettings = + pendingQualitySettings ?? message.qualitySettings; + activeRouteRevisionRef.current = message.routeRevision; + if (reauthenticated) { + commitQuality(currentQualitySettings); + const endpointUpdates = [ + ...[...peersRef.current.values()].map((peer) => + peer.updateProfile(currentQualitySettings), + ), + ...(hostProvisionalChildRef.current + ? [ + hostProvisionalChildRef.current.updateProfile( + currentQualitySettings, + ), + ] + : []), + ]; + void Promise.allSettled(endpointUpdates); } - activeRouteRevisionRef.current = 0; - peerAssistedRef.current = false; - clearHostSfuRoute(); - reconcileHostChildren(message.viewerPeerIds, generation); + const route = ensureHostSfuRoute(generation); + void route + .resyncAuthoritative({ + revision: message.routeRevision, + phase: "active", + assignment: message.routeAssignment, + }) + .then(async () => { + if (reauthenticated && hostSfuRouteRef.current === route) { + await route.updateProfile(currentQualitySettings); + } + syncHostSfuQualityWarning(route, generation); + }); return; } if (message.type === "viewer-presence") { @@ -2271,36 +2249,34 @@ export function HostPage({ sharingPausedRef.current = true; setSharingPaused(true); signalRef.current?.confirmSharingPaused(); - setNoticeKey("host.pause.stillPaused"); + setNoticeErrorKey("host.pause.stillPaused"); return; } if (message.type === "route-update") { - if (peerAssistedRef.current) { - const route = ensureHostSfuRoute(generation); - const accepted = route.accept(message); - if (accepted === "stale") return; - if (message.phase === "prepare") { - if (message.candidate.transport === "direct") { - prepareHostChild( - message.revision, - message.assignment, - message.candidate, - generation, - ); - } else { - discardPreparedHostChild(); - } + const route = ensureHostSfuRoute(generation); + const accepted = route.accept(message); + if (accepted === "stale") return; + if (message.phase === "prepare") { + if (message.candidate.transport === "direct") { + prepareHostChild( + message.revision, + message.assignment, + message.candidate, + generation, + ); } else { - activatePreparedHostChild(message.revision, message.assignment); + discardPreparedHostChild(); } - if ( - message.phase === "active" && - message.revision !== activeRouteRevisionRef.current - ) { - activeRouteRevisionRef.current = message.revision; - } - syncHostSfuQualityWarning(route, generation); + } else { + activatePreparedHostChild(message.revision, message.assignment); } + if ( + message.phase === "active" && + message.revision !== activeRouteRevisionRef.current + ) { + activeRouteRevisionRef.current = message.revision; + } + syncHostSfuQualityWarning(route, generation); return; } if (message.type === "viewer-quality-evidence") { @@ -2308,45 +2284,10 @@ export function HostPage({ return; } if (message.type === "sfu-config") { - if (peerAssistedRef.current) { - const route = ensureHostSfuRoute(generation); - void route - .acceptConfig(message) - .then(() => syncHostSfuQualityWarning(route, generation)); - } - return; - } - if (message.type === "peer-joined") { - if (peerAssistedRef.current) { - return; - } - reconcileHostChildren( - [...activeHostChildPeerIdsRef.current, message.peerId], - generation, - ); - return; - } - if (message.type === "peer-waiting") { - if (!peerAssistedRef.current) { - reconcileHostChildren( - activeHostChildPeerIdsRef.current.filter( - (peerId) => peerId !== message.peerId, - ), - generation, - ); - } - return; - } - if (message.type === "peer-left") { - if (peerAssistedRef.current) { - return; - } - reconcileHostChildren( - activeHostChildPeerIdsRef.current.filter( - (peerId) => peerId !== message.peerId, - ), - generation, - ); + const route = ensureHostSfuRoute(generation); + void route + .acceptConfig(message) + .then(() => syncHostSfuQualityWarning(route, generation)); return; } if (message.type === "signal") { @@ -2381,6 +2322,7 @@ export function HostPage({ endSharing( message.reason === "expired" ? say("host.roomExpired") : say("host.roomClosed"), false, + "warning", ); return; } @@ -2389,14 +2331,14 @@ export function HostPage({ if (!forgetRoom(activeRoom)) { return; } - endSharing({ key: "host.roomInvalid" }, false); + endSharing({ key: "host.roomInvalid" }, false, "warning"); return; } if (message.code === "AUTH_REQUIRED") { return; } if (message.code === "HOST_ALREADY_CONNECTED") { - endSharing(hostServerErrorNotice(message.code), false); + endSharing(hostServerErrorNotice(message.code), false, "warning"); return; } setNotice(hostServerErrorNotice(message.code), "warning"); @@ -2513,7 +2455,7 @@ export function HostPage({ let authenticated = false; const hostClientId = getStableClientId("host", activeRoom.roomId); hostClientIdRef.current = hostClientId; - const hostFallback = defaultHostDisplayName(hostClientId, vis); + const hostFallback = defaultHostDisplayName(hostClientId, visRef.current); const initialDisplayName = readDisplayName(hostFallback); displayNameRef.current = initialDisplayName; setDisplayName(initialDisplayName); @@ -2546,7 +2488,7 @@ export function HostPage({ isCurrentShare(generation, shareGeneration) && signalRef.current === signal ) { - endSharing({ key: hostTerminationKey(reason) }, false); + endSharing({ key: hostTerminationKey(reason) }, false, "warning"); } }, onAccessRequired: () => { @@ -2554,7 +2496,7 @@ export function HostPage({ isCurrentShare(generation, shareGeneration) && signalRef.current === signal ) { - endSharing({ key: "gate.expired" }, false); + endSharing({ key: "gate.expired" }, false, "warning"); onAuthorizationRequired?.(); } }, @@ -2953,11 +2895,17 @@ export function HostPage({ try { await navigator.clipboard.writeText(inviteUrl); setCopied(true); - window.setTimeout(() => { + // One owner for the confirmation window: a second copy restarts it + // instead of inheriting the first click's expiry. + if (copiedResetTimerRef.current !== null) { + window.clearTimeout(copiedResetTimerRef.current); + } + copiedResetTimerRef.current = window.setTimeout(() => { + copiedResetTimerRef.current = null; setCopied(false); }, 1_500); } catch { - setNoticeKey("host.invite.copyFailed"); + setNoticeErrorKey("host.invite.copyFailed"); } } @@ -3083,7 +3031,7 @@ export function HostPage({ password !== null && !viewerPasswordSchema.safeParse(password).success ) { - setNoticeKey("host.password.rule", { + setNoticeErrorKey("host.password.rule", "warning", { max: String(MAX_VIEWER_PASSWORD_LENGTH), }); return; @@ -3151,7 +3099,7 @@ export function HostPage({ setEditingDisplayName(false); setHasCustomDisplayName(readStoredDisplayName() !== null); if (!signalRef.current?.setDisplayName(saved)) { - setNoticeKey("host.nameOffline"); + setNoticeErrorKey("host.nameOffline"); } } @@ -3167,6 +3115,9 @@ export function HostPage({ const activeCodeEntryPolicy = room?.codeEntryPolicy ?? null; const hostPeerId = hostPresence?.peerId ?? hostPeerIdRef.current; + // One identity for the Host body everywhere (couch, name tag, topology) so + // the pawn is not recoloured per surface while the peer id is still pending. + const hostIdentity = hostPeerId ?? hostClientIdRef.current ?? "host-pending"; const couchEntries: CouchEntry[] = viewers.map((viewer) => { const snapshot = @@ -3292,7 +3243,7 @@ export function HostPage({ }) : phase === "starting" ? `${t("host.starting")}…` - : phase === "ended" && room + : phase === "ended" ? t("host.ended") : room ? t("host.roomReady") @@ -3409,7 +3360,7 @@ export function HostPage({

{t("host.idle.hint")}

)} - {phase === "ended" && room && !vis ? ( + {phase === "ended" && !vis ? ( {t("host.ended")} ) : null}
@@ -3525,7 +3476,9 @@ export function HostPage({ ) : null}
) : null} - {switchingSource ? ( + {/* The picker owns the stage while it is open: a state overlay + painted after it would cover and swallow its controls. */} + {nativeSources ? null : switchingSource ? (