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:
1 parent
0653b7a58a
commit
da47189fc3
291 files changed
+42361
-34024
No files matched your search
+12
-8
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
File renamed without changes.
@@ -20,7 +20,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
signalingProtocol = "screener-v20"
|
||||
signalingProtocol = "screener-v21"
|
||||
maxPendingCandidates = 64
|
||||
)
|
||||
|
||||
File renamed without changes.
@@ -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]
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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: ¤t,
|
||||
LatestRevision: &latest,
|
||||
ReleaseURL: &releaseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// authorizesGitHub reports whether the operator token may be sent to endpoint.
|
||||
// A fixture, mirror or plaintext endpoint must never receive it, which is why
|
||||
// the scheme and the host are both part of the rule.
|
||||
func authorizesGitHub(endpoint *url.URL, token string) bool {
|
||||
return endpoint.Scheme == "https" &&
|
||||
strings.ToLower(endpoint.Hostname()) == releaseAPIHost &&
|
||||
strings.TrimSpace(token) != ""
|
||||
}
|
||||
|
||||
// unavailableResult is unavailableResult: the current revision is reported when
|
||||
// it is well formed and null otherwise.
|
||||
func unavailableResult(currentRevision string) releaseResult {
|
||||
return releaseResult{Status: statusUnavailable, CurrentRevision: revisionOrNull(currentRevision)}
|
||||
}
|
||||
|
||||
func revisionOrNull(value string) *string {
|
||||
revision := normalizeRevision(value)
|
||||
if revision == "" {
|
||||
return nil
|
||||
}
|
||||
return &revision
|
||||
}
|
||||
|
||||
// normalizeRevision ports normalizeRevision, returning "" where it returned
|
||||
// null: only a full 40-hex commit identifies a release.
|
||||
func normalizeRevision(value string) string {
|
||||
revision := strings.ToLower(strings.TrimSpace(value))
|
||||
if !fullRevisionPattern.MatchString(revision) {
|
||||
return ""
|
||||
}
|
||||
return revision
|
||||
}
|
||||
|
||||
// releaseMetadata is the subset of the GitHub release payload that is read.
|
||||
// A tag_name or html_url of another type fails to decode, which is the
|
||||
// rejection the JavaScript reached through typeof. Deviation: the JavaScript
|
||||
// tested `draft === true`, so it read a draft of another type as "not a
|
||||
// draft"; here such a payload fails to decode and the check reports
|
||||
// unavailable. Only stricter, and a release payload the checker cannot
|
||||
// understand must never be reported as an update.
|
||||
type releaseMetadata struct {
|
||||
TagName *string `json:"tag_name"`
|
||||
HTMLURL *string `json:"html_url"`
|
||||
Draft *bool `json:"draft"`
|
||||
Prerelease *bool `json:"prerelease"`
|
||||
}
|
||||
|
||||
// parseReleaseMetadata ports parseReleaseMetadata. It accepts one shape only: a
|
||||
// published, non-draft release whose tag is a full commit revision and whose
|
||||
// page is exactly https://github.com/TNTcraftHIM/Screener/releases/tag/<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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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`;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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.
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 {
|
||||
@@ -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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
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)
|
||||
}
|
||||
+21
-20
@@ -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)
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -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 {
|
||||
+125
@@ -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.
File renamed without changes.
+1
-1
@@ -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"
|
||||
File renamed without changes.
File renamed without changes.
@@ -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
|
||||
+9
@@ -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")) {
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+9
-8
@@ -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{
|
||||
+2
-2
@@ -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
-1
@@ -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
-1
@@ -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
|
||||
+17
-7
@@ -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
|
||||
File renamed without changes.
+1
-1
@@ -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"
|
||||
)
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user