refactor: unify Go runtime and audited frontend

Squash integration of the reviewed Go core, frontend audit fixes, release tooling, and deployment hygiene. CI passed all Server/Client jobs.
This commit is contained in:
AAA authored and GitHub committed 2026-09-06 23:26:01 +08:00
1 parent 0653b7a58a
commit da47189fc3
291 files changed
+42361 -34024

No files matched your search

+12 -8
View File
@@ -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
+25 -9
View File
@@ -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: >-
+4 -1
View File
@@ -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
+5 -4
View File
@@ -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
+10 -3
View File
@@ -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://<computer-lan-ip>: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
@@ -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-<sha>.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:<stun-host>:3478 \
npm run gate:client-media
SCREENER_CLIENT_NATIVE_HOST_GATE=true \
@@ -216,7 +213,7 @@ SCREENER_REMOTE_HOST=<public-test-host> \
SCREENER_REMOTE_USER=<ssh-user> \
SCREENER_REMOTE_SSH_KEY=/path/to/key \
SCREENER_CLIENT_GATE_STUN_URLS=stun:<stun-host>: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=<public-test-host> \
SCREENER_REMOTE_USER=<ssh-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 \
@@ -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")
File renamed without changes.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

@@ -20,7 +20,7 @@ import (
)
const (
signalingProtocol = "screener-v20"
signalingProtocol = "screener-v21"
maxPendingCandidates = 64
)
+168
View File
@@ -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]
}
+67
View File
@@ -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")
}
}
+242
View File
@@ -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 <REVISION> [--api-url <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: &current,
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/<that
// revision> 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
}
+329
View File
@@ -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())
}
}
+1 -8
View File
@@ -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"
+52 -126
View File
@@ -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(/<script[^>]+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
+2 -1
View File
@@ -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
+8
View File
@@ -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.
@@ -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)
@@ -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-
+6 -5
View File
@@ -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
+37 -27
View File
@@ -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
+111
View File
@@ -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)
+32 -22
View File
@@ -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 <output-directory-outside-repository>
```
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-<revision>.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.
+31 -3
View File
@@ -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,
+3 -1
View File
@@ -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
+7 -7
View File
@@ -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
+11 -8
View File
@@ -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
+11 -11
View File
@@ -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
+106
View File
@@ -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.
+22 -20
View File
@@ -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`;
+2 -1
View File
@@ -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)
+82 -53
View File
@@ -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.
+29 -26
View File
@@ -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.
+31 -14
View File
@@ -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.
+17 -10
View File
@@ -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
+11 -2
View File
@@ -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
)
+46
View File
@@ -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=
File renamed without changes.
@@ -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 {
+137
View File
@@ -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)
}
})
}
}
@@ -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"
File renamed without changes.
@@ -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)
}
@@ -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("<!doctype html><html><body>client</body></html>")},
}
}
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("<!doctype html><html><body>client</body></html>"),
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)
}
@@ -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 {
@@ -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" {
@@ -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"
File renamed without changes.
@@ -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"
@@ -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"
@@ -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
@@ -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")) {
@@ -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{
@@ -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) {
@@ -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"
)
@@ -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
@@ -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
@@ -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"
)
+179
View File
@@ -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
}
+284
View File
@@ -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")
}
}
+429
View File
@@ -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
}
File diff suppressed because it is too large. Load diff
+148
View File
@@ -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
}
+442
View File
@@ -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 <site password>` 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
}
+162
View File
@@ -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
}
+227
View File
@@ -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)
}
}
}
+538
View File
@@ -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
}
+558
View File
@@ -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,
}
}
+81
View File
@@ -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
}
+136
View File
@@ -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))
}
})
}
}
Loaded 100 of 291 files, more files were not shown because too many files have changed in this diff. Show more