Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce3b307983 | ||
|
|
c0e362c028 | ||
|
|
22433e5c45 | ||
|
|
122080ab34 | ||
|
|
55afea72a1 | ||
|
|
a0ca194691 | ||
|
|
a491c4f733 | ||
|
|
aa4f948fbc | ||
|
|
0b7e7b8b31 | ||
|
|
b3ed1db36a | ||
|
|
6acc975a6d | ||
|
|
5f6aa657be | ||
|
|
08eb6dc36c | ||
|
|
786c96b110 | ||
|
|
f12ed6c685 | ||
|
|
df23f5edc1 | ||
|
|
78e4709c90 | ||
|
|
d13a953711 | ||
|
|
c7356337d7 | ||
|
|
719042aa45 | ||
|
|
d3194a85af | ||
|
|
bdddd24436 | ||
|
|
f31cf5d68f | ||
|
|
b5aaff9e39 | ||
|
|
c0aabf2a72 | ||
|
|
1485f1e637 | ||
|
|
2c8023edf9 | ||
|
|
0c4d87406e | ||
|
|
baf4073f68 | ||
|
|
df75b1d250 | ||
|
|
f292bd3599 | ||
|
|
9d627413a0 | ||
|
|
b7a42dc652 | ||
|
|
ff23fc9876 | ||
|
|
99d9d7f85a | ||
|
|
a2f8e32d4e | ||
|
|
8904753c16 | ||
|
|
70667d5aee | ||
|
|
d3852d15f3 | ||
|
|
040ca73ce9 | ||
|
|
f37f53eab2 | ||
|
|
8826f5c804 | ||
|
|
994d1f00d1 | ||
|
|
45be5ccea9 | ||
|
|
236c7c2973 | ||
|
|
67eeb9e898 | ||
|
|
1357ea0c8f | ||
|
|
994fe38fb9 | ||
|
|
844e548c95 | ||
|
|
8a302cd8cc | ||
|
|
61a17ff42b | ||
|
|
50ba2d6bf3 | ||
|
|
5f3b9132a1 | ||
|
|
d9f348ae69 | ||
|
|
f639c1bcb6 | ||
|
|
8379c134ca |
@@ -14,19 +14,34 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
gateway:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-latest-on-demand-xhigh-performance
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version: "1.26.5"
|
||||
cache: true
|
||||
cache-dependency-path: go.mod
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
- name: Verify Go and OpenSpec baseline
|
||||
- name: Verify Go and OpenSpec
|
||||
run: make OPENSPEC='bunx --bun @fission-ai/openspec@1.5.0' verify
|
||||
- name: Build and inspect reproducible Linux gateway artifacts
|
||||
run: |
|
||||
make gateway-linux DIST_DIR=dist
|
||||
sha256sum dist/verse-gateway-linux-* | tee dist/SHA256SUMS
|
||||
file dist/verse-gateway-linux-* | tee dist/file.txt
|
||||
go version -m dist/verse-gateway-linux-amd64 > dist/go-version-amd64.txt
|
||||
go version -m dist/verse-gateway-linux-arm64 > dist/go-version-arm64.txt
|
||||
make gateway-sbom DIST_DIR=dist
|
||||
- uses: christopherhx/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4
|
||||
with:
|
||||
name: verse-gateway-linux-${{ gitea.sha }}
|
||||
path: dist/*
|
||||
if-no-files-found: error
|
||||
# Gitea 1.27 floors the positive upload delay; request 31 days to retain at least 30 elapsed days.
|
||||
retention-days: 31
|
||||
- name: Verify clean checkout
|
||||
run: git diff --exit-code
|
||||
|
||||
@@ -38,8 +38,10 @@ change the repository's GPL boundary.
|
||||
silent capability fallback.
|
||||
- Bound every parser, queue, fragment, stream, datagram, timer, goroutine,
|
||||
allocation, and metric label.
|
||||
- A failed Apollo feasibility spike is a terminal `NO-GO`; preserve evidence
|
||||
and reopen the architecture decision rather than implementing around it.
|
||||
- Deterministic Apollo fixtures and a bounded fake provider are the
|
||||
implementation gate. Record live Apollo/macOS interoperability as
|
||||
`deferred-owner-e2e`; never treat the deferral as permission to implement
|
||||
around a later real failure.
|
||||
|
||||
## Source and verification rules
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
.PHONY: format-check module-verify build vet test openspec verify
|
||||
.PHONY: format-check module-verify build vet test openspec gateway-linux gateway-sbom verify
|
||||
|
||||
GO ?= go
|
||||
OPENSPEC ?= openspec
|
||||
DIST_DIR ?= dist
|
||||
SOURCE_REVISION ?= $(shell git rev-parse HEAD)
|
||||
SOURCE_DATE ?= $(shell git show -s --format=%cI HEAD)
|
||||
|
||||
format-check:
|
||||
@test -z "$$(gofmt -l $$(find gateway -type f -name '*.go' -print))"
|
||||
@@ -21,4 +24,17 @@ test:
|
||||
openspec:
|
||||
$(OPENSPEC) validate --all --strict --no-interactive
|
||||
|
||||
gateway-linux:
|
||||
mkdir -p "$(DIST_DIR)"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOWORK=off $(GO) build -mod=readonly -trimpath -buildvcs=false -ldflags=-buildid= -o "$(DIST_DIR)/verse-gateway-linux-amd64" ./cmd/verse-gateway
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 GOWORK=off $(GO) build -mod=readonly -trimpath -buildvcs=false -ldflags=-buildid= -o "$(DIST_DIR)/verse-gateway-linux-arm64" ./cmd/verse-gateway
|
||||
|
||||
gateway-sbom:
|
||||
$(GO) run ./cmd/verse-gateway-sbom \
|
||||
-source-revision "$(SOURCE_REVISION)" \
|
||||
-source-date "$(SOURCE_DATE)" \
|
||||
-artifact "amd64=$(DIST_DIR)/verse-gateway-linux-amd64" \
|
||||
-artifact "arm64=$(DIST_DIR)/verse-gateway-linux-arm64" \
|
||||
-output "$(DIST_DIR)/verse-gateway.spdx.json"
|
||||
|
||||
verify: format-check module-verify build vet test openspec
|
||||
|
||||
@@ -8,11 +8,11 @@ repository.
|
||||
|
||||
## Current status
|
||||
|
||||
The repository is prepared for **Phase 3C-A preflight and Apollo
|
||||
feasibility**. It does not yet contain a production gateway or a qualified
|
||||
Apollo path. A failed feasibility decision blocks Phase 3C-B; it does not
|
||||
authorize direct client-to-Apollo routing, cgo, a native sidecar, or
|
||||
decode/transcode behavior.
|
||||
The repository contains the Phase 3C-G pure-Go QUIC/mTLS gateway and Apollo
|
||||
profile. Qualification uses deterministic pinned-source fixtures and a
|
||||
bounded fake provider; the owner will perform live Apollo/macOS acceptance
|
||||
after both candidates exist. Deferring that live row does not authorize direct
|
||||
client-to-Apollo routing, cgo, a native sidecar, or decode/transcode behavior.
|
||||
|
||||
The existing Xcode project is retained for the later native-client phase. It
|
||||
is not Phase 3C gateway evidence and must not be used to move provider or
|
||||
@@ -32,6 +32,10 @@ Connection Server authority into a client.
|
||||
- The Protocol repository is the sole wire-contract authority; consumers pin
|
||||
an immutable release without a filesystem `replace`.
|
||||
|
||||
The gateway requires an explicit `-advertise-address host:port`; it is the
|
||||
only endpoint placed in the Server registration and must be client-reachable.
|
||||
Apollo management and RTSP addresses remain private gateway-side inputs.
|
||||
|
||||
## Verification
|
||||
|
||||
The baseline uses Go 1.26.5 and OpenSpec 1.5.0:
|
||||
|
||||
+29
-12
@@ -1,19 +1,36 @@
|
||||
# Third-party notices
|
||||
|
||||
No third-party gateway, provider, protocol, or streaming implementation is
|
||||
incorporated at this bootstrap revision. The existing Apple Xcode project was
|
||||
generated by the platform tool and remains reserved for a later native-client
|
||||
phase.
|
||||
The gateway uses the following exact third-party dependency:
|
||||
|
||||
Apollo, Moonlight, and related repositories are external research references
|
||||
only. Before any source is copied, adapted, linked, embedded, or used to create
|
||||
fixtures, update this file and the Phase 3C provenance record with:
|
||||
- `github.com/quic-go/quic-go` v0.61.0, upstream release commit
|
||||
`579ee19`, MIT license. It supplies the pure-Go QUIC/TLS and RFC 9221
|
||||
DATAGRAM transport only; no provider or client implementation is linked.
|
||||
|
||||
- upstream repository and exact commit;
|
||||
- source and destination paths;
|
||||
- license and retained notices;
|
||||
- whether the work is copied, derived, or independently implemented; and
|
||||
- modifications made by VerseVDI.
|
||||
The existing Apple Xcode project was generated by the platform tool and remains
|
||||
reserved for a later native-client phase.
|
||||
|
||||
The gateway's Apollo fixtures and provider-scoped protocol implementation were
|
||||
independently implemented after consulting wire behavior in these exact
|
||||
external research references. No implementation source from them is copied,
|
||||
linked, or embedded:
|
||||
|
||||
- Apollo `adc5c5a0bd80831ce495434bb16aee2cd4175fb8`, GPLv3:
|
||||
`src/rtsp.cpp`, `src/stream.cpp`, `src/audio.cpp`, `src/audio.h`,
|
||||
`src/nvhttp.cpp`, `LICENSE`, and `NOTICE`.
|
||||
- Moonlight Qt `c0c4d6056569bba40ac4458a3c225c05ff86df6d` with common-c
|
||||
pin `2ea47752c3051d72a64bcca190024e8b354fa1ef`, GPLv3:
|
||||
`src/ControlStream.c`, `src/Video.h`, `src/RtpAudioQueue.h`,
|
||||
`src/RtpAudioQueue.c`, `src/SdpGenerator.c`, `src/AudioStream.c`, and
|
||||
`LICENSE.txt`. The consulted files were verified byte-identical to the
|
||||
recorded local standalone common-c checkout `703a0694`.
|
||||
- cgutman/enet `aca87840b57f045a1f7f9299e4b1b9b8e2a5e2f1`, MIT:
|
||||
`protocol.c`, `peer.c`, `include/enet/protocol.h`, and `LICENSE`.
|
||||
- nanors `17fc7d61afb2fdd9a9aff38fbd7d4f2ff73a4508`, MIT:
|
||||
`rs.c`, `deps/obl/gf2_8_tables.h`, and `LICENSE`.
|
||||
|
||||
Any future copying, adaptation, linking, or embedding requires an updated
|
||||
source/destination provenance record and retained license notices before the
|
||||
change is accepted.
|
||||
|
||||
The VerseVDI Protocol is maintained in a separate repository and must be
|
||||
consumed only through an exact immutable release.
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"debug/buildinfo"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
dataPlaneModulePath = "git.sechmachine.io.vn/sechmachine/VerseVDI-Data-Plane"
|
||||
protocolModulePath = "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol"
|
||||
)
|
||||
|
||||
type artifactFlags []string
|
||||
|
||||
func (values *artifactFlags) String() string { return strings.Join(*values, ",") }
|
||||
func (values *artifactFlags) Set(value string) error {
|
||||
*values = append(*values, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
type goModule struct {
|
||||
Path string
|
||||
Version string
|
||||
Sum string
|
||||
}
|
||||
|
||||
type gatewayArtifact struct {
|
||||
Architecture string
|
||||
Filename string
|
||||
SHA256 string
|
||||
Size int64
|
||||
}
|
||||
|
||||
type sbomInput struct {
|
||||
SourceRevision string
|
||||
Created time.Time
|
||||
ProtocolGoModSum string
|
||||
Modules []goModule
|
||||
Artifacts []gatewayArtifact
|
||||
}
|
||||
|
||||
type spdxDocument struct {
|
||||
SPDXVersion string `json:"spdxVersion"`
|
||||
DataLicense string `json:"dataLicense"`
|
||||
SPDXID string `json:"SPDXID"`
|
||||
Name string `json:"name"`
|
||||
DocumentNamespace string `json:"documentNamespace"`
|
||||
CreationInfo spdxCreationInfo `json:"creationInfo"`
|
||||
DocumentComment string `json:"documentComment"`
|
||||
Packages []spdxPackage `json:"packages"`
|
||||
Relationships []spdxRelationship `json:"relationships"`
|
||||
}
|
||||
|
||||
type spdxCreationInfo struct {
|
||||
Created string `json:"created"`
|
||||
Creators []string `json:"creators"`
|
||||
}
|
||||
|
||||
type spdxPackage struct {
|
||||
Name string `json:"name"`
|
||||
SPDXID string `json:"SPDXID"`
|
||||
VersionInfo string `json:"versionInfo"`
|
||||
DownloadLocation string `json:"downloadLocation"`
|
||||
FilesAnalyzed bool `json:"filesAnalyzed"`
|
||||
PackagePurpose string `json:"primaryPackagePurpose"`
|
||||
Checksums []spdxChecksum `json:"checksums,omitempty"`
|
||||
LicenseConcluded string `json:"licenseConcluded"`
|
||||
LicenseDeclared string `json:"licenseDeclared"`
|
||||
CopyrightText string `json:"copyrightText"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
ExternalRefs []spdxRef `json:"externalRefs,omitempty"`
|
||||
}
|
||||
|
||||
type spdxChecksum struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
ChecksumValue string `json:"checksumValue"`
|
||||
}
|
||||
|
||||
type spdxRef struct {
|
||||
Category string `json:"referenceCategory"`
|
||||
Type string `json:"referenceType"`
|
||||
Locator string `json:"referenceLocator"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
type spdxRelationship struct {
|
||||
Element string `json:"spdxElementId"`
|
||||
Type string `json:"relationshipType"`
|
||||
Related string `json:"relatedSpdxElement"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var artifacts artifactFlags
|
||||
sourceRevision := flag.String("source-revision", "", "full clean source commit")
|
||||
sourceDate := flag.String("source-date", "", "UTC RFC3339 source date")
|
||||
goSumPath := flag.String("go-sum", "go.sum", "Go checksum file")
|
||||
output := flag.String("output", "", "SPDX JSON output")
|
||||
flag.Var(&artifacts, "artifact", "linux architecture and binary path, for example amd64=dist/verse-gateway-linux-amd64")
|
||||
flag.Parse()
|
||||
|
||||
if *output == "" {
|
||||
fatal(errors.New("output is required"))
|
||||
}
|
||||
created, err := time.Parse(time.RFC3339, *sourceDate)
|
||||
if err != nil {
|
||||
fatal(errors.New("source-date must be RFC3339"))
|
||||
}
|
||||
created = created.UTC()
|
||||
if err := verifyCleanRevision(*sourceRevision); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
inspected, modules, err := inspectArtifacts(artifacts)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
protocolVersion := ""
|
||||
for _, module := range modules {
|
||||
if module.Path == protocolModulePath {
|
||||
protocolVersion = module.Version
|
||||
}
|
||||
}
|
||||
goModSum, err := readGoModSum(*goSumPath, protocolModulePath, protocolVersion)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
encoded, err := buildSPDX(sbomInput{
|
||||
SourceRevision: *sourceRevision, Created: created, ProtocolGoModSum: goModSum,
|
||||
Modules: modules, Artifacts: inspected,
|
||||
})
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(*output, encoded, 0o644); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
_, _ = fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func verifyCleanRevision(revision string) error {
|
||||
if !regexp.MustCompile(`^[0-9a-f]{40}$`).MatchString(revision) {
|
||||
return errors.New("source-revision must be a full lowercase commit")
|
||||
}
|
||||
head, err := exec.Command("git", "rev-parse", "HEAD").Output()
|
||||
if err != nil || strings.TrimSpace(string(head)) != revision {
|
||||
return errors.New("source-revision does not match HEAD")
|
||||
}
|
||||
status, err := exec.Command("git", "status", "--porcelain", "--untracked-files=no").Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect source dirt: %w", err)
|
||||
}
|
||||
if len(status) != 0 {
|
||||
return errors.New("tracked source tree is dirty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func inspectArtifacts(specifications []string) ([]gatewayArtifact, []goModule, error) {
|
||||
if len(specifications) != 2 {
|
||||
return nil, nil, errors.New("exactly amd64 and arm64 artifacts are required")
|
||||
}
|
||||
var artifacts []gatewayArtifact
|
||||
var common []goModule
|
||||
seen := make(map[string]bool)
|
||||
for _, specification := range specifications {
|
||||
architecture, path, ok := strings.Cut(specification, "=")
|
||||
if !ok || (architecture != "amd64" && architecture != "arm64") || seen[architecture] {
|
||||
return nil, nil, errors.New("artifact must uniquely name amd64 or arm64")
|
||||
}
|
||||
seen[architecture] = true
|
||||
artifact, modules, err := inspectArtifact(architecture, path)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if common == nil {
|
||||
common = modules
|
||||
} else if !reflect.DeepEqual(common, modules) {
|
||||
return nil, nil, errors.New("artifact module inventories differ")
|
||||
}
|
||||
artifacts = append(artifacts, artifact)
|
||||
}
|
||||
sort.Slice(artifacts, func(first, second int) bool {
|
||||
return artifacts[first].Architecture < artifacts[second].Architecture
|
||||
})
|
||||
return artifacts, common, nil
|
||||
}
|
||||
|
||||
func inspectArtifact(architecture, path string) (gatewayArtifact, []goModule, error) {
|
||||
info, err := buildinfo.ReadFile(path)
|
||||
if err != nil {
|
||||
return gatewayArtifact{}, nil, fmt.Errorf("read %s build metadata: %w", architecture, err)
|
||||
}
|
||||
settings := make(map[string]string, len(info.Settings))
|
||||
for _, setting := range info.Settings {
|
||||
settings[setting.Key] = setting.Value
|
||||
}
|
||||
if info.Path != dataPlaneModulePath+"/cmd/verse-gateway" || settings["GOOS"] != "linux" ||
|
||||
settings["GOARCH"] != architecture || settings["CGO_ENABLED"] != "0" {
|
||||
return gatewayArtifact{}, nil, fmt.Errorf("%s artifact build identity is invalid", architecture)
|
||||
}
|
||||
modules := []goModule{{Path: info.Main.Path, Version: info.Main.Version, Sum: info.Main.Sum}}
|
||||
for _, dependency := range info.Deps {
|
||||
if dependency.Replace != nil {
|
||||
return gatewayArtifact{}, nil, fmt.Errorf("%s contains replaced module %s", architecture, dependency.Path)
|
||||
}
|
||||
modules = append(modules, goModule{Path: dependency.Path, Version: dependency.Version, Sum: dependency.Sum})
|
||||
}
|
||||
sort.Slice(modules, func(first, second int) bool {
|
||||
if modules[first].Path == modules[second].Path {
|
||||
return modules[first].Version < modules[second].Version
|
||||
}
|
||||
return modules[first].Path < modules[second].Path
|
||||
})
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return gatewayArtifact{}, nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
digest := sha256.New()
|
||||
size, err := io.Copy(digest, file)
|
||||
if err != nil {
|
||||
return gatewayArtifact{}, nil, err
|
||||
}
|
||||
return gatewayArtifact{
|
||||
Architecture: architecture, Filename: filepath.Base(path),
|
||||
SHA256: hex.EncodeToString(digest.Sum(nil)), Size: size,
|
||||
}, modules, nil
|
||||
}
|
||||
|
||||
func readGoModSum(path, modulePath, version string) (string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
prefix := modulePath + " " + version + "/go.mod "
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
if strings.HasPrefix(scanner.Text(), prefix) {
|
||||
return strings.TrimPrefix(scanner.Text(), prefix), nil
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", errors.New("protocol go.mod checksum is absent")
|
||||
}
|
||||
|
||||
func buildSPDX(input sbomInput) ([]byte, error) {
|
||||
if !regexp.MustCompile(`^[0-9a-f]{40}$`).MatchString(input.SourceRevision) || input.Created.Location() != time.UTC ||
|
||||
input.ProtocolGoModSum == "" || len(input.Artifacts) != 2 {
|
||||
return nil, errors.New("incomplete SPDX inputs")
|
||||
}
|
||||
modules := append([]goModule(nil), input.Modules...)
|
||||
sort.Slice(modules, func(first, second int) bool {
|
||||
if modules[first].Path == modules[second].Path {
|
||||
return modules[first].Version < modules[second].Version
|
||||
}
|
||||
return modules[first].Path < modules[second].Path
|
||||
})
|
||||
artifacts := append([]gatewayArtifact(nil), input.Artifacts...)
|
||||
sort.Slice(artifacts, func(first, second int) bool {
|
||||
return artifacts[first].Architecture < artifacts[second].Architecture
|
||||
})
|
||||
if artifacts[0].Architecture != "amd64" || artifacts[1].Architecture != "arm64" {
|
||||
return nil, errors.New("SPDX requires amd64 and arm64 artifacts")
|
||||
}
|
||||
|
||||
sourceID := "SPDXRef-Source"
|
||||
packages := []spdxPackage{{
|
||||
Name: dataPlaneModulePath, SPDXID: sourceID, VersionInfo: input.SourceRevision,
|
||||
DownloadLocation: "NOASSERTION", FilesAnalyzed: false, PackagePurpose: "SOURCE",
|
||||
LicenseConcluded: "NOASSERTION", LicenseDeclared: "GPL-3.0-only", CopyrightText: "NOASSERTION",
|
||||
Comment: "license_provenance=LICENSE",
|
||||
}}
|
||||
moduleIDs := make(map[string]string)
|
||||
protocolFound := false
|
||||
for _, module := range modules {
|
||||
if module.Path == dataPlaneModulePath {
|
||||
continue
|
||||
}
|
||||
if module.Path == "" || module.Version == "" || moduleIDs[module.Path] != "" {
|
||||
return nil, errors.New("ambiguous module inventory")
|
||||
}
|
||||
id := "SPDXRef-Module-" + shortDigest(module.Path+"@"+module.Version)
|
||||
moduleIDs[module.Path] = id
|
||||
comment := "go_module_sum=" + module.Sum
|
||||
if module.Path == protocolModulePath {
|
||||
if module.Sum == "" {
|
||||
return nil, errors.New("immutable Protocol checksum is absent")
|
||||
}
|
||||
protocolFound = true
|
||||
comment += "; go_mod_sum=" + input.ProtocolGoModSum
|
||||
}
|
||||
packages = append(packages, spdxPackage{
|
||||
Name: module.Path, SPDXID: id, VersionInfo: module.Version,
|
||||
DownloadLocation: "NOASSERTION", FilesAnalyzed: false, PackagePurpose: "LIBRARY",
|
||||
LicenseConcluded: "NOASSERTION", LicenseDeclared: "NOASSERTION", CopyrightText: "NOASSERTION",
|
||||
Comment: comment,
|
||||
ExternalRefs: []spdxRef{{
|
||||
Category: "PACKAGE-MANAGER", Type: "purl",
|
||||
Locator: "pkg:golang/" + module.Path + "@" + module.Version,
|
||||
}},
|
||||
})
|
||||
}
|
||||
if !protocolFound {
|
||||
return nil, errors.New("Protocol module is absent")
|
||||
}
|
||||
|
||||
var relationships []spdxRelationship
|
||||
for _, artifact := range artifacts {
|
||||
if artifact.Filename == "" || len(artifact.SHA256) != 64 || artifact.Size < 1 {
|
||||
return nil, errors.New("artifact metadata is invalid")
|
||||
}
|
||||
id := "SPDXRef-Artifact-" + artifact.Architecture
|
||||
packages = append(packages, spdxPackage{
|
||||
Name: artifact.Filename, SPDXID: id, VersionInfo: input.SourceRevision,
|
||||
DownloadLocation: "NOASSERTION", FilesAnalyzed: false, PackagePurpose: "APPLICATION",
|
||||
Checksums: []spdxChecksum{{Algorithm: "SHA256", ChecksumValue: artifact.SHA256}},
|
||||
LicenseConcluded: "NOASSERTION", LicenseDeclared: "GPL-3.0-only", CopyrightText: "NOASSERTION",
|
||||
Comment: fmt.Sprintf("GOOS=linux; GOARCH=%s; CGO_ENABLED=0; size=%d", artifact.Architecture, artifact.Size),
|
||||
})
|
||||
relationships = append(relationships,
|
||||
spdxRelationship{Element: "SPDXRef-DOCUMENT", Type: "DESCRIBES", Related: id},
|
||||
spdxRelationship{Element: id, Type: "GENERATED_FROM", Related: sourceID},
|
||||
)
|
||||
for _, moduleID := range moduleIDs {
|
||||
relationships = append(relationships, spdxRelationship{Element: id, Type: "DEPENDS_ON", Related: moduleID})
|
||||
}
|
||||
}
|
||||
sort.Slice(packages, func(first, second int) bool { return packages[first].SPDXID < packages[second].SPDXID })
|
||||
sort.Slice(relationships, func(first, second int) bool {
|
||||
left := relationships[first].Element + relationships[first].Type + relationships[first].Related
|
||||
right := relationships[second].Element + relationships[second].Type + relationships[second].Related
|
||||
return left < right
|
||||
})
|
||||
document := spdxDocument{
|
||||
SPDXVersion: "SPDX-2.3", DataLicense: "CC0-1.0", SPDXID: "SPDXRef-DOCUMENT",
|
||||
Name: "verse-gateway-" + input.SourceRevision,
|
||||
DocumentNamespace: "https://git.sechmachine.io.vn/sechmachine/VerseVDI-Data-Plane/spdx/" + input.SourceRevision,
|
||||
CreationInfo: spdxCreationInfo{
|
||||
Created: input.Created.Format(time.RFC3339),
|
||||
Creators: []string{"Tool: verse-gateway-sbom"},
|
||||
},
|
||||
DocumentComment: "notices=LICENSE; provenance=Go build metadata and artifact SHA-256; vulnerability_status=unscanned; signing_status=unsigned; phase3c_c_image_remediation=separate",
|
||||
Packages: packages, Relationships: relationships,
|
||||
}
|
||||
encoded, err := json.MarshalIndent(document, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(encoded, '\n'), nil
|
||||
}
|
||||
|
||||
func shortDigest(value string) string {
|
||||
digest := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(digest[:8])
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildSPDXIsDeterministicAndTruthful(t *testing.T) {
|
||||
input := sbomInput{
|
||||
SourceRevision: strings.Repeat("a", 40),
|
||||
Created: time.Date(2026, time.July, 30, 1, 2, 3, 0, time.UTC),
|
||||
ProtocolGoModSum: "h1:protocol-go-mod",
|
||||
Modules: []goModule{
|
||||
{Path: "git.sechmachine.io.vn/sechmachine/VerseVDI-Data-Plane", Version: "(devel)"},
|
||||
{Path: protocolModulePath, Version: "v1.0.0-phase3c-gateway-rc.9", Sum: "h1:protocol"},
|
||||
{Path: "github.com/quic-go/quic-go", Version: "v0.61.0", Sum: "h1:quic"},
|
||||
},
|
||||
Artifacts: []gatewayArtifact{
|
||||
{Architecture: "amd64", Filename: "verse-gateway-linux-amd64", SHA256: strings.Repeat("1", 64), Size: 100},
|
||||
{Architecture: "arm64", Filename: "verse-gateway-linux-arm64", SHA256: strings.Repeat("2", 64), Size: 101},
|
||||
},
|
||||
}
|
||||
first, err := buildSPDX(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := buildSPDX(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(first, second) {
|
||||
t.Fatal("SPDX generation was not byte-stable")
|
||||
}
|
||||
text := string(first)
|
||||
for _, required := range []string{
|
||||
`"spdxVersion": "SPDX-2.3"`,
|
||||
input.SourceRevision,
|
||||
"v1.0.0-phase3c-gateway-rc.9",
|
||||
input.ProtocolGoModSum,
|
||||
"verse-gateway-linux-amd64",
|
||||
"verse-gateway-linux-arm64",
|
||||
strings.Repeat("1", 64),
|
||||
strings.Repeat("2", 64),
|
||||
`"licenseConcluded": "NOASSERTION"`,
|
||||
"vulnerability_status=unscanned",
|
||||
"signing_status=unsigned",
|
||||
} {
|
||||
if !strings.Contains(text, required) {
|
||||
t.Fatalf("SPDX is missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSPDXRejectsAmbiguousInputs(t *testing.T) {
|
||||
valid := sbomInput{
|
||||
SourceRevision: strings.Repeat("a", 40), Created: time.Unix(0, 0).UTC(),
|
||||
ProtocolGoModSum: "h1:protocol-go-mod",
|
||||
Modules: []goModule{
|
||||
{Path: protocolModulePath, Version: "v1.0.0-rc.9", Sum: "h1:protocol"},
|
||||
},
|
||||
Artifacts: []gatewayArtifact{
|
||||
{Architecture: "amd64", Filename: "amd64", SHA256: strings.Repeat("1", 64), Size: 1},
|
||||
{Architecture: "arm64", Filename: "arm64", SHA256: strings.Repeat("2", 64), Size: 1},
|
||||
},
|
||||
}
|
||||
mutations := []func(*sbomInput){
|
||||
func(input *sbomInput) { input.SourceRevision = "short" },
|
||||
func(input *sbomInput) { input.Modules = nil },
|
||||
func(input *sbomInput) { input.Artifacts[1].Architecture = "amd64" },
|
||||
}
|
||||
for index, mutate := range mutations {
|
||||
input := valid
|
||||
input.Modules = append([]goModule(nil), valid.Modules...)
|
||||
input.Artifacts = append([]gatewayArtifact(nil), valid.Artifacts...)
|
||||
mutate(&input)
|
||||
if _, err := buildSPDX(input); err == nil {
|
||||
t.Fatalf("invalid SPDX input %d was accepted", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"net/http"
|
||||
|
||||
"git.sechmachine.io.vn/sechmachine/VerseVDI-Data-Plane/gateway"
|
||||
protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
var listen, advertiseAddress, controlPlane, certFile, keyFile, clientCAFile string
|
||||
var gatewayID, instanceIdentity, certificateIdentity, publicIdentity string
|
||||
flag.StringVar(&listen, "listen", "0.0.0.0:443", "gateway QUIC listen address")
|
||||
flag.StringVar(&advertiseAddress, "advertise-address", "", "client-visible gateway address host:port")
|
||||
flag.StringVar(&controlPlane, "control-plane", "", "Connection Server HTTPS base URL")
|
||||
flag.StringVar(&certFile, "cert", "", "gateway certificate PEM")
|
||||
flag.StringVar(&keyFile, "key", "", "gateway private key PEM")
|
||||
flag.StringVar(&clientCAFile, "client-ca", "", "Connection Server/client CA PEM")
|
||||
flag.StringVar(&gatewayID, "gateway-id", "", "stable gateway identifier")
|
||||
flag.StringVar(&instanceIdentity, "instance-identity", "", "gateway instance identity")
|
||||
flag.StringVar(&certificateIdentity, "certificate-identity", "", "gateway certificate identity")
|
||||
flag.StringVar(&publicIdentity, "public-identity", "gateway", "gateway public identity")
|
||||
flag.Parse()
|
||||
for name, value := range map[string]string{"control-plane": controlPlane, "advertise-address": advertiseAddress, "cert": certFile, "key": keyFile, "client-ca": clientCAFile, "gateway-id": gatewayID, "instance-identity": instanceIdentity, "certificate-identity": certificateIdentity} {
|
||||
if value == "" {
|
||||
return fmt.Errorf("-%s is required", name)
|
||||
}
|
||||
}
|
||||
if err := validateAdvertisedAddress(advertiseAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
serverTLS, clientTLS, err := loadTLS(certFile, keyFile, clientCAFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
transport := &http.Transport{TLSClientConfig: clientTLS}
|
||||
controlPlaneClient := gateway.NewControlPlaneClient(controlPlane, &http.Client{Transport: transport, Timeout: 5 * time.Second})
|
||||
provider := gateway.NewApolloAdapter(gateway.NewNativeApolloBackend(), gateway.ProviderIdentity{})
|
||||
capabilities := gateway.DefaultCapabilities()
|
||||
server, err := gateway.NewServer(gateway.ServerConfig{ListenAddress: listen, TLSConfig: serverTLS, GatewayID: gatewayID, Capabilities: capabilities, ProviderCapabilities: capabilities, Admission: controlPlaneClient, ProviderStateReporter: controlPlaneClient, ClipboardAuditReporter: controlPlaneClient, Provider: provider, PacerKbps: 100000})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
registration := protocol.GatewayRegistration{Version: "1", GatewayID: gatewayID, InstanceIdentity: instanceIdentity, CertificateIdentity: certificateIdentity, PublicIdentity: publicIdentity, Address: advertiseAddress, ProviderIdentity: "server-derived", ProtocolMinVersion: 1, ProtocolMaxVersion: 1, ConnectionCapacity: 8, BandwidthCapacityKbps: 100000, Features: []string{"quic-tls13", "datagram.media", "apollo"}, Capabilities: capabilities}
|
||||
if _, err := controlPlaneClient.Register(context.Background(), registration); err != nil {
|
||||
_ = server.Close()
|
||||
return err
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
go heartbeatLoop(ctx, controlPlaneClient, server, registration)
|
||||
return server.Serve(ctx)
|
||||
}
|
||||
|
||||
func validateAdvertisedAddress(address string) error {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil || host == "" || port == "" {
|
||||
return fmt.Errorf("-advertise-address must be a host:port address")
|
||||
}
|
||||
if parsed := net.ParseIP(host); parsed != nil && parsed.IsUnspecified() {
|
||||
return fmt.Errorf("-advertise-address must not be an unspecified address")
|
||||
}
|
||||
if net.JoinHostPort(host, port) != address {
|
||||
return fmt.Errorf("-advertise-address must use canonical host:port syntax")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func heartbeatLoop(ctx context.Context, client *gateway.ControlPlaneClient, server *gateway.Server, registration protocol.GatewayRegistration) {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
var sequence int64
|
||||
var sampler heartbeatSampler
|
||||
_, _ = sampler.sample(time.Now(), server.Metrics())
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
server.BeginDrain()
|
||||
deadline := time.Now().Add(5 * time.Second).UTC().Format(time.RFC3339Nano)
|
||||
_ = client.Drain(context.Background(), protocol.GatewayDrain{Version: "1", GatewayID: registration.GatewayID, Sequence: sequence + 1, Reason: "shutdown", Deadline: deadline})
|
||||
return
|
||||
case <-ticker.C:
|
||||
sequence++
|
||||
state := "ready"
|
||||
if server.Draining() {
|
||||
state = "draining"
|
||||
}
|
||||
metrics := server.Metrics()
|
||||
observedAt := time.Now()
|
||||
egressKbps, telemetry := sampler.sample(observedAt, metrics)
|
||||
_ = client.Heartbeat(ctx, protocol.GatewayHeartbeat{
|
||||
Version: "1", GatewayID: registration.GatewayID, Sequence: sequence,
|
||||
ObservedAt: observedAt.UTC().Format(time.RFC3339Nano), ActiveConnections: metrics.ActiveSessions,
|
||||
EgressKbps: egressKbps, State: state, Telemetry: telemetry,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type heartbeatSampler struct {
|
||||
observedAt time.Time
|
||||
mediaBytes uint64
|
||||
}
|
||||
|
||||
func (s *heartbeatSampler) sample(observedAt time.Time, metrics gateway.MetricsSnapshot) (int64, protocol.GatewayTelemetry) {
|
||||
egressKbps := int64(0)
|
||||
elapsedMillis := observedAt.Sub(s.observedAt).Milliseconds()
|
||||
if !s.observedAt.IsZero() && elapsedMillis > 0 && metrics.MediaBytes >= s.mediaBytes {
|
||||
delta := metrics.MediaBytes - s.mediaBytes
|
||||
milliseconds := uint64(elapsedMillis)
|
||||
whole, remainder := delta/milliseconds, delta%milliseconds
|
||||
if whole > 125_000_000 {
|
||||
egressKbps = 1_000_000_000
|
||||
} else {
|
||||
rate := whole*8 + remainder*8/milliseconds
|
||||
if rate > 1_000_000_000 {
|
||||
rate = 1_000_000_000
|
||||
}
|
||||
egressKbps = int64(rate)
|
||||
}
|
||||
}
|
||||
s.observedAt, s.mediaBytes = observedAt, metrics.MediaBytes
|
||||
return egressKbps, protocol.GatewayTelemetry{
|
||||
AdmittedSessions: boundedMetric(metrics.AdmittedSessions), AdmissionRejects: boundedMetric(metrics.AdmissionRejects),
|
||||
Reconnects: boundedMetric(metrics.Reconnects), DrainTransitions: boundedMetric(metrics.DrainTransitions),
|
||||
MediaDrops: boundedMetric(metrics.MediaDrops), MediaPackets: boundedMetric(metrics.MediaPackets),
|
||||
MediaBytes: boundedMetric(metrics.MediaBytes), QueueDelayMicros: boundedMetric(metrics.QueueDelayNanos / 1000),
|
||||
ProcessingDelayMicros: boundedMetric(metrics.ProcessingDelayNanos / 1000), ProcessingSamples: boundedMetric(metrics.ProcessingSamples),
|
||||
PacingDelayMicros: boundedMetric(metrics.PacingDelayNanos / 1000), ProviderErrors: boundedMetric(metrics.ProviderErrors),
|
||||
InputRejected: boundedMetric(metrics.InputRejected), ControlRttMicros: boundedMetric(metrics.ControlRTTNanos / 1000),
|
||||
ControlJitterMicros: boundedMetric(metrics.ControlJitterNanos / 1000), ControlLossPpm: boundedMetric(metrics.ControlLossPPM),
|
||||
PendingReliable: boundedMetric(metrics.PendingReliable), ProviderState: providerStateName(metrics.ProviderState),
|
||||
}
|
||||
}
|
||||
|
||||
func boundedMetric(value uint64) int64 {
|
||||
const maximum = uint64(^uint64(0) >> 1)
|
||||
if value > maximum {
|
||||
return int64(maximum)
|
||||
}
|
||||
return int64(value)
|
||||
}
|
||||
|
||||
func providerStateName(value uint64) string {
|
||||
switch value {
|
||||
case 1:
|
||||
return gateway.ProviderStateStarting
|
||||
case 2:
|
||||
return gateway.ProviderStateReady
|
||||
case 3:
|
||||
return gateway.ProviderStateDisconnected
|
||||
case 4:
|
||||
return gateway.ProviderStateTerminating
|
||||
case 5:
|
||||
return gateway.ProviderStateTerminated
|
||||
case 6:
|
||||
return gateway.ProviderStateCleanup
|
||||
case 7:
|
||||
return gateway.ProviderStateFailed
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func loadTLS(certFile, keyFile, clientCAFile string) (*tls.Config, *tls.Config, error) {
|
||||
certificate, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
caBytes, err := os.ReadFile(clientCAFile)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
clientCAs := x509.NewCertPool()
|
||||
if !clientCAs.AppendCertsFromPEM(caBytes) {
|
||||
return nil, nil, errors.New("client CA PEM contains no certificate")
|
||||
}
|
||||
return &tls.Config{MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{certificate}, ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: clientCAs}, &tls.Config{MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{certificate}, RootCAs: clientCAs}, nil
|
||||
}
|
||||
|
||||
func parseProviderIdentity(value string) (gateway.ProviderIdentity, error) {
|
||||
uniqueID, fingerprint, ok := strings.Cut(value, "#")
|
||||
if !ok || uniqueID == "" || fingerprint == "" {
|
||||
return gateway.ProviderIdentity{}, errors.New("provider identity must be unique-id#fingerprint")
|
||||
}
|
||||
return gateway.ProviderIdentity{UniqueID: uniqueID, Fingerprint: fingerprint}, nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sechmachine.io.vn/sechmachine/VerseVDI-Data-Plane/gateway"
|
||||
protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol"
|
||||
)
|
||||
|
||||
func TestHeartbeatReportsMeasuredEgressInsteadOfConfiguredCapacity(t *testing.T) {
|
||||
heartbeats := make(chan protocol.GatewayHeartbeat, 1)
|
||||
control := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path == "/api/v1/gateway/heartbeat" {
|
||||
heartbeat, err := protocol.DecodeGatewayHeartbeat(mustReadBody(t, request))
|
||||
if err != nil {
|
||||
t.Errorf("decode heartbeat: %v", err)
|
||||
} else {
|
||||
heartbeats <- heartbeat
|
||||
}
|
||||
}
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer control.Close()
|
||||
|
||||
server, err := gateway.NewServer(gateway.ServerConfig{
|
||||
ListenAddress: "127.0.0.1:0", TLSConfig: heartbeatTestTLS(t), GatewayID: "gateway-1",
|
||||
Admission: gateway.AdmissionFunc(func(context.Context, protocol.TunnelAdmissionRequest) (protocol.SessionAuthority, error) {
|
||||
return protocol.SessionAuthority{}, gateway.ErrAdmissionRejected
|
||||
}),
|
||||
Provider: gateway.NewFakeApollo(gateway.FakeApolloConfig{}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer server.Close()
|
||||
registration := protocol.GatewayRegistration{GatewayID: "gateway-1", BandwidthCapacityKbps: 100000}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go heartbeatLoop(ctx, gateway.NewControlPlaneClient(control.URL, control.Client()), server, registration)
|
||||
|
||||
select {
|
||||
case heartbeat := <-heartbeats:
|
||||
cancel()
|
||||
if heartbeat.EgressKbps != 0 {
|
||||
t.Fatalf("idle measured egress = %d Kbps, want 0; configured capacity is not traffic", heartbeat.EgressKbps)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("heartbeat was not published")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatSamplerUsesByteDeltaAndMonotonicElapsed(t *testing.T) {
|
||||
var sampler heartbeatSampler
|
||||
start := time.Now()
|
||||
if egress, _ := sampler.sample(start, gateway.MetricsSnapshot{MediaBytes: 1000}); egress != 0 {
|
||||
t.Fatalf("first sample egress = %d, want baseline 0", egress)
|
||||
}
|
||||
egress, telemetry := sampler.sample(start.Add(2*time.Second), gateway.MetricsSnapshot{
|
||||
AdmittedSessions: 2, AdmissionRejects: 3, Reconnects: 4, DrainTransitions: 5,
|
||||
MediaDrops: 6, MediaPackets: 7, MediaBytes: 17000, QueueDelayNanos: 9000,
|
||||
ProcessingDelayNanos: 10000, ProcessingSamples: 11, PacingDelayNanos: 12000,
|
||||
ProviderErrors: 13, InputRejected: 14, ControlRTTNanos: 15000,
|
||||
ControlJitterNanos: 16000, ControlLossPPM: 17, PendingReliable: 18, ProviderState: 2,
|
||||
})
|
||||
if egress != 64 || telemetry.MediaBytes != 17000 || telemetry.MediaPackets != 7 ||
|
||||
telemetry.QueueDelayMicros != 9 || telemetry.ProviderState != gateway.ProviderStateReady {
|
||||
t.Fatalf("sample = egress:%d telemetry:%#v", egress, telemetry)
|
||||
}
|
||||
}
|
||||
|
||||
func mustReadBody(t *testing.T, request *http.Request) []byte {
|
||||
t.Helper()
|
||||
defer request.Body.Close()
|
||||
data, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func heartbeatTestTLS(t *testing.T) *tls.Config {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "gateway.test"},
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
|
||||
IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certificate := tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(template)
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{certificate},
|
||||
ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: pool,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package gateway
|
||||
|
||||
const (
|
||||
apolloAudioDataShards = 4
|
||||
apolloAudioParityShards = 2
|
||||
apolloAudioTotalShards = apolloAudioDataShards + apolloAudioParityShards
|
||||
apolloAudioMaximumBlocks = 4
|
||||
)
|
||||
|
||||
type apolloAudioFECBlock struct {
|
||||
base uint16
|
||||
timestamp uint32
|
||||
ssrc uint32
|
||||
haveFEC bool
|
||||
size int
|
||||
shards [apolloAudioTotalShards][]byte
|
||||
received [apolloAudioTotalShards]bool
|
||||
count int
|
||||
}
|
||||
|
||||
type apolloAudioAssembler struct {
|
||||
blocks map[uint16]*apolloAudioFECBlock
|
||||
}
|
||||
|
||||
func (a *apolloAudioAssembler) Add(codec *apolloMediaCodec, shard apolloAudioShard) ([][]byte, bool, error) {
|
||||
if codec == nil || len(shard.payload) == 0 || len(shard.payload) > 1408 || len(shard.payload)%16 != 0 {
|
||||
return nil, false, errApolloMedia
|
||||
}
|
||||
if a.blocks == nil {
|
||||
a.blocks = make(map[uint16]*apolloAudioFECBlock)
|
||||
}
|
||||
base := shard.base
|
||||
if base&3 != 0 {
|
||||
return nil, false, errApolloMedia
|
||||
}
|
||||
index := 0
|
||||
if shard.parity {
|
||||
if shard.parityIndex >= apolloAudioParityShards {
|
||||
return nil, false, errApolloMedia
|
||||
}
|
||||
index = apolloAudioDataShards + int(shard.parityIndex)
|
||||
} else {
|
||||
index = int(uint16(shard.sequence - base))
|
||||
if index >= apolloAudioDataShards {
|
||||
return nil, false, errApolloMedia
|
||||
}
|
||||
}
|
||||
evicted := false
|
||||
block := a.blocks[base]
|
||||
if block == nil {
|
||||
if len(a.blocks) >= apolloAudioMaximumBlocks {
|
||||
var oldest uint16
|
||||
var maximumAge uint16
|
||||
for candidate := range a.blocks {
|
||||
age := base - candidate
|
||||
if age > maximumAge && age < 1<<15 {
|
||||
oldest, maximumAge = candidate, age
|
||||
}
|
||||
}
|
||||
if maximumAge == 0 {
|
||||
return nil, false, errApolloMedia
|
||||
}
|
||||
delete(a.blocks, oldest)
|
||||
evicted = true
|
||||
}
|
||||
block = &apolloAudioFECBlock{base: base}
|
||||
a.blocks[base] = block
|
||||
}
|
||||
if block.size == 0 {
|
||||
block.size = len(shard.payload)
|
||||
} else if block.size != len(shard.payload) {
|
||||
return nil, evicted, errApolloMedia
|
||||
}
|
||||
if shard.parity {
|
||||
if block.haveFEC && (block.timestamp != shard.timestamp || block.ssrc != shard.ssrc) {
|
||||
return nil, evicted, errApolloMedia
|
||||
}
|
||||
block.timestamp, block.ssrc, block.haveFEC = shard.timestamp, shard.ssrc, true
|
||||
} else {
|
||||
if block.haveFEC && (shard.timestamp != block.timestamp+uint32(index*5) || shard.ssrc != block.ssrc) {
|
||||
return nil, evicted, errApolloMedia
|
||||
}
|
||||
}
|
||||
if block.received[index] {
|
||||
return nil, evicted, errApolloMedia
|
||||
}
|
||||
block.shards[index] = append([]byte(nil), shard.payload...)
|
||||
block.received[index] = true
|
||||
block.count++
|
||||
if block.count < apolloAudioDataShards {
|
||||
return nil, evicted, nil
|
||||
}
|
||||
if err := reconstructApolloAudioBlock(block); err != nil {
|
||||
return nil, evicted, err
|
||||
}
|
||||
output := make([][]byte, apolloAudioDataShards)
|
||||
for index := range output {
|
||||
payload, err := codec.openApolloAudioCipher(base+uint16(index), block.shards[index])
|
||||
if err != nil {
|
||||
return nil, evicted, err
|
||||
}
|
||||
output[index] = payload
|
||||
}
|
||||
delete(a.blocks, base)
|
||||
return output, evicted, nil
|
||||
}
|
||||
|
||||
func reconstructApolloAudioBlock(block *apolloAudioFECBlock) error {
|
||||
if block == nil || block.count < apolloAudioDataShards || block.size == 0 {
|
||||
return errApolloMedia
|
||||
}
|
||||
missing := false
|
||||
for index := 0; index < apolloAudioDataShards; index++ {
|
||||
if !block.received[index] {
|
||||
missing = true
|
||||
block.shards[index] = make([]byte, block.size)
|
||||
}
|
||||
}
|
||||
if !missing {
|
||||
return nil
|
||||
}
|
||||
if !block.haveFEC {
|
||||
return errApolloMedia
|
||||
}
|
||||
rows := make([][]byte, 0, apolloAudioDataShards)
|
||||
shards := make([][]byte, 0, apolloAudioDataShards)
|
||||
for index, received := range block.received {
|
||||
if !received {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, apolloAudioFECRow(index))
|
||||
shards = append(shards, block.shards[index])
|
||||
if len(rows) == apolloAudioDataShards {
|
||||
break
|
||||
}
|
||||
}
|
||||
inverse, ok := apolloGFInvert(rows)
|
||||
if !ok {
|
||||
return errApolloMedia
|
||||
}
|
||||
for index := 0; index < apolloAudioDataShards; index++ {
|
||||
if block.received[index] {
|
||||
continue
|
||||
}
|
||||
for source, coefficient := range inverse[index] {
|
||||
apolloGFAXPY(block.shards[index], shards[source], coefficient)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func apolloAudioFECRow(index int) []byte {
|
||||
if index < apolloAudioDataShards {
|
||||
row := make([]byte, apolloAudioDataShards)
|
||||
row[index] = 1
|
||||
return row
|
||||
}
|
||||
parity := [8]byte{0x77, 0x40, 0x38, 0x0e, 0xc7, 0xa7, 0x0d, 0x6c}
|
||||
return append([]byte(nil), parity[(index-apolloAudioDataShards)*apolloAudioDataShards:(index-apolloAudioDataShards+1)*apolloAudioDataShards]...)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
)
|
||||
|
||||
const (
|
||||
apolloControlOuterType = 0x0001
|
||||
apolloControlHeaderSize = 8
|
||||
apolloControlTagSize = 16
|
||||
apolloControlInnerSize = 4
|
||||
apolloControlMaximumPlain = 2048
|
||||
|
||||
apolloControlTypeStart = 0x0307
|
||||
apolloControlTypeIDR = 0x0302
|
||||
apolloControlTypePing = 0x0200
|
||||
apolloControlTypeInput = 0x0206
|
||||
apolloControlTypeFEC = 0x5502
|
||||
apolloControlTypeRumble = 0x010b
|
||||
apolloControlTypeHDR = 0x010e
|
||||
apolloControlTypeTerm = 0x0109
|
||||
)
|
||||
|
||||
var errApolloControl = errors.New("apollo control malformed")
|
||||
|
||||
type apolloControlMessage struct {
|
||||
typeID uint16
|
||||
payload []byte
|
||||
}
|
||||
|
||||
type apolloControlCodec struct {
|
||||
aead cipher.AEAD
|
||||
nextClient uint32
|
||||
lastHost uint32
|
||||
hostSeen bool
|
||||
}
|
||||
|
||||
func newApolloControlCodec(key []byte) (*apolloControlCodec, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &apolloControlCodec{aead: aead}, nil
|
||||
}
|
||||
|
||||
func (c *apolloControlCodec) SealClient(typeID uint16, payload []byte) ([]byte, error) {
|
||||
if c == nil || c.aead == nil || typeID == 0 || len(payload) > apolloControlMaximumPlain || c.nextClient == ^uint32(0) {
|
||||
return nil, errApolloControl
|
||||
}
|
||||
sequence := c.nextClient
|
||||
c.nextClient++
|
||||
inner := make([]byte, apolloControlInnerSize+len(payload))
|
||||
binary.LittleEndian.PutUint16(inner[:2], typeID)
|
||||
binary.LittleEndian.PutUint16(inner[2:4], uint16(len(payload)))
|
||||
copy(inner[4:], payload)
|
||||
nonce := apolloControlNonce(sequence, 'C')
|
||||
sealed := c.aead.Seal(nil, nonce[:], inner, nil)
|
||||
packet := make([]byte, apolloControlHeaderSize+len(sealed))
|
||||
binary.LittleEndian.PutUint16(packet[:2], apolloControlOuterType)
|
||||
binary.LittleEndian.PutUint16(packet[2:4], uint16(4+len(sealed)))
|
||||
binary.LittleEndian.PutUint32(packet[4:8], sequence)
|
||||
copy(packet[8:24], sealed[len(inner):])
|
||||
copy(packet[24:], sealed[:len(inner)])
|
||||
return packet, nil
|
||||
}
|
||||
|
||||
func (c *apolloControlCodec) OpenHost(packet []byte) (apolloControlMessage, error) {
|
||||
if c == nil || c.aead == nil || len(packet) < apolloControlHeaderSize+apolloControlTagSize+apolloControlInnerSize || len(packet) > apolloControlHeaderSize+apolloControlTagSize+apolloControlMaximumPlain {
|
||||
return apolloControlMessage{}, errApolloControl
|
||||
}
|
||||
if binary.LittleEndian.Uint16(packet[:2]) != apolloControlOuterType || int(binary.LittleEndian.Uint16(packet[2:4])) != len(packet)-4 {
|
||||
return apolloControlMessage{}, errApolloControl
|
||||
}
|
||||
sequence := binary.LittleEndian.Uint32(packet[4:8])
|
||||
if c.hostSeen && sequence <= c.lastHost {
|
||||
return apolloControlMessage{}, errApolloControl
|
||||
}
|
||||
nonce := apolloControlNonce(sequence, 'H')
|
||||
sealed := make([]byte, len(packet)-apolloControlHeaderSize)
|
||||
copy(sealed, packet[24:])
|
||||
copy(sealed[len(packet)-24:], packet[8:24])
|
||||
plaintext, err := c.aead.Open(nil, nonce[:], sealed, nil)
|
||||
if err != nil || len(plaintext) < apolloControlInnerSize {
|
||||
return apolloControlMessage{}, errApolloControl
|
||||
}
|
||||
length := int(binary.LittleEndian.Uint16(plaintext[2:4]))
|
||||
if length != len(plaintext)-apolloControlInnerSize || length > apolloControlMaximumPlain {
|
||||
return apolloControlMessage{}, errApolloControl
|
||||
}
|
||||
c.lastHost, c.hostSeen = sequence, true
|
||||
return apolloControlMessage{typeID: binary.LittleEndian.Uint16(plaintext[:2]), payload: append([]byte(nil), plaintext[4:]...)}, nil
|
||||
}
|
||||
|
||||
func apolloControlNonce(sequence uint32, origin byte) [12]byte {
|
||||
var nonce [12]byte
|
||||
binary.LittleEndian.PutUint32(nonce[:4], sequence)
|
||||
nonce[10], nonce[11] = origin, 'C'
|
||||
return nonce
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
apolloENetChannels = 48
|
||||
apolloENetMaximumPacket = 4096
|
||||
apolloENetMaximumPayload = 2048
|
||||
apolloENetMaximumCommands = 32
|
||||
apolloENetMaximumPending = 128
|
||||
apolloENetMaximumReorder = 64
|
||||
// These are the pinned ENet fork defaults. The connection remains subject
|
||||
// to the stricter ten-second no-receive peer deadline below.
|
||||
apolloENetTimeoutLimit = 32
|
||||
apolloENetTimeoutMinimum = 5 * time.Second
|
||||
apolloENetTimeoutMaximum = 30 * time.Second
|
||||
apolloENetPeerTimeout = 10 * time.Second
|
||||
apolloENetPeerIDMask = 0x0fff
|
||||
apolloENetSentTimeFlag = 0x8000
|
||||
apolloENetCompressedFlag = 0x4000
|
||||
apolloENetSessionMask = 0x3000
|
||||
apolloENetSessionShift = 12
|
||||
apolloENetCommandMask = 0x0f
|
||||
apolloENetAcknowledged = 0x80
|
||||
apolloENetUnsequenced = 0x40
|
||||
apolloENetConnect = 2
|
||||
apolloENetVerifyConnect = 3
|
||||
apolloENetDisconnect = 4
|
||||
apolloENetPing = 5
|
||||
apolloENetSendReliable = 6
|
||||
apolloENetSendUnsequenced = 9
|
||||
apolloENetBandwidthLimit = 10
|
||||
apolloENetThrottleConfig = 11
|
||||
)
|
||||
|
||||
var errApolloENet = errors.New("apollo ENet malformed")
|
||||
|
||||
type apolloENetState uint8
|
||||
|
||||
const (
|
||||
apolloENetConnecting apolloENetState = iota
|
||||
apolloENetConnected
|
||||
apolloENetDisconnecting
|
||||
apolloENetClosed
|
||||
)
|
||||
|
||||
type apolloENetChannel struct {
|
||||
nextOutgoing uint16
|
||||
lastIncoming uint16
|
||||
hasIncoming bool
|
||||
incoming map[uint16][]byte
|
||||
}
|
||||
|
||||
type apolloENetPendingKey struct {
|
||||
channel uint8
|
||||
sequence uint16
|
||||
}
|
||||
|
||||
type apolloENetPending struct {
|
||||
packet []byte
|
||||
firstSent time.Time
|
||||
sentTime time.Time
|
||||
timeout time.Duration
|
||||
attempts uint8
|
||||
}
|
||||
|
||||
// apolloENetPeer is deliberately scoped to the Apollo adapter. It implements
|
||||
// one negotiated ENet peer over one connected UDP socket and exposes no
|
||||
// reusable transport abstraction.
|
||||
type apolloENetPeer struct {
|
||||
conn *net.UDPConn
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
state apolloENetState
|
||||
peerID uint16
|
||||
inboundSession uint8
|
||||
outboundSession uint8
|
||||
connectID uint32
|
||||
channels [apolloENetChannels]apolloENetChannel
|
||||
pending map[apolloENetPendingKey]*apolloENetPending
|
||||
unsequenced uint16
|
||||
rtt time.Duration
|
||||
variance time.Duration
|
||||
reliableSent uint64
|
||||
retransmits uint64
|
||||
lastReceive time.Time
|
||||
lastSend time.Time
|
||||
lastPing time.Time
|
||||
disconnectAck chan struct{}
|
||||
disconnectSeq uint16
|
||||
onPayload func(uint8, bool, []byte)
|
||||
onDisconnect func(error)
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func newApolloENetPeer(conn *net.UDPConn, now func() time.Time) (*apolloENetPeer, error) {
|
||||
if conn == nil {
|
||||
return nil, ErrProviderMalformed
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &apolloENetPeer{
|
||||
conn: conn, now: now, state: apolloENetConnecting, pending: make(map[apolloENetPendingKey]*apolloENetPending),
|
||||
rtt: 500 * time.Millisecond, variance: time.Millisecond, done: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *apolloENetPeer) Connect(ctx context.Context, connectData uint32) error {
|
||||
if p == nil || connectData == 0 {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
var id [4]byte
|
||||
if _, err := rand.Read(id[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
p.mu.Lock()
|
||||
if p.state != apolloENetConnecting {
|
||||
p.mu.Unlock()
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
p.connectID = binary.BigEndian.Uint32(id[:])
|
||||
now := p.now()
|
||||
packet := apolloENetConnectPacket(now, p.connectID, connectData)
|
||||
p.pending[apolloENetPendingKey{channel: 0xff, sequence: 1}] = &apolloENetPending{packet: append([]byte(nil), packet...), firstSent: now, sentTime: now, timeout: apolloENetRetransmitTimeout(p.rtt, p.variance, 1), attempts: 1}
|
||||
err := p.writeLocked(packet)
|
||||
p.mu.Unlock()
|
||||
if err != nil {
|
||||
p.close(err)
|
||||
return err
|
||||
}
|
||||
for {
|
||||
if err := p.readOnce(ctx); err != nil {
|
||||
p.close(err)
|
||||
return err
|
||||
}
|
||||
p.mu.Lock()
|
||||
connected := p.state == apolloENetConnected
|
||||
p.mu.Unlock()
|
||||
if connected {
|
||||
go p.run()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func apolloENetConnectPacket(now time.Time, connectID, data uint32) []byte {
|
||||
packet := make([]byte, 52)
|
||||
apolloENetHeader(packet, apolloENetPeerIDMask, 0, now)
|
||||
packet[4] = apolloENetConnect | apolloENetAcknowledged
|
||||
packet[5] = 0xff
|
||||
binary.BigEndian.PutUint16(packet[6:8], 1)
|
||||
binary.BigEndian.PutUint16(packet[8:10], 0)
|
||||
packet[10], packet[11] = 0xff, 0xff
|
||||
binary.BigEndian.PutUint32(packet[12:16], 1400)
|
||||
binary.BigEndian.PutUint32(packet[16:20], 32768)
|
||||
binary.BigEndian.PutUint32(packet[20:24], apolloENetChannels)
|
||||
binary.BigEndian.PutUint32(packet[24:28], 0)
|
||||
binary.BigEndian.PutUint32(packet[28:32], 0)
|
||||
binary.BigEndian.PutUint32(packet[32:36], 5000)
|
||||
binary.BigEndian.PutUint32(packet[36:40], 2)
|
||||
binary.BigEndian.PutUint32(packet[40:44], 2)
|
||||
binary.BigEndian.PutUint32(packet[44:48], connectID)
|
||||
binary.BigEndian.PutUint32(packet[48:52], data)
|
||||
return packet
|
||||
}
|
||||
|
||||
func apolloENetHeader(packet []byte, peerID uint16, session uint8, now time.Time) {
|
||||
value := peerID&apolloENetPeerIDMask | (uint16(session&3) << apolloENetSessionShift) | apolloENetSentTimeFlag
|
||||
binary.BigEndian.PutUint16(packet[:2], value)
|
||||
binary.BigEndian.PutUint16(packet[2:4], uint16(now.UnixMilli()))
|
||||
}
|
||||
|
||||
func (p *apolloENetPeer) run() {
|
||||
ticker := time.NewTicker(25 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-p.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := p.maintain(); err != nil {
|
||||
p.close(err)
|
||||
return
|
||||
}
|
||||
default:
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
err := p.readOnce(ctx)
|
||||
cancel()
|
||||
if err != nil && !errors.Is(err, context.DeadlineExceeded) && !isApolloENetTimeout(err) {
|
||||
p.close(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *apolloENetPeer) readOnce(ctx context.Context) error {
|
||||
if p == nil || p.conn == nil {
|
||||
return ErrProviderDisconnected
|
||||
}
|
||||
deadline := time.Now().Add(100 * time.Millisecond)
|
||||
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
|
||||
deadline = contextDeadline
|
||||
}
|
||||
if err := p.conn.SetReadDeadline(deadline); err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := make([]byte, apolloENetMaximumPacket+1)
|
||||
count, err := p.conn.Read(buffer)
|
||||
if err != nil {
|
||||
if networkErr, ok := err.(net.Error); ok && networkErr.Timeout() {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
return err
|
||||
}
|
||||
if count < 4 || count > apolloENetMaximumPacket {
|
||||
return errApolloENet
|
||||
}
|
||||
return p.handleDatagram(buffer[:count])
|
||||
}
|
||||
|
||||
func isApolloENetTimeout(err error) bool {
|
||||
return errors.Is(err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
func (p *apolloENetPeer) handleDatagram(packet []byte) error {
|
||||
if len(packet) < 4 || len(packet) > apolloENetMaximumPacket {
|
||||
return errApolloENet
|
||||
}
|
||||
header := binary.BigEndian.Uint16(packet[:2])
|
||||
if header&apolloENetCompressedFlag != 0 {
|
||||
return errApolloENet
|
||||
}
|
||||
peerID := header & apolloENetPeerIDMask
|
||||
session := uint8((header & apolloENetSessionMask) >> apolloENetSessionShift)
|
||||
offset := 2
|
||||
sentTime := uint16(0)
|
||||
if header&apolloENetSentTimeFlag != 0 {
|
||||
if len(packet) < 4 {
|
||||
return errApolloENet
|
||||
}
|
||||
sentTime = binary.BigEndian.Uint16(packet[2:4])
|
||||
offset = 4
|
||||
}
|
||||
p.mu.Lock()
|
||||
state := p.state
|
||||
if state == apolloENetClosed || (state == apolloENetConnected && (peerID != p.peerID || session != p.inboundSession)) {
|
||||
p.mu.Unlock()
|
||||
return errApolloENet
|
||||
}
|
||||
p.lastReceive = p.now()
|
||||
p.mu.Unlock()
|
||||
commands := 0
|
||||
for offset < len(packet) {
|
||||
commands++
|
||||
if commands > apolloENetMaximumCommands || len(packet)-offset < 4 {
|
||||
return errApolloENet
|
||||
}
|
||||
command := packet[offset] & apolloENetCommandMask
|
||||
flags := packet[offset]
|
||||
channel := packet[offset+1]
|
||||
sequence := binary.BigEndian.Uint16(packet[offset+2 : offset+4])
|
||||
consumed, err := p.handleCommand(command, flags, channel, sequence, sentTime, packet[offset:])
|
||||
if err != nil || consumed < 4 || consumed > len(packet)-offset {
|
||||
return errApolloENet
|
||||
}
|
||||
offset += consumed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *apolloENetPeer) handleCommand(command, flags, channel uint8, sequence, sentTime uint16, data []byte) (int, error) {
|
||||
switch command {
|
||||
case 1:
|
||||
if len(data) < 8 {
|
||||
return 0, errApolloENet
|
||||
}
|
||||
return 8, p.acknowledge(channel, binary.BigEndian.Uint16(data[4:6]), binary.BigEndian.Uint16(data[6:8]))
|
||||
case apolloENetVerifyConnect:
|
||||
if len(data) < 44 {
|
||||
return 0, errApolloENet
|
||||
}
|
||||
return 44, p.verifyConnect(sequence, sentTime, data[:44])
|
||||
case apolloENetDisconnect:
|
||||
if len(data) < 8 {
|
||||
return 0, errApolloENet
|
||||
}
|
||||
if flags&apolloENetAcknowledged != 0 {
|
||||
if err := p.sendAcknowledge(channel, sequence, sentTime); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return 8, ErrProviderDisconnected
|
||||
case apolloENetPing:
|
||||
if flags&apolloENetAcknowledged != 0 {
|
||||
if err := p.sendAcknowledge(channel, sequence, sentTime); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return 4, nil
|
||||
case apolloENetSendReliable:
|
||||
if len(data) < 6 {
|
||||
return 0, errApolloENet
|
||||
}
|
||||
length := int(binary.BigEndian.Uint16(data[4:6]))
|
||||
if length > apolloENetMaximumPayload || len(data) < 6+length {
|
||||
return 0, errApolloENet
|
||||
}
|
||||
if flags&apolloENetAcknowledged == 0 || channel >= apolloENetChannels {
|
||||
return 0, errApolloENet
|
||||
}
|
||||
if err := p.sendAcknowledge(channel, sequence, sentTime); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
deliver, err := p.acceptReliable(channel, sequence, data[6:6+length])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, message := range deliver {
|
||||
p.deliver(channel, true, message)
|
||||
}
|
||||
return 6 + length, nil
|
||||
case apolloENetSendUnsequenced:
|
||||
if len(data) < 8 {
|
||||
return 0, errApolloENet
|
||||
}
|
||||
length := int(binary.BigEndian.Uint16(data[6:8]))
|
||||
if flags&apolloENetUnsequenced == 0 || channel >= apolloENetChannels || length > apolloENetMaximumPayload || len(data) < 8+length {
|
||||
return 0, errApolloENet
|
||||
}
|
||||
p.deliver(channel, false, data[8:8+length])
|
||||
return 8 + length, nil
|
||||
case apolloENetBandwidthLimit:
|
||||
if len(data) < 12 {
|
||||
return 0, errApolloENet
|
||||
}
|
||||
return 12, nil
|
||||
case apolloENetThrottleConfig:
|
||||
if len(data) < 16 {
|
||||
return 0, errApolloENet
|
||||
}
|
||||
return 16, nil
|
||||
case apolloENetConnect, 7, 8, 12:
|
||||
return 0, errApolloENet
|
||||
default:
|
||||
return 0, errApolloENet
|
||||
}
|
||||
}
|
||||
|
||||
func (p *apolloENetPeer) verifyConnect(sequence, sentTime uint16, data []byte) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.state != apolloENetConnecting || binary.BigEndian.Uint32(data[40:44]) != p.connectID || binary.BigEndian.Uint32(data[16:20]) != apolloENetChannels {
|
||||
return errApolloENet
|
||||
}
|
||||
p.peerID = binary.BigEndian.Uint16(data[4:6])
|
||||
p.inboundSession = data[6]
|
||||
p.outboundSession = data[7]
|
||||
p.state = apolloENetConnected
|
||||
delete(p.pending, apolloENetPendingKey{channel: 0xff, sequence: 1})
|
||||
return p.sendAcknowledgeLocked(0xff, sequence, sentTime)
|
||||
}
|
||||
|
||||
func (p *apolloENetPeer) acknowledge(channel uint8, sequence, sentTime uint16) error {
|
||||
p.mu.Lock()
|
||||
if p.state == apolloENetDisconnecting && channel == 0xff && sequence == p.disconnectSeq && p.disconnectAck != nil {
|
||||
close(p.disconnectAck)
|
||||
p.disconnectAck = nil
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
key := apolloENetPendingKey{channel: channel, sequence: sequence}
|
||||
pending, ok := p.pending[key]
|
||||
if !ok {
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
delete(p.pending, key)
|
||||
measured := p.now().Sub(pending.sentTime)
|
||||
if measured < 0 || measured > 30*time.Second {
|
||||
p.mu.Unlock()
|
||||
return errApolloENet
|
||||
}
|
||||
delta := durationAbs(p.rtt - measured)
|
||||
p.variance += (delta - p.variance) / 4
|
||||
p.rtt += (measured - p.rtt) / 8
|
||||
p.mu.Unlock()
|
||||
_ = sentTime
|
||||
return nil
|
||||
}
|
||||
|
||||
func durationAbs(value time.Duration) time.Duration {
|
||||
if value < 0 {
|
||||
return -value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (p *apolloENetPeer) acceptReliable(channel uint8, sequence uint16, payload []byte) ([][]byte, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
state := &p.channels[channel]
|
||||
if !state.hasIncoming {
|
||||
state.hasIncoming = true
|
||||
state.lastIncoming = sequence
|
||||
return [][]byte{append([]byte(nil), payload...)}, nil
|
||||
}
|
||||
if sequence == state.lastIncoming+1 {
|
||||
state.lastIncoming = sequence
|
||||
deliver := [][]byte{append([]byte(nil), payload...)}
|
||||
for {
|
||||
next := state.lastIncoming + 1
|
||||
queued, ok := state.incoming[next]
|
||||
if !ok {
|
||||
return deliver, nil
|
||||
}
|
||||
delete(state.incoming, next)
|
||||
state.lastIncoming = next
|
||||
deliver = append(deliver, queued)
|
||||
}
|
||||
}
|
||||
if apolloENetSequenceGreater(sequence, state.lastIncoming) {
|
||||
if uint16(sequence-state.lastIncoming) > 1024 || len(state.incoming) >= apolloENetMaximumReorder {
|
||||
return nil, errApolloENet
|
||||
}
|
||||
if state.incoming == nil {
|
||||
state.incoming = make(map[uint16][]byte)
|
||||
}
|
||||
if _, duplicate := state.incoming[sequence]; !duplicate {
|
||||
state.incoming[sequence] = append([]byte(nil), payload...)
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func apolloENetSequenceGreater(first, second uint16) bool {
|
||||
return (first > second && first-second <= 32768) || (first < second && second-first > 32768)
|
||||
}
|
||||
|
||||
func (p *apolloENetPeer) deliver(channel uint8, reliable bool, payload []byte) {
|
||||
p.mu.Lock()
|
||||
callback := p.onPayload
|
||||
p.mu.Unlock()
|
||||
if callback != nil {
|
||||
callback(channel, reliable, append([]byte(nil), payload...))
|
||||
}
|
||||
}
|
||||
|
||||
func (p *apolloENetPeer) SendReliable(channel uint8, payload []byte) error {
|
||||
if channel >= apolloENetChannels || len(payload) == 0 || len(payload) > apolloENetMaximumPayload {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.state != apolloENetConnected || len(p.pending) >= apolloENetMaximumPending {
|
||||
return ErrProviderDisconnected
|
||||
}
|
||||
state := &p.channels[channel]
|
||||
state.nextOutgoing++
|
||||
if state.nextOutgoing == 0 {
|
||||
state.nextOutgoing++
|
||||
}
|
||||
now := p.now()
|
||||
packet := make([]byte, 10+len(payload))
|
||||
apolloENetHeader(packet, p.peerID, p.outboundSession, now)
|
||||
packet[4] = apolloENetSendReliable | apolloENetAcknowledged
|
||||
packet[5] = channel
|
||||
binary.BigEndian.PutUint16(packet[6:8], state.nextOutgoing)
|
||||
binary.BigEndian.PutUint16(packet[8:10], uint16(len(payload)))
|
||||
copy(packet[10:], payload)
|
||||
key := apolloENetPendingKey{channel: channel, sequence: state.nextOutgoing}
|
||||
p.pending[key] = &apolloENetPending{packet: append([]byte(nil), packet...), firstSent: now, sentTime: now, timeout: apolloENetRetransmitTimeout(p.rtt, p.variance, 1), attempts: 1}
|
||||
if err := p.writeLocked(packet); err != nil {
|
||||
delete(p.pending, key)
|
||||
return err
|
||||
}
|
||||
p.reliableSent++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *apolloENetPeer) SendUnsequenced(channel uint8, payload []byte) error {
|
||||
if channel >= apolloENetChannels || len(payload) == 0 || len(payload) > apolloENetMaximumPayload {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.state != apolloENetConnected {
|
||||
return ErrProviderDisconnected
|
||||
}
|
||||
p.unsequenced++
|
||||
packet := make([]byte, 12+len(payload))
|
||||
apolloENetHeader(packet, p.peerID, p.outboundSession, p.now())
|
||||
packet[4] = apolloENetSendUnsequenced | apolloENetUnsequenced
|
||||
packet[5] = channel
|
||||
binary.BigEndian.PutUint16(packet[6:8], 0)
|
||||
binary.BigEndian.PutUint16(packet[8:10], p.unsequenced)
|
||||
binary.BigEndian.PutUint16(packet[10:12], uint16(len(payload)))
|
||||
copy(packet[12:], payload)
|
||||
return p.writeLocked(packet)
|
||||
}
|
||||
|
||||
func (p *apolloENetPeer) sendAcknowledge(channel uint8, sequence, sentTime uint16) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.sendAcknowledgeLocked(channel, sequence, sentTime)
|
||||
}
|
||||
|
||||
func (p *apolloENetPeer) sendAcknowledgeLocked(channel uint8, sequence, sentTime uint16) error {
|
||||
if p.state == apolloENetClosed {
|
||||
return ErrProviderDisconnected
|
||||
}
|
||||
packet := make([]byte, 12)
|
||||
peerID := p.peerID
|
||||
session := p.outboundSession
|
||||
if p.state == apolloENetConnecting {
|
||||
peerID, session = apolloENetPeerIDMask, 0
|
||||
}
|
||||
apolloENetHeader(packet, peerID, session, p.now())
|
||||
packet[4] = 1
|
||||
packet[5] = channel
|
||||
binary.BigEndian.PutUint16(packet[6:8], 0)
|
||||
binary.BigEndian.PutUint16(packet[8:10], sequence)
|
||||
binary.BigEndian.PutUint16(packet[10:12], sentTime)
|
||||
return p.writeLocked(packet)
|
||||
}
|
||||
|
||||
func (p *apolloENetPeer) maintain() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.state != apolloENetConnected && p.state != apolloENetDisconnecting {
|
||||
return nil
|
||||
}
|
||||
now := p.now()
|
||||
if !p.lastReceive.IsZero() && now.Sub(p.lastReceive) > apolloENetPeerTimeout {
|
||||
return ErrProviderTimeout
|
||||
}
|
||||
for key, pending := range p.pending {
|
||||
if now.Sub(pending.sentTime) < pending.timeout {
|
||||
continue
|
||||
}
|
||||
if now.Sub(pending.firstSent) >= apolloENetTimeoutMaximum || (apolloENetExceededTimeoutLimit(pending.attempts) && now.Sub(pending.firstSent) >= apolloENetTimeoutMinimum) {
|
||||
return ErrProviderTimeout
|
||||
}
|
||||
pending.timeout = apolloENetRetransmitTimeout(p.rtt, p.variance, pending.attempts)
|
||||
pending.attempts++
|
||||
p.retransmits++
|
||||
pending.sentTime = now
|
||||
binary.BigEndian.PutUint16(pending.packet[2:4], uint16(now.UnixMilli()))
|
||||
if err := p.writeLocked(pending.packet); err != nil {
|
||||
return err
|
||||
}
|
||||
p.pending[key] = pending
|
||||
}
|
||||
if now.Sub(p.lastPing) >= 500*time.Millisecond {
|
||||
p.lastPing = now
|
||||
return p.sendPingLocked()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func apolloENetRetransmitTimeout(rtt, variance time.Duration, attempts uint8) time.Duration {
|
||||
if rtt < time.Millisecond {
|
||||
rtt = time.Millisecond
|
||||
}
|
||||
if variance < time.Millisecond {
|
||||
variance = time.Millisecond
|
||||
}
|
||||
base := rtt + minDuration(rtt, 4*variance)
|
||||
if base > apolloENetTimeoutMaximum/5 {
|
||||
base = apolloENetTimeoutMaximum / 5
|
||||
}
|
||||
if attempts == 0 {
|
||||
attempts = 1
|
||||
}
|
||||
if attempts > apolloENetTimeoutLimit {
|
||||
attempts = apolloENetTimeoutLimit
|
||||
}
|
||||
return base * time.Duration(attempts)
|
||||
}
|
||||
|
||||
func apolloENetExceededTimeoutLimit(attempts uint8) bool {
|
||||
return attempts >= 6 // 1 << (attempts - 1) reaches the fork's limit of 32.
|
||||
}
|
||||
|
||||
func minDuration(first, second time.Duration) time.Duration {
|
||||
if first < second {
|
||||
return first
|
||||
}
|
||||
return second
|
||||
}
|
||||
|
||||
func (p *apolloENetPeer) sendPingLocked() error {
|
||||
if len(p.pending) >= apolloENetMaximumPending {
|
||||
return ErrProviderTimeout
|
||||
}
|
||||
state := &p.channels[0]
|
||||
state.nextOutgoing++
|
||||
if state.nextOutgoing == 0 {
|
||||
state.nextOutgoing++
|
||||
}
|
||||
now := p.now()
|
||||
packet := make([]byte, 8)
|
||||
apolloENetHeader(packet, p.peerID, p.outboundSession, now)
|
||||
packet[4] = apolloENetPing | apolloENetAcknowledged
|
||||
packet[5] = 0
|
||||
binary.BigEndian.PutUint16(packet[6:8], state.nextOutgoing)
|
||||
p.pending[apolloENetPendingKey{channel: 0, sequence: state.nextOutgoing}] = &apolloENetPending{packet: append([]byte(nil), packet...), firstSent: now, sentTime: now, timeout: apolloENetRetransmitTimeout(p.rtt, p.variance, 1), attempts: 1}
|
||||
if err := p.writeLocked(packet); err != nil {
|
||||
delete(p.pending, apolloENetPendingKey{channel: 0, sequence: state.nextOutgoing})
|
||||
return err
|
||||
}
|
||||
p.reliableSent++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *apolloENetPeer) telemetry() ProviderTelemetry {
|
||||
if p == nil {
|
||||
return ProviderTelemetry{}
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return ProviderTelemetry{
|
||||
ControlRTT: p.rtt, ControlJitter: p.variance, ReliableSent: p.reliableSent,
|
||||
ReliableRetransmits: p.retransmits, PendingReliable: uint64(len(p.pending)),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *apolloENetPeer) writeLocked(packet []byte) error {
|
||||
if len(packet) < 4 || len(packet) > apolloENetMaximumPacket {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
count, err := p.conn.Write(packet)
|
||||
if err != nil || count != len(packet) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ErrProviderDisconnected
|
||||
}
|
||||
p.lastSend = p.now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *apolloENetPeer) Disconnect(ctx context.Context) error {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.mu.Lock()
|
||||
if p.state == apolloENetClosed {
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
p.state = apolloENetDisconnecting
|
||||
ack := make(chan struct{})
|
||||
p.disconnectAck, p.disconnectSeq = ack, 1
|
||||
packet := make([]byte, 12)
|
||||
apolloENetHeader(packet, p.peerID, p.outboundSession, p.now())
|
||||
packet[4] = apolloENetDisconnect | apolloENetAcknowledged
|
||||
packet[5] = 0xff
|
||||
binary.BigEndian.PutUint16(packet[6:8], 1)
|
||||
if err := p.writeLocked(packet); err != nil {
|
||||
p.mu.Unlock()
|
||||
p.close(err)
|
||||
return err
|
||||
}
|
||||
p.mu.Unlock()
|
||||
deadline := time.NewTimer(2 * time.Second)
|
||||
defer deadline.Stop()
|
||||
select {
|
||||
case <-ack:
|
||||
p.close(nil)
|
||||
return nil
|
||||
case <-p.done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
p.close(ctx.Err())
|
||||
return ctx.Err()
|
||||
case <-deadline.C:
|
||||
p.close(ErrProviderTimeout)
|
||||
return ErrProviderTimeout
|
||||
}
|
||||
}
|
||||
|
||||
func (p *apolloENetPeer) close(err error) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.closeOnce.Do(func() {
|
||||
p.mu.Lock()
|
||||
p.state = apolloENetClosed
|
||||
callback := p.onDisconnect
|
||||
p.mu.Unlock()
|
||||
close(p.done)
|
||||
_ = p.conn.Close()
|
||||
if callback != nil && err != nil {
|
||||
callback(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestApolloENetConnectWireVectorAndRTO(t *testing.T) {
|
||||
now := time.UnixMilli(0x1234)
|
||||
packet := apolloENetConnectPacket(now, 0x01020304, 0x12345678)
|
||||
expected, err := hex.DecodeString("8fff123482ff00010000ffff00000578000080000000003000000000000000000000138800000002000000020102030412345678")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(packet) != string(expected) {
|
||||
t.Fatalf("CONNECT wire bytes = %x, want %x", packet, expected)
|
||||
}
|
||||
if got := apolloENetRetransmitTimeout(100*time.Millisecond, 30*time.Millisecond, 1); got != 200*time.Millisecond {
|
||||
t.Fatalf("initial RTO = %s", got)
|
||||
}
|
||||
if got := apolloENetRetransmitTimeout(100*time.Millisecond, 30*time.Millisecond, 6); got != 1200*time.Millisecond {
|
||||
t.Fatalf("bounded retry RTO = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApolloENetConnectVerifies48ChannelsAndFlushesAck(t *testing.T) {
|
||||
server, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer server.Close()
|
||||
client, err := net.DialUDP("udp", nil, server.LocalAddr().(*net.UDPAddr))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
peer, err := newApolloENetPeer(client, time.Now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer peer.close(nil)
|
||||
received := make(chan string, 3)
|
||||
peer.onPayload = func(_ uint8, _ bool, payload []byte) { received <- string(payload) }
|
||||
serverDone := make(chan error, 1)
|
||||
go func() {
|
||||
buffer := make([]byte, apolloENetMaximumPacket)
|
||||
count, remote, readErr := server.ReadFromUDP(buffer)
|
||||
if readErr != nil {
|
||||
serverDone <- readErr
|
||||
return
|
||||
}
|
||||
packet := buffer[:count]
|
||||
if len(packet) != 52 || packet[4] != apolloENetConnect|apolloENetAcknowledged || packet[5] != 0xff || binary.BigEndian.Uint32(packet[20:24]) != apolloENetChannels || binary.BigEndian.Uint32(packet[48:52]) != 0x12345678 {
|
||||
serverDone <- ErrProviderMalformed
|
||||
return
|
||||
}
|
||||
verify := make([]byte, 48)
|
||||
apolloENetHeader(verify, 0, 0, time.Now())
|
||||
verify[4] = apolloENetVerifyConnect | apolloENetAcknowledged
|
||||
verify[5] = 0xff
|
||||
binary.BigEndian.PutUint16(verify[6:8], 1)
|
||||
binary.BigEndian.PutUint16(verify[8:10], 7)
|
||||
verify[10], verify[11] = 2, 3
|
||||
binary.BigEndian.PutUint32(verify[12:16], 1400)
|
||||
binary.BigEndian.PutUint32(verify[16:20], 32768)
|
||||
binary.BigEndian.PutUint32(verify[20:24], apolloENetChannels)
|
||||
binary.BigEndian.PutUint32(verify[44:48], binary.BigEndian.Uint32(packet[44:48]))
|
||||
if _, writeErr := server.WriteToUDP(verify, remote); writeErr != nil {
|
||||
serverDone <- writeErr
|
||||
return
|
||||
}
|
||||
count, _, readErr = server.ReadFromUDP(buffer)
|
||||
if readErr != nil {
|
||||
serverDone <- readErr
|
||||
return
|
||||
}
|
||||
ack := buffer[:count]
|
||||
if len(ack) != 12 || ack[4]&apolloENetCommandMask != 1 || binary.BigEndian.Uint16(ack[8:10]) != 1 {
|
||||
serverDone <- ErrProviderMalformed
|
||||
return
|
||||
}
|
||||
serverDone <- nil
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := peer.Connect(ctx, 0x12345678); err != nil {
|
||||
t.Fatalf("Connect() error = %v", err)
|
||||
}
|
||||
for _, command := range []struct {
|
||||
sequence uint16
|
||||
payload string
|
||||
}{{1, "A"}, {3, "C"}, {2, "B"}} {
|
||||
if err := peer.handleDatagram(sourceShapedENetReliablePacket(peer.peerID, peer.inboundSession, command.sequence, []byte(command.payload))); err != nil {
|
||||
t.Fatalf("handleDatagram() error = %v", err)
|
||||
}
|
||||
}
|
||||
if err := <-serverDone; err != nil {
|
||||
t.Fatalf("ENet server error = %v", err)
|
||||
}
|
||||
ordered := ""
|
||||
for range 3 {
|
||||
select {
|
||||
case payload := <-received:
|
||||
ordered += payload
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("reliable payload order = %q, want ABC", ordered)
|
||||
}
|
||||
}
|
||||
if ordered != "ABC" {
|
||||
t.Fatalf("reliable payload order = %q, want ABC", ordered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApolloENetFakeClockRetransmitsLostReliablePacketAtForkRTO(t *testing.T) {
|
||||
server, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer server.Close()
|
||||
client, err := net.DialUDP("udp", nil, server.LocalAddr().(*net.UDPAddr))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Unix(1, 0)
|
||||
peer, err := newApolloENetPeer(client, func() time.Time { return now })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer peer.close(nil)
|
||||
peer.state, peer.peerID, peer.outboundSession, peer.lastPing = apolloENetConnected, 1, 2, now
|
||||
peer.rtt, peer.variance = 100*time.Millisecond, 30*time.Millisecond
|
||||
if err := peer.SendReliable(apolloChannelKeyboard, []byte{0x01}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertApolloENetReliablePacket(t, server, 1)
|
||||
|
||||
now = now.Add(199 * time.Millisecond)
|
||||
if err := peer.maintain(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertApolloENetPending(t, peer, 1, 200*time.Millisecond)
|
||||
now = now.Add(time.Millisecond)
|
||||
if err := peer.maintain(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertApolloENetReliablePacket(t, server, 1)
|
||||
assertApolloENetPending(t, peer, 2, 200*time.Millisecond)
|
||||
|
||||
now = now.Add(199 * time.Millisecond)
|
||||
if err := peer.maintain(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertApolloENetPending(t, peer, 2, 200*time.Millisecond)
|
||||
now = now.Add(time.Millisecond)
|
||||
if err := peer.maintain(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertApolloENetReliablePacket(t, server, 1)
|
||||
assertApolloENetPending(t, peer, 3, 400*time.Millisecond)
|
||||
telemetry := peer.telemetry()
|
||||
if telemetry.ControlRTT != 100*time.Millisecond || telemetry.ControlJitter != 30*time.Millisecond || telemetry.ReliableSent != 1 || telemetry.ReliableRetransmits != 2 || telemetry.PendingReliable != 1 {
|
||||
t.Fatalf("ENet telemetry = %#v", telemetry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApolloENetFakeClockKeepsSessionAlivePastElevenVirtualSeconds(t *testing.T) {
|
||||
server, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer server.Close()
|
||||
client, err := net.DialUDP("udp", nil, server.LocalAddr().(*net.UDPAddr))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Unix(1, 0)
|
||||
peer, err := newApolloENetPeer(client, func() time.Time { return now })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer peer.close(nil)
|
||||
peer.state, peer.peerID, peer.inboundSession, peer.outboundSession, peer.lastPing, peer.lastReceive = apolloENetConnected, 1, 1, 2, now, now
|
||||
for virtual := 500 * time.Millisecond; virtual <= 11*time.Second; virtual += 500 * time.Millisecond {
|
||||
now = time.Unix(1, 0).Add(virtual)
|
||||
if err := peer.maintain(); err != nil {
|
||||
t.Fatalf("maintain() at virtual %s = %v", virtual, err)
|
||||
}
|
||||
assertApolloENetPingPacket(t, server)
|
||||
peer.mu.Lock()
|
||||
sequence := peer.channels[0].nextOutgoing
|
||||
peer.mu.Unlock()
|
||||
if err := peer.handleDatagram(sourceShapedENetAcknowledgePacket(peer.peerID, peer.inboundSession, 0, sequence)); err != nil {
|
||||
t.Fatalf("handleDatagram() ACK at virtual %s = %v", virtual, err)
|
||||
}
|
||||
}
|
||||
if peer.state != apolloENetConnected || len(peer.pending) != 0 {
|
||||
t.Fatalf("virtual ENet session state = %v pending=%d", peer.state, len(peer.pending))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApolloENetDisconnectCompletesOnProviderAcknowledgement(t *testing.T) {
|
||||
server, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer server.Close()
|
||||
client, err := net.DialUDP("udp", nil, server.LocalAddr().(*net.UDPAddr))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
peer, err := newApolloENetPeer(client, time.Now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer peer.close(nil)
|
||||
peer.state, peer.peerID, peer.inboundSession, peer.outboundSession = apolloENetConnected, 1, 1, 2
|
||||
go peer.run()
|
||||
serverDone := make(chan error, 1)
|
||||
go func() {
|
||||
buffer := make([]byte, apolloENetMaximumPacket)
|
||||
count, remote, readErr := server.ReadFromUDP(buffer)
|
||||
if readErr != nil {
|
||||
serverDone <- readErr
|
||||
return
|
||||
}
|
||||
packet := buffer[:count]
|
||||
if count != 12 || packet[4]&apolloENetCommandMask != apolloENetDisconnect || packet[5] != 0xff {
|
||||
serverDone <- ErrProviderMalformed
|
||||
return
|
||||
}
|
||||
_, writeErr := server.WriteToUDP(sourceShapedENetAcknowledgePacket(1, 1, 0xff, binary.BigEndian.Uint16(packet[6:8])), remote)
|
||||
serverDone <- writeErr
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := peer.Disconnect(ctx); err != nil {
|
||||
t.Fatalf("Disconnect() = %v after provider ACK", err)
|
||||
}
|
||||
if err := <-serverDone; err != nil {
|
||||
t.Fatalf("provider ACK = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertApolloENetReliablePacket(t *testing.T, server *net.UDPConn, sequence uint16) {
|
||||
t.Helper()
|
||||
buffer := make([]byte, apolloENetMaximumPacket)
|
||||
if err := server.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count, _, err := server.ReadFromUDP(buffer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 11 || buffer[4]&apolloENetCommandMask != apolloENetSendReliable || binary.BigEndian.Uint16(buffer[6:8]) != sequence {
|
||||
t.Fatalf("retransmitted packet = %x", buffer[:count])
|
||||
}
|
||||
}
|
||||
|
||||
func assertApolloENetPingPacket(t *testing.T, server *net.UDPConn) {
|
||||
t.Helper()
|
||||
buffer := make([]byte, apolloENetMaximumPacket)
|
||||
if err := server.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count, _, err := server.ReadFromUDP(buffer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 8 || buffer[4]&apolloENetCommandMask != apolloENetPing || buffer[4]&apolloENetAcknowledged == 0 {
|
||||
t.Fatalf("ping packet = %x", buffer[:count])
|
||||
}
|
||||
}
|
||||
|
||||
func assertApolloENetPending(t *testing.T, peer *apolloENetPeer, attempts uint8, timeout time.Duration) {
|
||||
t.Helper()
|
||||
pending := peer.pending[apolloENetPendingKey{channel: apolloChannelKeyboard, sequence: 1}]
|
||||
if pending == nil || pending.attempts != attempts || pending.timeout != timeout {
|
||||
t.Fatalf("pending reliable state = %#v, want attempts=%d timeout=%s", pending, attempts, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func sourceShapedENetReliablePacket(peerID uint16, session uint8, sequence uint16, payload []byte) []byte {
|
||||
return sourceShapedENetReliablePacketOn(peerID, session, apolloChannelKeyboard, sequence, payload)
|
||||
}
|
||||
|
||||
func sourceShapedENetReliablePacketOn(peerID uint16, session, channel uint8, sequence uint16, payload []byte) []byte {
|
||||
packet := make([]byte, 10+len(payload))
|
||||
binary.BigEndian.PutUint16(packet[:2], peerID|(uint16(session&3)<<apolloENetSessionShift)|apolloENetSentTimeFlag)
|
||||
binary.BigEndian.PutUint16(packet[2:4], 0x1234)
|
||||
packet[4] = apolloENetSendReliable | apolloENetAcknowledged
|
||||
packet[5] = channel
|
||||
binary.BigEndian.PutUint16(packet[6:8], sequence)
|
||||
binary.BigEndian.PutUint16(packet[8:10], uint16(len(payload)))
|
||||
copy(packet[10:], payload)
|
||||
return packet
|
||||
}
|
||||
|
||||
func sourceShapedENetAcknowledgePacket(peerID uint16, session, channel uint8, sequence uint16) []byte {
|
||||
packet := make([]byte, 12)
|
||||
binary.BigEndian.PutUint16(packet[:2], peerID|(uint16(session&3)<<apolloENetSessionShift)|apolloENetSentTimeFlag)
|
||||
packet[4] = 1
|
||||
packet[5] = channel
|
||||
binary.BigEndian.PutUint16(packet[8:10], sequence)
|
||||
return packet
|
||||
}
|
||||
|
||||
func TestApolloControlWireVectorAndTagFailure(t *testing.T) {
|
||||
codec, err := newApolloControlCodec([]byte("0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packet, err := codec.SealClient(apolloControlTypePing, []byte{4, 0, 0, 0, 0, 0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const expected = "01001e0000000000c688b2e3cb8a5869293f01293a19486c692d0799eb8e220a74b4"
|
||||
if string(packet) != string(mustDecodeHex(t, expected)) {
|
||||
t.Fatalf("control wire bytes = %x", packet)
|
||||
}
|
||||
tampered := append([]byte(nil), packet...)
|
||||
tampered[len(tampered)-1] ^= 1
|
||||
if _, err := codec.OpenHost(tampered); err == nil {
|
||||
t.Fatal("OpenHost() accepted a tag failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApolloKeyboardInputWireVector(t *testing.T) {
|
||||
packet, err := encodeApolloInputEvent(InputEvent{Device: "keyboard", Code: 30, Pressed: true, Payload: []byte{2}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const expected = "0000000a03000000001e00020000"
|
||||
if string(packet.payload) != string(mustDecodeHex(t, expected)) {
|
||||
t.Fatalf("keyboard packet = %x, want %s", packet.payload, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func mustDecodeHex(t *testing.T, value string) []byte {
|
||||
t.Helper()
|
||||
decoded, err := hex.DecodeString(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
apolloChannelGeneric = 0
|
||||
apolloChannelUrgent = 1
|
||||
apolloChannelKeyboard = 2
|
||||
apolloChannelMouse = 3
|
||||
apolloChannelUTF8 = 6
|
||||
apolloChannelGamepad = 16
|
||||
)
|
||||
|
||||
type apolloInputPacket struct {
|
||||
channel uint8
|
||||
payload []byte
|
||||
}
|
||||
|
||||
func encodeApolloInputEvent(event InputEvent) (apolloInputPacket, error) {
|
||||
switch event.Device {
|
||||
case "keyboard":
|
||||
if event.Code < 0 || event.Code > 0xffff || len(event.Payload) > 1 {
|
||||
return apolloInputPacket{}, ErrInputMalformed
|
||||
}
|
||||
packet := make([]byte, 14)
|
||||
binary.BigEndian.PutUint32(packet[:4], 10)
|
||||
if event.Pressed {
|
||||
binary.LittleEndian.PutUint32(packet[4:8], 3)
|
||||
} else {
|
||||
binary.LittleEndian.PutUint32(packet[4:8], 4)
|
||||
}
|
||||
binary.LittleEndian.PutUint16(packet[9:11], uint16(event.Code))
|
||||
if len(event.Payload) == 1 {
|
||||
packet[11] = event.Payload[0]
|
||||
}
|
||||
return apolloInputPacket{channel: apolloChannelKeyboard, payload: packet}, nil
|
||||
case "mouse-button":
|
||||
if event.Code < 1 || event.Code > 8 || len(event.Payload) != 0 {
|
||||
return apolloInputPacket{}, ErrInputMalformed
|
||||
}
|
||||
packet := make([]byte, 9)
|
||||
binary.BigEndian.PutUint32(packet[:4], 5)
|
||||
magic := uint32(8)
|
||||
if !event.Pressed {
|
||||
magic = 9
|
||||
}
|
||||
binary.LittleEndian.PutUint32(packet[4:8], magic)
|
||||
packet[8] = byte(event.Code)
|
||||
return apolloInputPacket{channel: apolloChannelMouse, payload: packet}, nil
|
||||
case "mouse-relative":
|
||||
if event.Pressed || len(event.Payload) != 4 {
|
||||
return apolloInputPacket{}, ErrInputMalformed
|
||||
}
|
||||
packet := make([]byte, 12)
|
||||
binary.BigEndian.PutUint32(packet[:4], 8)
|
||||
binary.LittleEndian.PutUint32(packet[4:8], 7)
|
||||
copy(packet[8:], event.Payload)
|
||||
return apolloInputPacket{channel: apolloChannelMouse, payload: packet}, nil
|
||||
case "utf8":
|
||||
if event.Pressed || len(event.Payload) == 0 || len(event.Payload) > utf8.UTFMax || !utf8.Valid(event.Payload) || utf8.RuneCount(event.Payload) != 1 {
|
||||
return apolloInputPacket{}, ErrInputMalformed
|
||||
}
|
||||
packet := make([]byte, 8+len(event.Payload))
|
||||
binary.BigEndian.PutUint32(packet[:4], uint32(4+len(event.Payload)))
|
||||
binary.LittleEndian.PutUint32(packet[4:8], 0x17)
|
||||
copy(packet[8:], event.Payload)
|
||||
return apolloInputPacket{channel: apolloChannelUTF8, payload: packet}, nil
|
||||
case "controller":
|
||||
if event.Code < 0 || event.Code > 15 || len(event.Payload) != 16 {
|
||||
return apolloInputPacket{}, ErrInputMalformed
|
||||
}
|
||||
packet := make([]byte, 34)
|
||||
binary.BigEndian.PutUint32(packet[:4], 30)
|
||||
binary.LittleEndian.PutUint32(packet[4:8], 0x0c)
|
||||
binary.LittleEndian.PutUint16(packet[8:10], 0x1a)
|
||||
binary.LittleEndian.PutUint16(packet[10:12], uint16(event.Code))
|
||||
copy(packet[12:14], event.Payload[:2])
|
||||
binary.LittleEndian.PutUint16(packet[14:16], 0x14)
|
||||
copy(packet[16:28], event.Payload[2:14])
|
||||
binary.LittleEndian.PutUint16(packet[28:30], 0x9c)
|
||||
copy(packet[30:32], event.Payload[14:16])
|
||||
binary.LittleEndian.PutUint16(packet[32:34], 0x55)
|
||||
return apolloInputPacket{channel: apolloChannelGamepad + uint8(event.Code), payload: packet}, nil
|
||||
default:
|
||||
return apolloInputPacket{}, ErrInputMalformed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type apolloLaunchResponse struct {
|
||||
StatusCode string `xml:"status_code,attr"`
|
||||
SessionURL string `xml:"sessionUrl0"`
|
||||
}
|
||||
|
||||
func apolloInventoryContains(data []byte, applicationID string) bool {
|
||||
if len(data) == 0 || len(data) > 64<<10 || applicationID == "" {
|
||||
return false
|
||||
}
|
||||
var document struct {
|
||||
Applications []struct {
|
||||
ID string `xml:"ID"`
|
||||
} `xml:"App"`
|
||||
}
|
||||
if xml.Unmarshal(data, &document) != nil {
|
||||
return false
|
||||
}
|
||||
for _, application := range document.Applications {
|
||||
if strings.TrimSpace(application.ID) == applicationID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseApolloLaunchResponse(data []byte) (apolloLaunchResponse, error) {
|
||||
if len(data) == 0 || len(data) > 64<<10 {
|
||||
return apolloLaunchResponse{}, ErrProviderMalformed
|
||||
}
|
||||
var response apolloLaunchResponse
|
||||
if xml.Unmarshal(data, &response) != nil || response.StatusCode != "200" || strings.TrimSpace(response.SessionURL) == "" {
|
||||
return apolloLaunchResponse{}, ErrProviderMalformed
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package gateway
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestApolloInventoryAndLaunchResponseRequireExactApplicationAndSuccess(t *testing.T) {
|
||||
inventory := []byte(`<root><App><ID>41</ID></App><App><ID>42</ID></App></root>`)
|
||||
if !apolloInventoryContains(inventory, "42") || apolloInventoryContains(inventory, "420") {
|
||||
t.Fatal("apolloInventoryContains() did not require an exact inventory ID")
|
||||
}
|
||||
if _, err := parseApolloLaunchResponse([]byte(`<root status_code="200"><sessionUrl0>rtspenc://apollo.test:47984</sessionUrl0></root>`)); err != nil {
|
||||
t.Fatalf("parseApolloLaunchResponse() error = %v", err)
|
||||
}
|
||||
if _, err := parseApolloLaunchResponse([]byte(`<root status_code="403"><sessionUrl0>rtspenc://apollo.test:47984</sessionUrl0></root>`)); err == nil {
|
||||
t.Fatal("parseApolloLaunchResponse() accepted a denied launch")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
)
|
||||
|
||||
const (
|
||||
// Apollo limits clear audio payloads to 1400 bytes and uses AES-CBC with
|
||||
// PKCS#7 padding. FEC adds its fixed 12-byte header outside that ciphertext.
|
||||
// This is the largest accepted UDP datagram, not an allocation hint.
|
||||
apolloMediaMaximumPacket = 12 + 12 + 1408
|
||||
apolloVideoHeaderSize = 32
|
||||
apolloRTPHeaderSize = 12
|
||||
apolloVideoNVHeaderSize = 16
|
||||
apolloVideoRawPacketSize = 1024 + 16
|
||||
)
|
||||
|
||||
var (
|
||||
errApolloMedia = errors.New("apollo media malformed")
|
||||
errApolloMediaParity = errors.New("apollo media parity packet")
|
||||
)
|
||||
|
||||
type apolloMediaCodec struct {
|
||||
block cipher.Block
|
||||
aead cipher.AEAD
|
||||
keyID uint32
|
||||
}
|
||||
|
||||
type apolloRTPPacket struct {
|
||||
extension bool
|
||||
payloadType byte
|
||||
sequence uint16
|
||||
payload []byte
|
||||
}
|
||||
|
||||
type apolloAudioShard struct {
|
||||
sequence uint16
|
||||
timestamp uint32
|
||||
ssrc uint32
|
||||
base uint16
|
||||
parityIndex uint8
|
||||
parity bool
|
||||
payload []byte
|
||||
}
|
||||
|
||||
func newApolloMediaCodec(key []byte, keyID uint32) (*apolloMediaCodec, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &apolloMediaCodec{block: block, aead: aead, keyID: keyID}, nil
|
||||
}
|
||||
|
||||
func apolloMediaPing(payload []byte, sequence uint32) []byte {
|
||||
if len(payload) != 16 {
|
||||
return nil
|
||||
}
|
||||
ping := make([]byte, 20)
|
||||
copy(ping, payload)
|
||||
binary.BigEndian.PutUint32(ping[16:], sequence)
|
||||
return ping
|
||||
}
|
||||
|
||||
func (c *apolloMediaCodec) OpenVideo(packet []byte) (apolloVideoShard, error) {
|
||||
if c == nil || c.aead == nil || len(packet) != apolloVideoHeaderSize+apolloVideoRawPacketSize {
|
||||
return apolloVideoShard{}, errApolloMedia
|
||||
}
|
||||
sealed := make([]byte, len(packet)-apolloVideoHeaderSize+apolloControlTagSize)
|
||||
copy(sealed, packet[apolloVideoHeaderSize:])
|
||||
copy(sealed[len(packet)-apolloVideoHeaderSize:], packet[16:apolloVideoHeaderSize])
|
||||
plaintext, err := c.aead.Open(nil, packet[:12], sealed, nil)
|
||||
if err != nil {
|
||||
return apolloVideoShard{}, errApolloMedia
|
||||
}
|
||||
rtp, err := parseApolloRTP(plaintext)
|
||||
if err != nil || !rtp.extension || rtp.payloadType != 0 || len(rtp.payload) != 1024 {
|
||||
return apolloVideoShard{}, errApolloMedia
|
||||
}
|
||||
nv := rtp.payload[:apolloVideoNVHeaderSize]
|
||||
flags := nv[8]
|
||||
fecInfo := binary.LittleEndian.Uint32(nv[12:16])
|
||||
dataPackets := int(fecInfo >> 22)
|
||||
fecIndex := int((fecInfo >> 12) & 0x03ff)
|
||||
fecPercent := int((fecInfo >> 4) & 0xff)
|
||||
if dataPackets < 1 || dataPackets > apolloVideoMaximumDataShards {
|
||||
return apolloVideoShard{}, errApolloMedia
|
||||
}
|
||||
parityPackets := (dataPackets*fecPercent + 99) / 100
|
||||
if dataPackets+parityPackets > 255 || fecIndex >= dataPackets+parityPackets {
|
||||
return apolloVideoShard{}, errApolloMedia
|
||||
}
|
||||
block := (nv[11] >> 4) & 0x03
|
||||
lastBlock := (nv[11] >> 6) & 0x03
|
||||
if block > lastBlock {
|
||||
return apolloVideoShard{}, errApolloMedia
|
||||
}
|
||||
return apolloVideoShard{
|
||||
frame: binary.LittleEndian.Uint32(nv[4:8]),
|
||||
block: block,
|
||||
lastBlock: lastBlock,
|
||||
dataPackets: dataPackets,
|
||||
parity: parityPackets,
|
||||
index: fecIndex,
|
||||
sequence: rtp.sequence,
|
||||
streamIndex: binary.LittleEndian.Uint32(nv[:4]) >> 8,
|
||||
flags: flags,
|
||||
payload: append([]byte(nil), rtp.payload[apolloVideoNVHeaderSize:]...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *apolloMediaCodec) OpenAudio(packet []byte) (apolloAudioShard, error) {
|
||||
if c == nil || c.block == nil || len(packet) <= apolloRTPHeaderSize || len(packet) > apolloMediaMaximumPacket {
|
||||
return apolloAudioShard{}, errApolloMedia
|
||||
}
|
||||
rtp, err := parseApolloRTP(packet)
|
||||
if err != nil || rtp.extension || len(rtp.payload) == 0 {
|
||||
return apolloAudioShard{}, errApolloMedia
|
||||
}
|
||||
if rtp.payloadType == 97 && len(rtp.payload)%aes.BlockSize == 0 {
|
||||
return apolloAudioShard{
|
||||
sequence: rtp.sequence, timestamp: binary.BigEndian.Uint32(packet[4:8]), ssrc: binary.BigEndian.Uint32(packet[8:12]),
|
||||
base: rtp.sequence &^ 3, payload: append([]byte(nil), rtp.payload...),
|
||||
}, nil
|
||||
}
|
||||
if rtp.payloadType != 127 || len(rtp.payload) <= 12 || len(rtp.payload)-12 > 1408 || rtp.payload[0] > 1 || rtp.payload[1] != 97 {
|
||||
return apolloAudioShard{}, errApolloMedia
|
||||
}
|
||||
base := binary.BigEndian.Uint16(rtp.payload[2:4])
|
||||
if base&3 != 0 || len(rtp.payload[12:])%aes.BlockSize != 0 {
|
||||
return apolloAudioShard{}, errApolloMedia
|
||||
}
|
||||
return apolloAudioShard{
|
||||
sequence: rtp.sequence, timestamp: binary.BigEndian.Uint32(rtp.payload[4:8]), ssrc: binary.BigEndian.Uint32(rtp.payload[8:12]),
|
||||
base: base, parityIndex: rtp.payload[0], parity: true, payload: append([]byte(nil), rtp.payload[12:]...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *apolloMediaCodec) openApolloAudioCipher(sequence uint16, payload []byte) ([]byte, error) {
|
||||
if c == nil || c.block == nil || len(payload) == 0 || len(payload) > 1408 || len(payload)%aes.BlockSize != 0 {
|
||||
return nil, errApolloMedia
|
||||
}
|
||||
plaintext := append([]byte(nil), payload...)
|
||||
iv := make([]byte, aes.BlockSize)
|
||||
binary.BigEndian.PutUint32(iv, c.keyID+uint32(sequence))
|
||||
cipher.NewCBCDecrypter(c.block, iv).CryptBlocks(plaintext, plaintext)
|
||||
padding := int(plaintext[len(plaintext)-1])
|
||||
if padding == 0 || padding > aes.BlockSize || padding > len(plaintext) {
|
||||
return nil, errApolloMedia
|
||||
}
|
||||
for _, value := range plaintext[len(plaintext)-padding:] {
|
||||
if int(value) != padding {
|
||||
return nil, errApolloMedia
|
||||
}
|
||||
}
|
||||
return append([]byte(nil), plaintext[:len(plaintext)-padding]...), nil
|
||||
}
|
||||
|
||||
func parseApolloRTP(packet []byte) (apolloRTPPacket, error) {
|
||||
if len(packet) < apolloRTPHeaderSize || packet[0]>>6 != 2 || packet[0]&0x2f != 0 {
|
||||
return apolloRTPPacket{}, errApolloMedia
|
||||
}
|
||||
offset := apolloRTPHeaderSize
|
||||
extension := packet[0]&0x10 != 0
|
||||
if extension {
|
||||
if len(packet) < offset+4 || binary.BigEndian.Uint16(packet[14:16]) != 0 {
|
||||
return apolloRTPPacket{}, errApolloMedia
|
||||
}
|
||||
offset += 4
|
||||
}
|
||||
if offset >= len(packet) {
|
||||
return apolloRTPPacket{}, errApolloMedia
|
||||
}
|
||||
return apolloRTPPacket{extension: extension, payloadType: packet[1] & 0x7f, sequence: binary.BigEndian.Uint16(packet[2:4]), payload: packet[offset:]}, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func FuzzApolloENetAndRTSPParsersStayBounded(f *testing.F) {
|
||||
server, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
|
||||
if err != nil {
|
||||
f.Fatal(err)
|
||||
}
|
||||
defer server.Close()
|
||||
client, err := net.DialUDP("udp", nil, server.LocalAddr().(*net.UDPAddr))
|
||||
if err != nil {
|
||||
f.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
f.Add([]byte{0x80, 0x01, 0, 0, apolloENetPing | apolloENetAcknowledged, 0, 0, 1})
|
||||
f.Add([]byte("RTSP/1.0 200 OK\r\nCSeq: 1\r\n\r\n"))
|
||||
f.Fuzz(func(t *testing.T, data []byte) {
|
||||
if len(data) > apolloENetMaximumPacket+1 {
|
||||
data = data[:apolloENetMaximumPacket+1]
|
||||
}
|
||||
peer := &apolloENetPeer{
|
||||
conn: client, now: time.Now, state: apolloENetConnected, peerID: 1, inboundSession: 0,
|
||||
pending: make(map[apolloENetPendingKey]*apolloENetPending), rtt: time.Millisecond, variance: time.Millisecond,
|
||||
}
|
||||
_ = peer.handleDatagram(data)
|
||||
_, _ = parseApolloRTSPMessage(data)
|
||||
_, _ = ParseRTSPResponse(data)
|
||||
_ = validateApolloDescribe(apolloRTSPMessage{headers: map[string]string{"content-type": "application/sdp"}, body: data})
|
||||
_, _ = parseApolloRTP(data)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
)
|
||||
|
||||
const (
|
||||
encryptedRTSPHeaderSize = 24
|
||||
encryptedRTSPMaxPayload = 64 << 10
|
||||
)
|
||||
|
||||
var errEncryptedRTSPFrame = errors.New("invalid encrypted RTSP frame")
|
||||
|
||||
// encryptedRTSPCodec keeps client and host nonce spaces disjoint. It accepts
|
||||
// only strictly increasing host sequence numbers, so a replay cannot be fed
|
||||
// into the RTSP parser after it has already authenticated once.
|
||||
type encryptedRTSPCodec struct {
|
||||
aead cipher.AEAD
|
||||
nextClient uint32
|
||||
lastClient uint32
|
||||
clientReceived bool
|
||||
lastHost uint32
|
||||
hostReceived bool
|
||||
}
|
||||
|
||||
func newEncryptedRTSPCodec(key []byte) (*encryptedRTSPCodec, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &encryptedRTSPCodec{aead: aead, nextClient: 1}, nil
|
||||
}
|
||||
|
||||
func (codec *encryptedRTSPCodec) SealClient(plaintext []byte) ([]byte, error) {
|
||||
if codec == nil || codec.aead == nil || len(plaintext) == 0 || len(plaintext) > encryptedRTSPMaxPayload || codec.nextClient == 0 {
|
||||
return nil, errEncryptedRTSPFrame
|
||||
}
|
||||
sequence := codec.nextClient
|
||||
codec.nextClient++
|
||||
nonce := encryptedRTSPNonce(sequence, 'C', 'R')
|
||||
sealed := codec.aead.Seal(nil, nonce[:], plaintext, nil)
|
||||
ciphertext, tag := sealed[:len(plaintext)], sealed[len(plaintext):]
|
||||
frame := make([]byte, encryptedRTSPHeaderSize+len(ciphertext))
|
||||
binary.BigEndian.PutUint32(frame[:4], uint32(len(ciphertext))|0x80000000)
|
||||
binary.BigEndian.PutUint32(frame[4:8], sequence)
|
||||
copy(frame[8:24], tag)
|
||||
copy(frame[24:], ciphertext)
|
||||
return frame, nil
|
||||
}
|
||||
|
||||
func (codec *encryptedRTSPCodec) OpenHost(frame []byte) ([]byte, error) {
|
||||
return codec.open(frame, 'H', 'R', &codec.lastHost, &codec.hostReceived)
|
||||
}
|
||||
|
||||
// OpenClient validates the client-originated direction. The native gateway
|
||||
// only sends this direction, but retaining the inverse lets a bounded fake
|
||||
// provider verify the negotiated session key and frame shape.
|
||||
func (codec *encryptedRTSPCodec) OpenClient(frame []byte) ([]byte, error) {
|
||||
return codec.open(frame, 'C', 'R', &codec.lastClient, &codec.clientReceived)
|
||||
}
|
||||
|
||||
func (codec *encryptedRTSPCodec) open(frame []byte, origin, protocol byte, last *uint32, received *bool) ([]byte, error) {
|
||||
if codec == nil || codec.aead == nil || len(frame) < encryptedRTSPHeaderSize {
|
||||
return nil, errEncryptedRTSPFrame
|
||||
}
|
||||
length := binary.BigEndian.Uint32(frame[:4])
|
||||
if length&0x80000000 == 0 || int(length&0x7fffffff) > encryptedRTSPMaxPayload || len(frame) != encryptedRTSPHeaderSize+int(length&0x7fffffff) {
|
||||
return nil, errEncryptedRTSPFrame
|
||||
}
|
||||
sequence := binary.BigEndian.Uint32(frame[4:8])
|
||||
if sequence == 0 || (*received && sequence <= *last) {
|
||||
return nil, errEncryptedRTSPFrame
|
||||
}
|
||||
nonce := encryptedRTSPNonce(sequence, origin, protocol)
|
||||
sealed := make([]byte, int(length&0x7fffffff)+codec.aead.Overhead())
|
||||
copy(sealed, frame[24:])
|
||||
copy(sealed[length&0x7fffffff:], frame[8:24])
|
||||
plaintext, err := codec.aead.Open(nil, nonce[:], sealed, nil)
|
||||
if err != nil {
|
||||
return nil, errEncryptedRTSPFrame
|
||||
}
|
||||
*last, *received = sequence, true
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
func encryptedRTSPNonce(sequence uint32, origin, protocol byte) [12]byte {
|
||||
var nonce [12]byte
|
||||
binary.BigEndian.PutUint32(nonce[:4], sequence)
|
||||
nonce[10], nonce[11] = origin, protocol
|
||||
return nonce
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
maxApolloRTSPHeaders = 16 << 10
|
||||
maxApolloRTSPBody = 48 << 10
|
||||
maxApolloRTSPPort = 65535
|
||||
apolloEncryptionAll = 0x07
|
||||
)
|
||||
|
||||
type apolloRTSPMessage struct {
|
||||
status int
|
||||
cseq uint32
|
||||
headers map[string]string
|
||||
body []byte
|
||||
raw []byte
|
||||
}
|
||||
|
||||
type apolloRTSPSetup struct {
|
||||
sessionID string
|
||||
audioPort int
|
||||
videoPort int
|
||||
controlPort int
|
||||
audioPing []byte
|
||||
videoPing []byte
|
||||
controlConnect uint32
|
||||
streamHost string
|
||||
streamPort int64
|
||||
providerWork protocol.ProviderSessionWork
|
||||
streamKey []byte
|
||||
streamKeyID uint32
|
||||
}
|
||||
|
||||
func (b *NativeApolloBackend) performRTSPHandshake(ctx context.Context, work protocol.ProviderSessionWork, key []byte, keyID uint32, streamURL *url.URL) (*apolloRTSPSetup, []byte, error) {
|
||||
if streamURL == nil || streamURL.Scheme != "rtspenc" || streamURL.Hostname() != work.StreamHost || streamURL.Port() != strconv.FormatInt(work.StreamPort, 10) || streamURL.User != nil || streamURL.RawQuery != "" || streamURL.Fragment != "" {
|
||||
return nil, nil, ErrProviderMalformed
|
||||
}
|
||||
codec, err := newEncryptedRTSPCodec(key)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
request := func(method, target, session string, headers []apolloRTSPHeader, body []byte, sequence uint32) (apolloRTSPMessage, error) {
|
||||
return b.encryptedRTSPRequest(ctx, work, codec, method, target, session, headers, body, sequence)
|
||||
}
|
||||
options, err := request("OPTIONS", streamURL.String(), "", nil, nil, 1)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
describe, err := request("DESCRIBE", streamURL.String(), "", []apolloRTSPHeader{{"Accept", "application/sdp"}, {"If-Modified-Since", "Thu, 01 Jan 1970 00:00:00 GMT"}}, nil, 2)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := validateApolloDescribe(describe); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
setupHeaders := []apolloRTSPHeader{{"Transport", "unicast;X-GS-ClientPort=50000-50001"}, {"If-Modified-Since", "Thu, 01 Jan 1970 00:00:00 GMT"}}
|
||||
audio, err := request("SETUP", "streamid=audio/0/0", "", setupHeaders, nil, 3)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sessionID, err := apolloRTSPSession(audio)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
audioPort, err := apolloRTSPServerPort(audio)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
audioPing, err := apolloRTSPPingPayload(audio)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
video, err := request("SETUP", "streamid=video/0/0", sessionID, setupHeaders, nil, 4)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := apolloRTSPMatchSession(video, sessionID); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
videoPort, err := apolloRTSPServerPort(video)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
videoPing, err := apolloRTSPPingPayload(video)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
control, err := request("SETUP", "streamid=control/13/0", sessionID, setupHeaders, nil, 5)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := apolloRTSPMatchSession(control, sessionID); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
controlPort, err := apolloRTSPServerPort(control)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
connectData, err := apolloRTSPConnectData(control)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
announceBody, err := apolloAnnounceProfile(work.StreamPolicy)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
announce, err := request("ANNOUNCE", "streamid=control/13/0", sessionID, []apolloRTSPHeader{{"Content-Type", "application/sdp"}}, announceBody, 6)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := apolloRTSPMatchSession(announce, sessionID); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
play, err := request("PLAY", "/", sessionID, nil, nil, 7)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := apolloRTSPMatchSession(play, sessionID); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
_ = options
|
||||
setup := &apolloRTSPSetup{
|
||||
sessionID: sessionID, audioPort: audioPort, videoPort: videoPort, controlPort: controlPort,
|
||||
audioPing: audioPing, videoPing: videoPing, controlConnect: connectData, streamHost: work.StreamHost,
|
||||
streamPort: work.StreamPort, providerWork: work, streamKey: append([]byte(nil), key...), streamKeyID: keyID,
|
||||
}
|
||||
return setup, append([]byte(nil), control.raw...), nil
|
||||
}
|
||||
|
||||
type apolloRTSPHeader struct{ key, value string }
|
||||
|
||||
func (b *NativeApolloBackend) encryptedRTSPRequest(ctx context.Context, work protocol.ProviderSessionWork, codec *encryptedRTSPCodec, method, target, session string, headers []apolloRTSPHeader, body []byte, sequence uint32) (apolloRTSPMessage, error) {
|
||||
if b == nil || b.Dialer == nil || codec == nil || sequence == 0 || len(body) > maxApolloRTSPBody || method == "" || target == "" {
|
||||
return apolloRTSPMessage{}, ErrProviderMalformed
|
||||
}
|
||||
conn, err := b.Dialer.DialContext(ctx, "tcp", net.JoinHostPort(work.StreamHost, strconv.FormatInt(work.StreamPort, 10)))
|
||||
if err != nil {
|
||||
return apolloRTSPMessage{}, err
|
||||
}
|
||||
defer conn.Close()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
|
||||
deadline = contextDeadline
|
||||
}
|
||||
if err := conn.SetDeadline(deadline); err != nil {
|
||||
return apolloRTSPMessage{}, err
|
||||
}
|
||||
plaintext, err := buildApolloRTSPRequest(method, target, session, headers, body, sequence)
|
||||
if err != nil {
|
||||
return apolloRTSPMessage{}, err
|
||||
}
|
||||
frame, err := codec.SealClient(plaintext)
|
||||
if err != nil {
|
||||
return apolloRTSPMessage{}, err
|
||||
}
|
||||
if _, err := conn.Write(frame); err != nil {
|
||||
return apolloRTSPMessage{}, err
|
||||
}
|
||||
response, err := readEncryptedRTSPMessage(conn, codec)
|
||||
if err != nil {
|
||||
return apolloRTSPMessage{}, err
|
||||
}
|
||||
if response.status != 200 || response.cseq != sequence {
|
||||
return apolloRTSPMessage{}, ErrProviderMalformed
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func buildApolloRTSPRequest(method, target, session string, headers []apolloRTSPHeader, body []byte, sequence uint32) ([]byte, error) {
|
||||
if method == "" || target == "" || strings.ContainsAny(method, "\r\n ") || strings.ContainsAny(target, "\r\n") || sequence == 0 {
|
||||
return nil, ErrProviderMalformed
|
||||
}
|
||||
var builder strings.Builder
|
||||
builder.Grow(256 + len(body))
|
||||
fmt.Fprintf(&builder, "%s %s RTSP/1.0\r\nCSeq: %d\r\n", method, target, sequence)
|
||||
if session != "" {
|
||||
if !validApolloRTSPToken(session) {
|
||||
return nil, ErrProviderMalformed
|
||||
}
|
||||
fmt.Fprintf(&builder, "Session: %s\r\n", session)
|
||||
}
|
||||
seen := map[string]struct{}{"cseq": {}, "session": {}}
|
||||
for _, header := range headers {
|
||||
key := strings.ToLower(header.key)
|
||||
if !validApolloRTSPToken(header.key) || header.value == "" || len(header.value) > 1024 || strings.ContainsAny(header.value, "\r\n") {
|
||||
return nil, ErrProviderMalformed
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
return nil, ErrProviderMalformed
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
fmt.Fprintf(&builder, "%s: %s\r\n", header.key, header.value)
|
||||
}
|
||||
if len(body) != 0 {
|
||||
if _, ok := seen["content-length"]; ok {
|
||||
return nil, ErrProviderMalformed
|
||||
}
|
||||
fmt.Fprintf(&builder, "Content-Length: %d\r\n", len(body))
|
||||
}
|
||||
builder.WriteString("\r\n")
|
||||
builder.Write(body)
|
||||
return []byte(builder.String()), nil
|
||||
}
|
||||
|
||||
func readEncryptedRTSPMessage(conn net.Conn, codec *encryptedRTSPCodec) (apolloRTSPMessage, error) {
|
||||
header := make([]byte, encryptedRTSPHeaderSize)
|
||||
if _, err := io.ReadFull(conn, header); err != nil {
|
||||
return apolloRTSPMessage{}, err
|
||||
}
|
||||
length := uint64(header[0]&0x7f)<<24 | uint64(header[1])<<16 | uint64(header[2])<<8 | uint64(header[3])
|
||||
if header[0]&0x80 == 0 || length == 0 || length > encryptedRTSPMaxPayload {
|
||||
return apolloRTSPMessage{}, ErrProviderMalformed
|
||||
}
|
||||
frame := make([]byte, encryptedRTSPHeaderSize+int(length))
|
||||
copy(frame, header)
|
||||
if _, err := io.ReadFull(conn, frame[encryptedRTSPHeaderSize:]); err != nil {
|
||||
return apolloRTSPMessage{}, err
|
||||
}
|
||||
plaintext, err := codec.OpenHost(frame)
|
||||
if err != nil {
|
||||
return apolloRTSPMessage{}, ErrProviderMalformed
|
||||
}
|
||||
return parseApolloRTSPMessage(plaintext)
|
||||
}
|
||||
|
||||
func parseApolloRTSPMessage(data []byte) (apolloRTSPMessage, error) {
|
||||
if len(data) == 0 || len(data) > encryptedRTSPMaxPayload {
|
||||
return apolloRTSPMessage{}, ErrProviderMalformed
|
||||
}
|
||||
headerEnd := strings.Index(string(data), "\r\n\r\n")
|
||||
if headerEnd < 0 || headerEnd+4 > maxApolloRTSPHeaders {
|
||||
return apolloRTSPMessage{}, ErrProviderMalformed
|
||||
}
|
||||
lines := strings.Split(string(data[:headerEnd]), "\r\n")
|
||||
if len(lines) < 1 {
|
||||
return apolloRTSPMessage{}, ErrProviderMalformed
|
||||
}
|
||||
parts := strings.SplitN(lines[0], " ", 3)
|
||||
if len(parts) != 3 || parts[0] != "RTSP/1.0" || len(parts[2]) == 0 || len(parts[2]) > 128 {
|
||||
return apolloRTSPMessage{}, ErrProviderMalformed
|
||||
}
|
||||
status, err := strconv.Atoi(parts[1])
|
||||
if err != nil || status < 100 || status > 599 {
|
||||
return apolloRTSPMessage{}, ErrProviderMalformed
|
||||
}
|
||||
message := apolloRTSPMessage{status: status, headers: make(map[string]string), raw: append([]byte(nil), data...)}
|
||||
for _, line := range lines[1:] {
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
value = strings.TrimSpace(value)
|
||||
if !ok || !validApolloRTSPToken(key) || value == "" || len(value) > 1024 {
|
||||
return apolloRTSPMessage{}, ErrProviderMalformed
|
||||
}
|
||||
if _, duplicate := message.headers[key]; duplicate {
|
||||
return apolloRTSPMessage{}, ErrProviderMalformed
|
||||
}
|
||||
message.headers[key] = value
|
||||
}
|
||||
cseq, ok := message.headers["cseq"]
|
||||
if !ok {
|
||||
return apolloRTSPMessage{}, ErrProviderMalformed
|
||||
}
|
||||
parsedCSeq, err := strconv.ParseUint(cseq, 10, 32)
|
||||
if err != nil || parsedCSeq == 0 {
|
||||
return apolloRTSPMessage{}, ErrProviderMalformed
|
||||
}
|
||||
message.cseq = uint32(parsedCSeq)
|
||||
message.body = append([]byte(nil), data[headerEnd+4:]...)
|
||||
if len(message.body) > maxApolloRTSPBody {
|
||||
return apolloRTSPMessage{}, ErrProviderMalformed
|
||||
}
|
||||
if length, hasLength := message.headers["content-length"]; hasLength {
|
||||
declared, err := strconv.ParseUint(length, 10, 16)
|
||||
if err != nil || int(declared) != len(message.body) {
|
||||
return apolloRTSPMessage{}, ErrProviderMalformed
|
||||
}
|
||||
} else if len(message.body) != 0 {
|
||||
return apolloRTSPMessage{}, ErrProviderMalformed
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func validateApolloDescribe(message apolloRTSPMessage) error {
|
||||
if message.headers["content-type"] != "application/sdp" || len(message.body) == 0 {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
body := string(message.body)
|
||||
lineEnding := "\n"
|
||||
if strings.Contains(body, "\r\n") {
|
||||
if strings.Contains(strings.ReplaceAll(body, "\r\n", ""), "\n") {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
lineEnding = "\r\n"
|
||||
}
|
||||
if !strings.HasSuffix(body, lineEnding) {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
attributes := map[string]string{}
|
||||
seen := map[string]struct{}{}
|
||||
stage := 0
|
||||
stereo := false
|
||||
for _, line := range strings.Split(strings.TrimSuffix(body, lineEnding), lineEnding) {
|
||||
if line == "" {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
if !strings.HasPrefix(line, "a=") {
|
||||
if line == "sprop-parameter-sets=AAAAAU" && stage >= 3 && stage <= 4 {
|
||||
stage = 5
|
||||
continue
|
||||
}
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
key, value, ok := strings.Cut(strings.TrimPrefix(line, "a="), ":")
|
||||
if !ok || key == "" || value == "" || len(key) > 128 || len(value) > 256 {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
if key == "fmtp" {
|
||||
if stage < 3 || !validApolloSurroundParameters(value) {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
if _, duplicate := seen["fmtp:"+value]; duplicate {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
seen["fmtp:"+value] = struct{}{}
|
||||
if value == "97 surround-params=21101" {
|
||||
stereo = true
|
||||
}
|
||||
stage = 6
|
||||
continue
|
||||
}
|
||||
if key == "rtpmap" && value == "98 AV1/90000" && stage >= 3 && stage <= 5 {
|
||||
if _, duplicate := seen[key]; duplicate {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
stage = 6
|
||||
continue
|
||||
}
|
||||
if key == "x-nv-video[0].refPicInvalidation" && value == "1" && stage == 3 {
|
||||
if _, duplicate := seen[key]; duplicate {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
stage = 4
|
||||
continue
|
||||
}
|
||||
if key != "x-ss-general.featureFlags" && key != "x-ss-general.encryptionSupported" && key != "x-ss-general.encryptionRequested" {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
if _, duplicate := attributes[key]; duplicate || (key == "x-ss-general.featureFlags" && stage != 0) || (key == "x-ss-general.encryptionSupported" && stage != 1) || (key == "x-ss-general.encryptionRequested" && stage != 2) {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
attributes[key] = value
|
||||
stage++
|
||||
}
|
||||
featureFlags, featureFlagsOK := attributes["x-ss-general.featureFlags"]
|
||||
supported, supportedOK := attributes["x-ss-general.encryptionSupported"]
|
||||
requested, requestedOK := attributes["x-ss-general.encryptionRequested"]
|
||||
if !featureFlagsOK || !supportedOK || !requestedOK || !stereo {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
if _, err := strconv.ParseUint(featureFlags, 10, 32); err != nil {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
supportedFlags, err := strconv.ParseUint(supported, 10, 32)
|
||||
if err != nil || supportedFlags&apolloEncryptionAll != apolloEncryptionAll {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
requestedFlags, err := strconv.ParseUint(requested, 10, 32)
|
||||
if err != nil || requestedFlags&^supportedFlags != 0 || requestedFlags&1 == 0 {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validApolloSurroundParameters(value string) bool {
|
||||
const prefix = "97 surround-params="
|
||||
if !strings.HasPrefix(value, prefix) {
|
||||
return false
|
||||
}
|
||||
parameters := strings.TrimPrefix(value, prefix)
|
||||
if len(parameters) < 5 || len(parameters) > 11 {
|
||||
return false
|
||||
}
|
||||
channels := int(parameters[0] - '0')
|
||||
streams := int(parameters[1] - '0')
|
||||
coupled := int(parameters[2] - '0')
|
||||
if (channels != 2 && channels != 6 && channels != 8) || len(parameters) != channels+3 || streams+coupled != channels || streams == 0 {
|
||||
return false
|
||||
}
|
||||
used := [8]bool{}
|
||||
for _, character := range parameters[3:] {
|
||||
if character < '0' || int(character-'0') >= channels || used[character-'0'] {
|
||||
return false
|
||||
}
|
||||
used[character-'0'] = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func apolloRTSPSession(message apolloRTSPMessage) (string, error) {
|
||||
value, ok := message.headers["session"]
|
||||
if !ok {
|
||||
return "", ErrProviderMalformed
|
||||
}
|
||||
token, _, _ := strings.Cut(value, ";")
|
||||
token = strings.TrimSpace(token)
|
||||
if !validApolloRTSPToken(token) || len(token) > 256 {
|
||||
return "", ErrProviderMalformed
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func apolloRTSPMatchSession(message apolloRTSPMessage, expected string) error {
|
||||
actual, err := apolloRTSPSession(message)
|
||||
if err != nil || actual != expected {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func apolloRTSPServerPort(message apolloRTSPMessage) (int, error) {
|
||||
transport, ok := message.headers["transport"]
|
||||
if !ok {
|
||||
return 0, ErrProviderMalformed
|
||||
}
|
||||
parts := strings.Split(transport, ";")
|
||||
if len(parts) != 2 || parts[0] != "unicast" {
|
||||
return 0, ErrProviderMalformed
|
||||
}
|
||||
key, value, ok := strings.Cut(parts[1], "=")
|
||||
port, err := strconv.Atoi(value)
|
||||
if !ok || key != "server_port" || err != nil || port < 1 || port > maxApolloRTSPPort {
|
||||
return 0, ErrProviderMalformed
|
||||
}
|
||||
return port, nil
|
||||
}
|
||||
|
||||
func apolloRTSPPingPayload(message apolloRTSPMessage) ([]byte, error) {
|
||||
payload, ok := message.headers["x-ss-ping-payload"]
|
||||
if !ok || len(payload) != 16 || !validApolloRTSPToken(payload) {
|
||||
return nil, ErrProviderMalformed
|
||||
}
|
||||
return []byte(payload), nil
|
||||
}
|
||||
|
||||
func apolloRTSPConnectData(message apolloRTSPMessage) (uint32, error) {
|
||||
value, ok := message.headers["x-ss-connect-data"]
|
||||
if !ok {
|
||||
return 0, ErrProviderMalformed
|
||||
}
|
||||
if value == "" || strings.Trim(value, "0123456789") != "" {
|
||||
return 0, ErrProviderMalformed
|
||||
}
|
||||
parsed, err := strconv.ParseUint(value, 10, 32)
|
||||
if err != nil || parsed == 0 {
|
||||
return 0, ErrProviderMalformed
|
||||
}
|
||||
return uint32(parsed), nil
|
||||
}
|
||||
|
||||
func apolloAnnounceProfile(policy protocol.ProviderStreamPolicy) ([]byte, error) {
|
||||
if err := validateApolloStreamPolicy(policy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
format, supportsHEVC := int64(0), int64(0)
|
||||
if policy.Codec == "HEVC" {
|
||||
format, supportsHEVC = 1, 1
|
||||
}
|
||||
maximumBitrate := policy.BitrateKbps * 80 / 100
|
||||
if maximumBitrate > 100000 {
|
||||
maximumBitrate = 100000
|
||||
}
|
||||
return []byte("v=0\r\n" +
|
||||
"o=android 0 0 IN IP4 0.0.0.0\r\n" +
|
||||
"s=NVIDIA Streaming Client\r\n" +
|
||||
fmt.Sprintf("a=x-nv-video[0].clientViewportWd:%d\r\n", policy.ResolutionWidth) +
|
||||
fmt.Sprintf("a=x-nv-video[0].clientViewportHt:%d\r\n", policy.ResolutionHeight) +
|
||||
fmt.Sprintf("a=x-nv-video[0].maxFPS:%d\r\n", policy.Fps) +
|
||||
"a=x-nv-video[0].packetSize:1024\r\n" +
|
||||
"a=x-nv-video[0].videoEncoderSlicesPerFrame:1\r\n" +
|
||||
"a=x-nv-video[0].maxNumReferenceFrames:0\r\n" +
|
||||
fmt.Sprintf("a=x-nv-clientSupportHevc:%d\r\n", supportsHEVC) +
|
||||
fmt.Sprintf("a=x-nv-vqos[0].bitStreamFormat:%d\r\n", format) +
|
||||
fmt.Sprintf("a=x-nv-video[0].initialBitrateKbps:%d\r\n", maximumBitrate) +
|
||||
fmt.Sprintf("a=x-nv-video[0].initialPeakBitrateKbps:%d\r\n", maximumBitrate) +
|
||||
fmt.Sprintf("a=x-nv-vqos[0].bw.minimumBitrateKbps:%d\r\n", maximumBitrate) +
|
||||
fmt.Sprintf("a=x-nv-vqos[0].bw.maximumBitrateKbps:%d\r\n", maximumBitrate) +
|
||||
"a=x-nv-vqos[0].fec.minRequiredFecPackets:2\r\n" +
|
||||
"a=x-nv-vqos[0].qosTrafficType:5\r\n" +
|
||||
"a=x-nv-audio.surround.numChannels:2\r\n" +
|
||||
"a=x-nv-audio.surround.channelMask:3\r\n" +
|
||||
"a=x-nv-audio.surround.AudioQuality:0\r\n" +
|
||||
"a=x-nv-aqos.packetDuration:5\r\n" +
|
||||
"a=x-nv-aqos.qosTrafficType:4\r\n" +
|
||||
"a=x-nv-general.useReliableUdp:13\r\n" +
|
||||
"a=x-nv-general.featureFlags:167\r\n" +
|
||||
"a=x-ml-general.featureFlags:0\r\n" +
|
||||
fmt.Sprintf("a=x-ml-video.configuredBitrateKbps:%d\r\n", policy.BitrateKbps) +
|
||||
"a=x-ss-general.encryptionEnabled:7\r\n" +
|
||||
"a=x-ss-video[0].chromaSamplingType:0\r\n" +
|
||||
"a=x-ss-video[0].intraRefresh:0\r\n"), nil
|
||||
}
|
||||
|
||||
func validateApolloStreamPolicy(policy protocol.ProviderStreamPolicy) error {
|
||||
if err := policy.Validate(); err != nil || !policy.AudioEnabled || policy.BitrateKbps > 125000 ||
|
||||
(policy.Codec != "H264" && policy.Codec != "HEVC") {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
if (policy.Codec == "H264" && (policy.ResolutionWidth > 4096 || policy.ResolutionHeight > 4096)) ||
|
||||
(policy.Codec == "HEVC" && (policy.ResolutionWidth > 8192 || policy.ResolutionHeight > 8192)) {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateApolloProviderStreamPolicy(info ManagementInfo, policy protocol.ProviderStreamPolicy) error {
|
||||
if validateApolloStreamPolicy(policy) != nil || !info.HasServerCodecModeSupport || !info.HasMaxLumaPixelsHEVC {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
switch policy.Codec {
|
||||
case "H264":
|
||||
if info.ServerCodecModeSupport&0x1 == 0 {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
case "HEVC":
|
||||
luma := uint64(policy.ResolutionWidth) * uint64(policy.ResolutionHeight)
|
||||
if info.ServerCodecModeSupport&0x100 == 0 || info.MaxLumaPixelsHEVC == 0 || luma > info.MaxLumaPixelsHEVC {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
default:
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validApolloRTSPToken(value string) bool {
|
||||
if value == "" || len(value) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character <= 0x20 || character >= 0x7f || strings.ContainsRune("()<>@,;:\\\"/[]?={}", character) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncryptedRTSPRejectsTagReplayAndReorderedHostFrames(t *testing.T) {
|
||||
key := []byte("0123456789abcdef")
|
||||
codec, err := newEncryptedRTSPCodec(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := hostEncryptedRTSPFrame(t, key, 1, []byte("RTSP/1.0 200 OK\r\n\r\n"))
|
||||
if plaintext, err := codec.OpenHost(first); err != nil || string(plaintext) != "RTSP/1.0 200 OK\r\n\r\n" {
|
||||
t.Fatalf("OpenHost() = %q, %v", plaintext, err)
|
||||
}
|
||||
if _, err := codec.OpenHost(first); err == nil {
|
||||
t.Fatal("OpenHost() accepted a replayed frame")
|
||||
}
|
||||
tampered := hostEncryptedRTSPFrame(t, key, 2, []byte("RTSP/1.0 200 OK\r\n\r\n"))
|
||||
tampered[len(tampered)-1] ^= 0x01
|
||||
if _, err := codec.OpenHost(tampered); err == nil {
|
||||
t.Fatal("OpenHost() accepted a tag failure")
|
||||
}
|
||||
third := hostEncryptedRTSPFrame(t, key, 3, []byte("RTSP/1.0 200 OK\r\n\r\n"))
|
||||
if _, err := codec.OpenHost(third); err != nil {
|
||||
t.Fatalf("OpenHost() sequence 3 error = %v", err)
|
||||
}
|
||||
second := hostEncryptedRTSPFrame(t, key, 2, []byte("RTSP/1.0 200 OK\r\n\r\n"))
|
||||
if _, err := codec.OpenHost(second); err == nil {
|
||||
t.Fatal("OpenHost() accepted an out-of-order frame")
|
||||
}
|
||||
}
|
||||
|
||||
func hostEncryptedRTSPFrame(t *testing.T, key []byte, sequence uint32, plaintext []byte) []byte {
|
||||
t.Helper()
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nonce := encryptedRTSPNonce(sequence, 'H', 'R')
|
||||
sealed := aead.Seal(nil, nonce[:], plaintext, nil)
|
||||
frame := make([]byte, encryptedRTSPHeaderSize+len(plaintext))
|
||||
binary.BigEndian.PutUint32(frame[:4], uint32(len(plaintext))|0x80000000)
|
||||
binary.BigEndian.PutUint32(frame[4:8], sequence)
|
||||
copy(frame[8:24], sealed[len(plaintext):])
|
||||
copy(frame[24:], sealed[:len(plaintext)])
|
||||
return frame
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
)
|
||||
|
||||
const (
|
||||
apolloVideoMaximumDataShards = 255
|
||||
apolloVideoMaximumBlocks = 4
|
||||
apolloVideoShardPayloadSize = apolloVideoRawPacketSize - apolloRTPHeaderSize - 4 - apolloVideoNVHeaderSize
|
||||
)
|
||||
|
||||
type apolloVideoShard struct {
|
||||
frame uint32
|
||||
block uint8
|
||||
lastBlock uint8
|
||||
dataPackets int
|
||||
parity int
|
||||
index int
|
||||
sequence uint16
|
||||
streamIndex uint32
|
||||
flags byte
|
||||
payload []byte
|
||||
}
|
||||
|
||||
type apolloVideoFECBlock struct {
|
||||
dataPackets int
|
||||
parity int
|
||||
firstSeq uint16
|
||||
streamBase uint32
|
||||
haveBase bool
|
||||
shards [][]byte
|
||||
received []bool
|
||||
count int
|
||||
complete bool
|
||||
}
|
||||
|
||||
type apolloVideoAssembler struct {
|
||||
haveFrame bool
|
||||
frame uint32
|
||||
lastBlock uint8
|
||||
blocks [apolloVideoMaximumBlocks]*apolloVideoFECBlock
|
||||
}
|
||||
|
||||
func (a *apolloVideoAssembler) Add(shard apolloVideoShard) ([]byte, error) {
|
||||
if len(shard.payload) != apolloVideoShardPayloadSize || shard.dataPackets < 1 || shard.dataPackets > apolloVideoMaximumDataShards || shard.parity < 0 || shard.dataPackets+shard.parity > 255 || shard.block > shard.lastBlock || shard.lastBlock >= apolloVideoMaximumBlocks || shard.index >= shard.dataPackets+shard.parity {
|
||||
return nil, errApolloMedia
|
||||
}
|
||||
if !a.haveFrame || apolloFrameNewer(shard.frame, a.frame) {
|
||||
*a = apolloVideoAssembler{haveFrame: true, frame: shard.frame, lastBlock: shard.lastBlock}
|
||||
} else if shard.frame != a.frame {
|
||||
return nil, nil
|
||||
}
|
||||
if shard.lastBlock != a.lastBlock {
|
||||
return nil, errApolloMedia
|
||||
}
|
||||
block := a.blocks[shard.block]
|
||||
if block == nil {
|
||||
block = &apolloVideoFECBlock{
|
||||
dataPackets: shard.dataPackets,
|
||||
parity: shard.parity,
|
||||
shards: make([][]byte, shard.dataPackets+shard.parity),
|
||||
received: make([]bool, shard.dataPackets+shard.parity),
|
||||
}
|
||||
a.blocks[shard.block] = block
|
||||
} else if block.dataPackets != shard.dataPackets || block.parity != shard.parity {
|
||||
return nil, errApolloMedia
|
||||
}
|
||||
if shard.index < shard.dataPackets {
|
||||
if shard.index == 0 && shard.flags&0x04 == 0 || shard.index == shard.dataPackets-1 && shard.flags&0x02 == 0 || shard.flags&^byte(0x07) != 0 {
|
||||
return nil, errApolloMedia
|
||||
}
|
||||
base := shard.sequence - uint16(shard.index)
|
||||
streamBase := shard.streamIndex - uint32(shard.index)
|
||||
if !block.haveBase {
|
||||
block.firstSeq, block.streamBase, block.haveBase = base, streamBase, true
|
||||
} else if block.firstSeq != base || block.streamBase != streamBase {
|
||||
return nil, errApolloMedia
|
||||
}
|
||||
}
|
||||
if block.received[shard.index] {
|
||||
if !bytes.Equal(block.shards[shard.index], shard.payload) {
|
||||
return nil, errApolloMedia
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
block.shards[shard.index] = append([]byte(nil), shard.payload...)
|
||||
block.received[shard.index] = true
|
||||
block.count++
|
||||
if block.count < block.dataPackets {
|
||||
return nil, nil
|
||||
}
|
||||
if !block.complete {
|
||||
if err := reconstructApolloVideoBlock(block); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block.complete = true
|
||||
}
|
||||
for index := uint8(0); index <= a.lastBlock; index++ {
|
||||
if a.blocks[index] == nil || !a.blocks[index].complete {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
capacity := 0
|
||||
for blockIndex := uint8(0); blockIndex <= a.lastBlock; blockIndex++ {
|
||||
capacity += a.blocks[blockIndex].dataPackets * apolloVideoShardPayloadSize
|
||||
}
|
||||
frame := make([]byte, 0, capacity)
|
||||
for blockIndex := uint8(0); blockIndex <= a.lastBlock; blockIndex++ {
|
||||
for shardIndex := 0; shardIndex < a.blocks[blockIndex].dataPackets; shardIndex++ {
|
||||
frame = append(frame, a.blocks[blockIndex].shards[shardIndex]...)
|
||||
}
|
||||
}
|
||||
if len(frame) < 8 || frame[0] != 0x01 {
|
||||
return nil, errApolloMedia
|
||||
}
|
||||
lastPayloadLength := int(binary.LittleEndian.Uint16(frame[4:6]))
|
||||
end := len(frame) - apolloVideoShardPayloadSize + lastPayloadLength
|
||||
if lastPayloadLength == 0 || lastPayloadLength > apolloVideoShardPayloadSize || end <= 8 || end > len(frame) {
|
||||
return nil, errApolloMedia
|
||||
}
|
||||
*a = apolloVideoAssembler{}
|
||||
return append([]byte(nil), frame[8:end]...), nil
|
||||
}
|
||||
|
||||
func apolloFrameNewer(first, second uint32) bool {
|
||||
return int32(first-second) > 0
|
||||
}
|
||||
|
||||
func reconstructApolloVideoBlock(block *apolloVideoFECBlock) error {
|
||||
if block == nil || block.count < block.dataPackets {
|
||||
return errApolloMedia
|
||||
}
|
||||
missing := false
|
||||
for index := 0; index < block.dataPackets; index++ {
|
||||
if !block.received[index] {
|
||||
missing = true
|
||||
block.shards[index] = make([]byte, apolloVideoShardPayloadSize)
|
||||
}
|
||||
}
|
||||
if !missing {
|
||||
return nil
|
||||
}
|
||||
selectedRows := make([][]byte, 0, block.dataPackets)
|
||||
selectedShards := make([][]byte, 0, block.dataPackets)
|
||||
for index, received := range block.received {
|
||||
if !received {
|
||||
continue
|
||||
}
|
||||
selectedRows = append(selectedRows, apolloVideoFECRow(index, block.dataPackets, block.parity))
|
||||
selectedShards = append(selectedShards, block.shards[index])
|
||||
if len(selectedRows) == block.dataPackets {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(selectedRows) != block.dataPackets {
|
||||
return errApolloMedia
|
||||
}
|
||||
inverse, ok := apolloGFInvert(selectedRows)
|
||||
if !ok {
|
||||
return errApolloMedia
|
||||
}
|
||||
for index := 0; index < block.dataPackets; index++ {
|
||||
if block.received[index] {
|
||||
continue
|
||||
}
|
||||
for source, coefficient := range inverse[index] {
|
||||
apolloGFAXPY(block.shards[index], selectedShards[source], coefficient)
|
||||
}
|
||||
block.received[index] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func apolloVideoFECRow(index, dataPackets, parity int) []byte {
|
||||
row := make([]byte, dataPackets)
|
||||
if index < dataPackets {
|
||||
row[index] = 1
|
||||
return row
|
||||
}
|
||||
parityIndex := index - dataPackets
|
||||
for dataIndex := range row {
|
||||
row[dataIndex] = apolloGFInverse(byte((parity + dataIndex) ^ parityIndex))
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func apolloGFInvert(matrix [][]byte) ([][]byte, bool) {
|
||||
size := len(matrix)
|
||||
if size == 0 {
|
||||
return nil, false
|
||||
}
|
||||
work := make([][]byte, size)
|
||||
inverse := make([][]byte, size)
|
||||
for row := range matrix {
|
||||
if len(matrix[row]) != size {
|
||||
return nil, false
|
||||
}
|
||||
work[row] = append([]byte(nil), matrix[row]...)
|
||||
inverse[row] = make([]byte, size)
|
||||
inverse[row][row] = 1
|
||||
}
|
||||
for column := 0; column < size; column++ {
|
||||
pivot := column
|
||||
for pivot < size && work[pivot][column] == 0 {
|
||||
pivot++
|
||||
}
|
||||
if pivot == size {
|
||||
return nil, false
|
||||
}
|
||||
work[column], work[pivot] = work[pivot], work[column]
|
||||
inverse[column], inverse[pivot] = inverse[pivot], inverse[column]
|
||||
factor := apolloGFInverse(work[column][column])
|
||||
for index := column; index < size; index++ {
|
||||
work[column][index] = apolloGFMultiply(work[column][index], factor)
|
||||
}
|
||||
for index := range inverse[column] {
|
||||
inverse[column][index] = apolloGFMultiply(inverse[column][index], factor)
|
||||
}
|
||||
for row := 0; row < size; row++ {
|
||||
if row == column || work[row][column] == 0 {
|
||||
continue
|
||||
}
|
||||
factor = work[row][column]
|
||||
for index := column; index < size; index++ {
|
||||
work[row][index] ^= apolloGFMultiply(work[column][index], factor)
|
||||
}
|
||||
for index := range inverse[row] {
|
||||
inverse[row][index] ^= apolloGFMultiply(inverse[column][index], factor)
|
||||
}
|
||||
}
|
||||
}
|
||||
return inverse, true
|
||||
}
|
||||
|
||||
func apolloGFAXPY(destination, source []byte, coefficient byte) {
|
||||
if coefficient == 0 {
|
||||
return
|
||||
}
|
||||
for index := range destination {
|
||||
destination[index] ^= apolloGFMultiply(source[index], coefficient)
|
||||
}
|
||||
}
|
||||
|
||||
func apolloGFInverse(value byte) byte {
|
||||
if value == 0 {
|
||||
return 0
|
||||
}
|
||||
return apolloGFPow(value, 254)
|
||||
}
|
||||
|
||||
func apolloGFPow(value byte, exponent uint8) byte {
|
||||
result := byte(1)
|
||||
for exponent != 0 {
|
||||
if exponent&1 != 0 {
|
||||
result = apolloGFMultiply(result, value)
|
||||
}
|
||||
value = apolloGFMultiply(value, value)
|
||||
exponent >>= 1
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func apolloGFMultiply(first, second byte) byte {
|
||||
var product byte
|
||||
for second != 0 {
|
||||
if second&1 != 0 {
|
||||
product ^= first
|
||||
}
|
||||
high := first & 0x80
|
||||
first <<= 1
|
||||
if high != 0 {
|
||||
first ^= 0x1d
|
||||
}
|
||||
second >>= 1
|
||||
}
|
||||
return product
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol"
|
||||
)
|
||||
|
||||
var ErrNoCapabilityOverlap = errors.New("no capability overlap")
|
||||
|
||||
func DefaultCapabilities() protocol.CapabilityProfile {
|
||||
return protocol.CapabilityProfile{
|
||||
Transport: "quic-tls13",
|
||||
Framing: "datagram-v2",
|
||||
Media: "encoded",
|
||||
Audio: "encoded",
|
||||
SourceRateControl: "server",
|
||||
ClientDecode: []string{"hevc-opus", "h264-opus"},
|
||||
}
|
||||
}
|
||||
|
||||
func capabilityProfileUnset(profile protocol.CapabilityProfile) bool {
|
||||
return profile.Transport == "" && profile.Framing == "" && profile.Media == "" &&
|
||||
profile.Audio == "" && profile.SourceRateControl == "" && len(profile.ClientDecode) == 0
|
||||
}
|
||||
|
||||
func IntersectCapabilities(profiles ...protocol.CapabilityProfile) (protocol.CapabilityProfile, error) {
|
||||
selected, err := protocol.IntersectCapabilityProfiles(profiles...)
|
||||
if err != nil {
|
||||
return protocol.CapabilityProfile{}, ErrNoCapabilityOverlap
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrClipboardDenied = errors.New("clipboard policy denied")
|
||||
ErrClipboardRate = errors.New("clipboard rate limited")
|
||||
)
|
||||
|
||||
const clipboardRetention = time.Minute
|
||||
|
||||
type clipboardRecord struct {
|
||||
digest [sha256.Size]byte
|
||||
direction string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
type clipboardGate struct {
|
||||
policy protocol.ClipboardPolicy
|
||||
now func() time.Time
|
||||
mu sync.Mutex
|
||||
updates []time.Time
|
||||
seen map[string]clipboardRecord
|
||||
}
|
||||
|
||||
func newClipboardGate(policy protocol.ClipboardPolicy, now func() time.Time) (*clipboardGate, error) {
|
||||
if err := policy.Validate(); err != nil || now == nil {
|
||||
return nil, ErrProviderMalformed
|
||||
}
|
||||
return &clipboardGate{policy: policy, now: now, seen: make(map[string]clipboardRecord, policy.MaxUpdatesPerMinute)}, nil
|
||||
}
|
||||
|
||||
// ValidateGatewayClipboard applies the authenticated Server policy before any
|
||||
// clipboard value can reach a provider or Verse client.
|
||||
func ValidateGatewayClipboard(policy protocol.ClipboardPolicy, value protocol.GatewayClipboardText) error {
|
||||
if err := policy.Validate(); err != nil || value.Validate() != nil || !utf8.ValidString(value.Text) {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
if len(value.Text) > int(policy.MaxTextBytes) {
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
switch value.Direction {
|
||||
case "client_to_provider":
|
||||
if !policy.ClientToProviderEnabled {
|
||||
return ErrClipboardDenied
|
||||
}
|
||||
case "provider_to_client":
|
||||
if !policy.ProviderToClientEnabled {
|
||||
return ErrClipboardDenied
|
||||
}
|
||||
default:
|
||||
return ErrProviderMalformed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *clipboardGate) fromClient(value protocol.GatewayClipboardText) (bool, error) {
|
||||
if g == nil {
|
||||
return false, ErrClipboardDenied
|
||||
}
|
||||
if err := ValidateGatewayClipboard(g.policy, value); err != nil {
|
||||
return false, err
|
||||
}
|
||||
now := g.now()
|
||||
digest := sha256.Sum256([]byte(value.Text))
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.pruneLocked(now)
|
||||
if record, ok := g.seen[value.LoopToken]; ok {
|
||||
if record.direction == "provider_to_client" && record.digest == digest {
|
||||
return true, nil
|
||||
}
|
||||
return false, ErrClipboardDenied
|
||||
}
|
||||
if !g.allowUpdateLocked(now) {
|
||||
return false, ErrClipboardRate
|
||||
}
|
||||
g.seen[value.LoopToken] = clipboardRecord{digest: digest, direction: value.Direction, at: now}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (g *clipboardGate) fromProvider(text string) (protocol.GatewayClipboardText, bool, error) {
|
||||
if g == nil || !g.policy.ProviderToClientEnabled {
|
||||
return protocol.GatewayClipboardText{}, false, ErrClipboardDenied
|
||||
}
|
||||
if !utf8.ValidString(text) || len(text) > int(g.policy.MaxTextBytes) {
|
||||
return protocol.GatewayClipboardText{}, false, ErrProviderMalformed
|
||||
}
|
||||
now := g.now()
|
||||
digest := sha256.Sum256([]byte(text))
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.pruneLocked(now)
|
||||
for _, record := range g.seen {
|
||||
if record.digest == digest {
|
||||
return protocol.GatewayClipboardText{}, true, nil
|
||||
}
|
||||
}
|
||||
if !g.allowUpdateLocked(now) {
|
||||
return protocol.GatewayClipboardText{}, false, ErrClipboardRate
|
||||
}
|
||||
for attempts := 0; attempts < 3; attempts++ {
|
||||
var raw [24]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return protocol.GatewayClipboardText{}, false, err
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(raw[:])
|
||||
if _, exists := g.seen[token]; exists {
|
||||
continue
|
||||
}
|
||||
value := protocol.GatewayClipboardText{Direction: "provider_to_client", Text: text, Encoding: "utf-8", LoopToken: token}
|
||||
g.seen[token] = clipboardRecord{digest: digest, direction: value.Direction, at: now}
|
||||
return value, false, nil
|
||||
}
|
||||
return protocol.GatewayClipboardText{}, false, ErrClipboardDenied
|
||||
}
|
||||
|
||||
func (g *clipboardGate) retractClient(value protocol.GatewayClipboardText) {
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
digest := sha256.Sum256([]byte(value.Text))
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if record, ok := g.seen[value.LoopToken]; ok && record.direction == "client_to_provider" && record.digest == digest {
|
||||
delete(g.seen, value.LoopToken)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *clipboardGate) pruneLocked(now time.Time) {
|
||||
minimum := now.Add(-clipboardRetention)
|
||||
index := 0
|
||||
for _, update := range g.updates {
|
||||
if update.After(minimum) {
|
||||
g.updates[index] = update
|
||||
index++
|
||||
}
|
||||
}
|
||||
g.updates = g.updates[:index]
|
||||
for token, record := range g.seen {
|
||||
if !record.at.After(minimum) {
|
||||
delete(g.seen, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *clipboardGate) allowUpdateLocked(now time.Time) bool {
|
||||
if len(g.updates) >= int(g.policy.MaxUpdatesPerMinute) {
|
||||
return false
|
||||
}
|
||||
g.updates = append(g.updates, now)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol"
|
||||
)
|
||||
|
||||
func TestValidateGatewayClipboardEnforcesServerOwnedPolicy(t *testing.T) {
|
||||
policy := protocol.ClipboardPolicy{ClientToProviderEnabled: true, ProviderToClientEnabled: true, MaxTextBytes: 5, MaxUpdatesPerMinute: 2}
|
||||
valid := protocol.GatewayClipboardText{Direction: "client_to_provider", Text: "hello", Encoding: "utf-8", LoopToken: "abcdefghijklmnop"}
|
||||
if err := ValidateGatewayClipboard(policy, valid); err != nil {
|
||||
t.Fatalf("ValidateGatewayClipboard() valid text = %v", err)
|
||||
}
|
||||
if err := ValidateGatewayClipboard(policy, protocol.GatewayClipboardText{Direction: "client_to_provider", Text: "hello", Encoding: "utf-8", LoopToken: "!!!!!!!!!!!!!!!!"}); err == nil {
|
||||
t.Fatal("ValidateGatewayClipboard() accepted malformed loop token")
|
||||
}
|
||||
if err := ValidateGatewayClipboard(policy, protocol.GatewayClipboardText{Direction: "client_to_provider", Text: strings.Repeat("x", 6), Encoding: "utf-8", LoopToken: "abcdefghijklmnop"}); err == nil {
|
||||
t.Fatal("ValidateGatewayClipboard() accepted oversized text")
|
||||
}
|
||||
disabled := policy
|
||||
disabled.ClientToProviderEnabled = false
|
||||
if err := ValidateGatewayClipboard(disabled, valid); err == nil {
|
||||
t.Fatal("ValidateGatewayClipboard() accepted disabled direction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClipboardGateSuppressesReflectionsAndBoundsRate(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
policy := protocol.ClipboardPolicy{ClientToProviderEnabled: true, ProviderToClientEnabled: true, MaxTextBytes: 64, MaxUpdatesPerMinute: 2}
|
||||
gate, err := newClipboardGate(policy, func() time.Time { return now })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := protocol.GatewayClipboardText{Direction: "client_to_provider", Text: "client", Encoding: "utf-8", LoopToken: "abcdefghijklmnop"}
|
||||
if suppress, err := gate.fromClient(client); err != nil || suppress {
|
||||
t.Fatalf("fromClient() = suppress %t, err %v", suppress, err)
|
||||
}
|
||||
if _, suppress, err := gate.fromProvider("client"); err != nil || !suppress {
|
||||
t.Fatalf("fromProvider() reflection = suppress %t, err %v", suppress, err)
|
||||
}
|
||||
host, suppress, err := gate.fromProvider("host")
|
||||
if err != nil || suppress || host.Direction != "provider_to_client" {
|
||||
t.Fatalf("fromProvider() host = %#v, suppress %t, err %v", host, suppress, err)
|
||||
}
|
||||
if suppress, err := gate.fromClient(host); err != nil || !suppress {
|
||||
t.Fatalf("fromClient() host reflection = suppress %t, err %v", suppress, err)
|
||||
}
|
||||
if _, err := gate.fromClient(protocol.GatewayClipboardText{Direction: "client_to_provider", Text: "third", Encoding: "utf-8", LoopToken: "qrstuvwxyzABCDEF"}); !errors.Is(err, ErrClipboardRate) {
|
||||
t.Fatalf("fromClient() rate error = %v, want ErrClipboardRate", err)
|
||||
}
|
||||
now = now.Add(time.Minute)
|
||||
if suppress, err := gate.fromClient(protocol.GatewayClipboardText{Direction: "client_to_provider", Text: "after-window", Encoding: "utf-8", LoopToken: "0123456789abcdef"}); err != nil || suppress {
|
||||
t.Fatalf("fromClient() after window = suppress %t, err %v", suppress, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClipboardGatePermitsRetryAfterProviderWriteFailure(t *testing.T) {
|
||||
policy := protocol.ClipboardPolicy{ClientToProviderEnabled: true, MaxTextBytes: 64, MaxUpdatesPerMinute: 2}
|
||||
gate, err := newClipboardGate(policy, time.Now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value := protocol.GatewayClipboardText{Direction: "client_to_provider", Text: "retry", Encoding: "utf-8", LoopToken: "abcdefghijklmnop"}
|
||||
if suppress, err := gate.fromClient(value); err != nil || suppress {
|
||||
t.Fatalf("fromClient() = suppress %t, err %v", suppress, err)
|
||||
}
|
||||
gate.retractClient(value)
|
||||
if suppress, err := gate.fromClient(value); err != nil || suppress {
|
||||
t.Fatalf("fromClient() retry = suppress %t, err %v", suppress, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol"
|
||||
)
|
||||
|
||||
type ControlPlaneClient struct {
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func NewControlPlaneClient(baseURL string, client *http.Client) *ControlPlaneClient {
|
||||
if client == nil {
|
||||
client = &http.Client{}
|
||||
}
|
||||
return &ControlPlaneClient{BaseURL: strings.TrimRight(baseURL, "/"), HTTPClient: client}
|
||||
}
|
||||
|
||||
func (c *ControlPlaneClient) Register(ctx context.Context, registration protocol.GatewayRegistration) (protocol.GatewayRegistration, error) {
|
||||
payload, err := protocol.EncodeGatewayRegistration(registration)
|
||||
if err != nil {
|
||||
return protocol.GatewayRegistration{}, err
|
||||
}
|
||||
response, err := c.post(ctx, "/api/v1/gateway/register", payload)
|
||||
if err != nil {
|
||||
return protocol.GatewayRegistration{}, err
|
||||
}
|
||||
return protocol.DecodeGatewayRegistration(response)
|
||||
}
|
||||
|
||||
func (c *ControlPlaneClient) Heartbeat(ctx context.Context, heartbeat protocol.GatewayHeartbeat) error {
|
||||
payload, err := protocol.EncodeGatewayHeartbeat(heartbeat)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.post(ctx, "/api/v1/gateway/heartbeat", payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *ControlPlaneClient) Drain(ctx context.Context, drain protocol.GatewayDrain) error {
|
||||
payload, err := protocol.EncodeGatewayDrain(drain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.post(ctx, "/api/v1/gateway/drain", payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *ControlPlaneClient) Admit(ctx context.Context, request protocol.TunnelAdmissionRequest) (protocol.SessionAuthority, error) {
|
||||
payload, err := protocol.EncodeTunnelAdmissionRequest(request)
|
||||
if err != nil {
|
||||
return protocol.SessionAuthority{}, err
|
||||
}
|
||||
response, err := c.post(ctx, "/api/v1/gateway/admit", payload)
|
||||
if err != nil {
|
||||
return protocol.SessionAuthority{}, err
|
||||
}
|
||||
return protocol.DecodeSessionAuthority(response)
|
||||
}
|
||||
|
||||
func (c *ControlPlaneClient) Release(ctx context.Context, authority protocol.SessionAuthority) error {
|
||||
payload, err := protocol.EncodeSessionAuthority(authority)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.post(ctx, "/api/v1/gateway/release", payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *ControlPlaneClient) ProviderWork(ctx context.Context, authority protocol.SessionAuthority) (protocol.ProviderSessionWork, error) {
|
||||
payload, err := protocol.EncodeSessionAuthority(authority)
|
||||
if err != nil {
|
||||
return protocol.ProviderSessionWork{}, err
|
||||
}
|
||||
response, err := c.post(ctx, "/api/v1/gateway/provider-work", payload)
|
||||
if err != nil {
|
||||
return protocol.ProviderSessionWork{}, err
|
||||
}
|
||||
return protocol.DecodeProviderSessionWork(response)
|
||||
}
|
||||
|
||||
func (c *ControlPlaneClient) ReportProviderState(ctx context.Context, state protocol.ProviderState) error {
|
||||
payload, err := protocol.EncodeProviderState(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.post(ctx, "/api/v1/gateway/provider-state", payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *ControlPlaneClient) ReportClipboardAudit(ctx context.Context, audit protocol.GatewayClipboardAudit) error {
|
||||
payload, err := protocol.EncodeGatewayClipboardAudit(audit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.post(ctx, "/api/v1/gateway/clipboard-audit", payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *ControlPlaneClient) post(ctx context.Context, path string, payload []byte) ([]byte, error) {
|
||||
if c == nil || c.HTTPClient == nil || c.BaseURL == "" {
|
||||
return nil, errors.New("control-plane client is not configured")
|
||||
}
|
||||
baseURL, err := url.Parse(c.BaseURL)
|
||||
if err != nil || baseURL.Scheme != "https" || baseURL.Host == "" {
|
||||
return nil, errors.New("control-plane base URL must be absolute HTTPS")
|
||||
}
|
||||
requestURL := *baseURL
|
||||
requestURL.Path = strings.TrimRight(baseURL.Path, "/") + path
|
||||
requestURL.RawPath = ""
|
||||
requestURL.RawQuery = ""
|
||||
requestURL.Fragment = ""
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
client := *c.HTTPClient
|
||||
client.CheckRedirect = func(*http.Request, []*http.Request) error {
|
||||
return errors.New("control-plane redirects are not permitted")
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, defaultControlLimit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) > defaultControlLimit {
|
||||
return nil, ErrFrameSize
|
||||
}
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
var stable protocol.StableError
|
||||
if json.Unmarshal(body, &stable) == nil && stable.Code != "" {
|
||||
return nil, fmt.Errorf("%s: %s", stable.Code, stable.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("control-plane status %d", response.StatusCode)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
var _ Admission = (*ControlPlaneClient)(nil)
|
||||
var _ ClipboardAuditReporter = (*ControlPlaneClient)(nil)
|
||||
@@ -0,0 +1,94 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol"
|
||||
)
|
||||
|
||||
func TestControlPlaneRejectsHTTPBeforeSending(t *testing.T) {
|
||||
var requests atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
requests.Add(1)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := NewControlPlaneClient(server.URL, server.Client()).Heartbeat(
|
||||
context.Background(),
|
||||
validControlPlaneHeartbeat("gateway-1"),
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("HTTP control-plane URL was accepted")
|
||||
}
|
||||
if got := requests.Load(); got != 0 {
|
||||
t.Fatalf("HTTP control-plane received %d requests, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlPlaneRejectsHTTPSRedirectToHTTPWithoutDisclosure(t *testing.T) {
|
||||
var downgradeRequests atomic.Int64
|
||||
downgrade := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
downgradeRequests.Add(1)
|
||||
}))
|
||||
defer downgrade.Close()
|
||||
|
||||
serverTLS, clientTLS := testTLS(t)
|
||||
source := httptest.NewUnstartedServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if len(request.TLS.PeerCertificates) == 0 {
|
||||
t.Error("source did not authenticate the client certificate")
|
||||
}
|
||||
http.Redirect(response, request, downgrade.URL+"/capture", http.StatusTemporaryRedirect)
|
||||
}))
|
||||
source.TLS = serverTLS
|
||||
source.StartTLS()
|
||||
defer source.Close()
|
||||
|
||||
client := &http.Client{Transport: &http.Transport{TLSClientConfig: clientTLS}}
|
||||
err := NewControlPlaneClient(source.URL, client).Heartbeat(
|
||||
context.Background(),
|
||||
validControlPlaneHeartbeat("gateway-secret"),
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("HTTPS-to-HTTP redirect was accepted")
|
||||
}
|
||||
if got := downgradeRequests.Load(); got != 0 {
|
||||
t.Fatalf("downgrade target received %d requests, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlPlanePinnedMTLSRemainsFunctional(t *testing.T) {
|
||||
serverTLS, clientTLS := testTLS(t)
|
||||
var authenticated atomic.Bool
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
authenticated.Store(request.TLS != nil && len(request.TLS.PeerCertificates) > 0)
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
server.TLS = serverTLS
|
||||
server.StartTLS()
|
||||
defer server.Close()
|
||||
|
||||
client := &http.Client{Transport: &http.Transport{TLSClientConfig: clientTLS}}
|
||||
if err := NewControlPlaneClient(server.URL, client).Heartbeat(
|
||||
context.Background(),
|
||||
validControlPlaneHeartbeat("gateway-1"),
|
||||
); err != nil {
|
||||
t.Fatalf("pinned mTLS heartbeat: %v", err)
|
||||
}
|
||||
if !authenticated.Load() {
|
||||
t.Fatal("server did not authenticate the client certificate")
|
||||
}
|
||||
}
|
||||
|
||||
func validControlPlaneHeartbeat(gatewayID string) protocol.GatewayHeartbeat {
|
||||
return protocol.GatewayHeartbeat{
|
||||
Version: "1", GatewayID: gatewayID, Sequence: 1,
|
||||
ObservedAt: time.Unix(1, 0).UTC().Format(time.RFC3339Nano),
|
||||
State: "ready",
|
||||
Telemetry: protocol.GatewayTelemetry{MediaPackets: 1, ProviderState: "ready"},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type syntheticPacerDelivery struct {
|
||||
at time.Time
|
||||
flow string
|
||||
bytes int64
|
||||
}
|
||||
|
||||
func TestFairPacerEightFlowSharesAndCapacitySteps(t *testing.T) {
|
||||
start := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
flows := []string{"one", "two", "three", "four", "five", "six", "seven", "eight"}
|
||||
pacer := newFairPacer(8000)
|
||||
next := make(map[string]time.Time, len(flows))
|
||||
baseline := runSyntheticPacer(pacer, start, start.Add(60*time.Second), flows, next)
|
||||
assertSyntheticFairness(t, baseline, flows)
|
||||
assertSyntheticCap(t, baseline, 1_000_000)
|
||||
|
||||
pacer.setKbps(6000)
|
||||
quarter := runSyntheticPacer(pacer, start.Add(60*time.Second), start.Add(70*time.Second), flows, next)
|
||||
assertSyntheticFairness(t, quarter, flows)
|
||||
assertSyntheticCap(t, quarter, 750_000)
|
||||
|
||||
pacer.setKbps(4000)
|
||||
half := runSyntheticPacer(pacer, start.Add(70*time.Second), start.Add(80*time.Second), flows, next)
|
||||
assertSyntheticFairness(t, half, flows)
|
||||
assertSyntheticCap(t, half, 500_000)
|
||||
}
|
||||
|
||||
func TestFairPacerBoundsCatchupAfterHostStall(t *testing.T) {
|
||||
start := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
pacer := newFairPacer(8000)
|
||||
_ = pacer.reserveAt(start, "one", 1000)
|
||||
resumed := start.Add(100 * time.Millisecond)
|
||||
next := pacer.reserveAt(resumed, "one", 1000)
|
||||
if next.Before(resumed.Add(-fairPacerMaximumCatchup)) || next.After(resumed.Add(10*time.Millisecond)) {
|
||||
t.Fatalf("post-stall reservation = %s, want bounded catchup near %s", next, resumed)
|
||||
}
|
||||
pacer.mu.Lock()
|
||||
debt := pacer.flows["one"].debt
|
||||
pacer.mu.Unlock()
|
||||
if debt <= 0 || debt > nativeApolloVideoQueueLatency-fairPacerMaximumCatchup {
|
||||
t.Fatalf("post-stall debt = %s, want bounded valid schedule debt", debt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFairPacerRepaysBoundedDebtAfterHostStall(t *testing.T) {
|
||||
start := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
pacer := newFairPacer(8000)
|
||||
next := make(map[string]time.Time)
|
||||
deliveries := runSyntheticPacerWithStall(
|
||||
pacer, start, start.Add(6*time.Second), []string{"one"}, next,
|
||||
start.Add(time.Second), 100*time.Millisecond,
|
||||
)
|
||||
if total := syntheticDeliveryBytes(deliveries); total < 5_990_000 || total > 6_010_000 {
|
||||
t.Fatalf("post-stall delivery bytes = %d, want nominal throughput after bounded debt repayment", total)
|
||||
}
|
||||
pacer.mu.Lock()
|
||||
remaining := pacer.flows["one"].debt
|
||||
pacer.mu.Unlock()
|
||||
if remaining != 0 {
|
||||
t.Fatalf("post-stall debt = %s after repayment, want zero", remaining)
|
||||
}
|
||||
assertSyntheticCap(t, deliveries, 1_000_000)
|
||||
t.Logf("single-flow debt repaid: bytes=%d remaining=%s", syntheticDeliveryBytes(deliveries), remaining)
|
||||
}
|
||||
|
||||
func TestFairPacerRepaysSimultaneousEightFlowDebtAcrossCapacitySteps(t *testing.T) {
|
||||
start := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
flows := []string{"one", "two", "three", "four", "five", "six", "seven", "eight"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
kbps int64
|
||||
bytesPerSecond int64
|
||||
minimumBytes int64
|
||||
}{
|
||||
{name: "baseline", kbps: 8000, bytesPerSecond: 1_000_000, minimumBytes: 9_980_000},
|
||||
{name: "quarter", kbps: 6000, bytesPerSecond: 750_000, minimumBytes: 7_480_000},
|
||||
{name: "half", kbps: 4000, bytesPerSecond: 500_000, minimumBytes: 4_980_000},
|
||||
}
|
||||
for _, test := range tests {
|
||||
pacer := newFairPacer(8000)
|
||||
next := make(map[string]time.Time, len(flows))
|
||||
_ = runSyntheticPacer(pacer, start, start.Add(time.Second), flows, next)
|
||||
resumed := start.Add(1100 * time.Millisecond)
|
||||
for _, flow := range flows {
|
||||
next[flow] = pacer.reserveAt(resumed, flow, 1000)
|
||||
}
|
||||
assertSyntheticDebt(t, pacer, flows, true)
|
||||
pacer.setKbps(test.kbps)
|
||||
deliveries := runSyntheticPacer(pacer, resumed, resumed.Add(10*time.Second), flows, next)
|
||||
if total := syntheticDeliveryBytes(deliveries); total < test.minimumBytes {
|
||||
t.Fatalf("%s post-stall delivery bytes = %d, want at least %d", test.name, total, test.minimumBytes)
|
||||
}
|
||||
assertSyntheticFairness(t, deliveries, flows)
|
||||
assertSyntheticCap(t, deliveries, test.bytesPerSecond)
|
||||
assertSyntheticDebt(t, pacer, flows, false)
|
||||
t.Logf("%s eight-flow debt repaid: bytes=%d cap=%d", test.name, syntheticDeliveryBytes(deliveries), test.bytesPerSecond*5*105/100)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSyntheticDebt(t *testing.T, pacer *fairPacer, flows []string, wantDebt bool) {
|
||||
t.Helper()
|
||||
pacer.mu.Lock()
|
||||
defer pacer.mu.Unlock()
|
||||
for _, flow := range flows {
|
||||
debt := pacer.flows[flow].debt
|
||||
if wantDebt && (debt <= 0 || debt > nativeApolloVideoQueueLatency-fairPacerMaximumCatchup) {
|
||||
t.Fatalf("flow %s active debt = %s, want bounded nonzero debt", flow, debt)
|
||||
}
|
||||
if !wantDebt && debt != 0 {
|
||||
t.Fatalf("flow %s debt = %s after repayment, want zero", flow, debt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runSyntheticPacer(pacer *fairPacer, start, end time.Time, flows []string, next map[string]time.Time) []syntheticPacerDelivery {
|
||||
const packetBytes = 1000
|
||||
for _, flow := range flows {
|
||||
if next[flow].IsZero() {
|
||||
next[flow] = pacer.reserveAt(start, flow, packetBytes)
|
||||
}
|
||||
}
|
||||
var deliveries []syntheticPacerDelivery
|
||||
for {
|
||||
flow := ""
|
||||
at := end.Add(time.Nanosecond)
|
||||
for _, candidate := range flows {
|
||||
if next[candidate].Before(at) {
|
||||
flow, at = candidate, next[candidate]
|
||||
}
|
||||
}
|
||||
if at.After(end) {
|
||||
return deliveries
|
||||
}
|
||||
deliveries = append(deliveries, syntheticPacerDelivery{at: at, flow: flow, bytes: packetBytes})
|
||||
next[flow] = pacer.reserveAt(at, flow, packetBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func runSyntheticPacerWithStall(pacer *fairPacer, start, end time.Time, flows []string, next map[string]time.Time, stallAt time.Time, stall time.Duration) []syntheticPacerDelivery {
|
||||
const packetBytes = 1000
|
||||
for _, flow := range flows {
|
||||
if next[flow].IsZero() {
|
||||
next[flow] = pacer.reserveAt(start, flow, packetBytes)
|
||||
}
|
||||
}
|
||||
now := start
|
||||
stalled := false
|
||||
var deliveries []syntheticPacerDelivery
|
||||
for {
|
||||
flow := ""
|
||||
target := end.Add(time.Nanosecond)
|
||||
for _, candidate := range flows {
|
||||
if next[candidate].Before(target) {
|
||||
flow, target = candidate, next[candidate]
|
||||
}
|
||||
}
|
||||
if target.After(end) {
|
||||
return deliveries
|
||||
}
|
||||
if !stalled && !target.Before(stallAt) {
|
||||
now = stallAt.Add(stall)
|
||||
stalled = true
|
||||
}
|
||||
if now.Before(target) {
|
||||
now = target
|
||||
}
|
||||
if now.After(end) {
|
||||
return deliveries
|
||||
}
|
||||
deliveries = append(deliveries, syntheticPacerDelivery{at: now, flow: flow, bytes: packetBytes})
|
||||
next[flow] = pacer.reserveAt(now, flow, packetBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func syntheticDeliveryBytes(deliveries []syntheticPacerDelivery) int64 {
|
||||
var total int64
|
||||
for _, delivery := range deliveries {
|
||||
total += delivery.bytes
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func assertSyntheticFairness(t *testing.T, deliveries []syntheticPacerDelivery, flows []string) {
|
||||
t.Helper()
|
||||
counts := make(map[string]int64, len(flows))
|
||||
for _, delivery := range deliveries {
|
||||
counts[delivery.flow] += delivery.bytes
|
||||
}
|
||||
total := int64(0)
|
||||
for _, flow := range flows {
|
||||
total += counts[flow]
|
||||
}
|
||||
target := total / int64(len(flows))
|
||||
for _, flow := range flows {
|
||||
delta := counts[flow] - target
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
if target == 0 || float64(delta)/float64(target) > 0.10 {
|
||||
t.Fatalf("flow %s share=%d target=%d", flow, counts[flow], target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertSyntheticCap(t *testing.T, deliveries []syntheticPacerDelivery, bytesPerSecond int64) {
|
||||
t.Helper()
|
||||
sort.Slice(deliveries, func(first, second int) bool { return deliveries[first].at.Before(deliveries[second].at) })
|
||||
for first, total, last := 0, int64(0), 0; first < len(deliveries); first++ {
|
||||
for last < len(deliveries) && deliveries[last].at.Sub(deliveries[first].at) <= 5*time.Second {
|
||||
total += deliveries[last].bytes
|
||||
last++
|
||||
}
|
||||
if total > bytesPerSecond*5*105/100 {
|
||||
t.Fatalf("five-second egress=%d exceeds cap=%d", total, bytesPerSecond*5)
|
||||
}
|
||||
total -= deliveries[first].bytes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package gateway
|
||||
|
||||
import "encoding/binary"
|
||||
|
||||
type FeedbackKind uint8
|
||||
|
||||
const (
|
||||
FeedbackIDR FeedbackKind = iota + 1
|
||||
FeedbackFEC
|
||||
FeedbackTerminalReceipt
|
||||
)
|
||||
|
||||
const (
|
||||
gatewayFeedbackHeaderSize = 8
|
||||
gatewayFeedbackClient = 0
|
||||
gatewayFeedbackGateway = 1
|
||||
gatewayFeedbackIDR = 1
|
||||
gatewayFeedbackFEC = 2
|
||||
gatewayFeedbackTerminalAck = 3
|
||||
gatewayFeedbackTerminated = 0x10
|
||||
gatewayFeedbackRumble = 0x11
|
||||
gatewayFeedbackHDR = 0x12
|
||||
gatewayFeedbackDisconnected = 0x13
|
||||
)
|
||||
|
||||
type gatewayFeedbackMessage struct {
|
||||
direction byte
|
||||
kind byte
|
||||
payload []byte
|
||||
}
|
||||
|
||||
func EncodeProviderEvent(event ProviderEvent) ([]byte, error) {
|
||||
switch event.Kind {
|
||||
case ProviderEventTerminated:
|
||||
if len(event.Payload) != 4 {
|
||||
return nil, ErrProviderMalformed
|
||||
}
|
||||
return encodeGatewayFeedback(gatewayFeedbackGateway, gatewayFeedbackTerminated, event.Payload)
|
||||
case ProviderEventRumble:
|
||||
if len(event.Payload) != 5 || event.Payload[0] > 15 {
|
||||
return nil, ErrProviderMalformed
|
||||
}
|
||||
return encodeGatewayFeedback(gatewayFeedbackGateway, gatewayFeedbackRumble, event.Payload)
|
||||
case ProviderEventHDR:
|
||||
if len(event.Payload) != 1 || event.Payload[0] > 1 {
|
||||
return nil, ErrProviderMalformed
|
||||
}
|
||||
return encodeGatewayFeedback(gatewayFeedbackGateway, gatewayFeedbackHDR, event.Payload)
|
||||
case ProviderEventDisconnected:
|
||||
if len(event.Payload) != 0 {
|
||||
return nil, ErrProviderMalformed
|
||||
}
|
||||
return encodeGatewayFeedback(gatewayFeedbackGateway, gatewayFeedbackDisconnected, nil)
|
||||
default:
|
||||
return nil, ErrProviderMalformed
|
||||
}
|
||||
}
|
||||
|
||||
func EncodeClientFeedback(feedback Feedback) ([]byte, error) {
|
||||
var kind byte
|
||||
switch feedback.Kind {
|
||||
case FeedbackIDR:
|
||||
kind = gatewayFeedbackIDR
|
||||
if len(feedback.Payload) != 0 {
|
||||
return nil, ErrProviderMalformed
|
||||
}
|
||||
case FeedbackFEC:
|
||||
kind = gatewayFeedbackFEC
|
||||
if !validGatewayFECStatus(feedback.Payload) {
|
||||
return nil, ErrProviderMalformed
|
||||
}
|
||||
case FeedbackTerminalReceipt:
|
||||
kind = gatewayFeedbackTerminalAck
|
||||
if len(feedback.Payload) != 0 {
|
||||
return nil, ErrProviderMalformed
|
||||
}
|
||||
default:
|
||||
return nil, ErrProviderMalformed
|
||||
}
|
||||
return encodeGatewayFeedback(gatewayFeedbackClient, kind, feedback.Payload)
|
||||
}
|
||||
|
||||
func DecodeClientFeedback(data []byte) (Feedback, error) {
|
||||
message, err := decodeGatewayFeedback(data)
|
||||
if err != nil || message.direction != gatewayFeedbackClient {
|
||||
return Feedback{}, ErrProviderMalformed
|
||||
}
|
||||
switch message.kind {
|
||||
case gatewayFeedbackIDR:
|
||||
if len(message.payload) != 0 {
|
||||
return Feedback{}, ErrProviderMalformed
|
||||
}
|
||||
return Feedback{Kind: FeedbackIDR}, nil
|
||||
case gatewayFeedbackFEC:
|
||||
if !validGatewayFECStatus(message.payload) {
|
||||
return Feedback{}, ErrProviderMalformed
|
||||
}
|
||||
return Feedback{Kind: FeedbackFEC, Payload: message.payload}, nil
|
||||
case gatewayFeedbackTerminalAck:
|
||||
if len(message.payload) != 0 {
|
||||
return Feedback{}, ErrProviderMalformed
|
||||
}
|
||||
return Feedback{Kind: FeedbackTerminalReceipt}, nil
|
||||
default:
|
||||
return Feedback{}, ErrProviderMalformed
|
||||
}
|
||||
}
|
||||
|
||||
func validGatewayFECStatus(payload []byte) bool {
|
||||
if len(payload) != 21 {
|
||||
return false
|
||||
}
|
||||
totalData := binary.BigEndian.Uint16(payload[10:12])
|
||||
totalParity := binary.BigEndian.Uint16(payload[12:14])
|
||||
receivedData := binary.BigEndian.Uint16(payload[14:16])
|
||||
receivedParity := binary.BigEndian.Uint16(payload[16:18])
|
||||
return totalData > 0 && receivedData <= totalData && receivedParity <= totalParity && payload[18] <= 100 && payload[20] > 0 && payload[19] < payload[20]
|
||||
}
|
||||
|
||||
func DecodeProviderEvent(data []byte) (ProviderEvent, error) {
|
||||
message, err := decodeGatewayFeedback(data)
|
||||
if err != nil || message.direction != gatewayFeedbackGateway {
|
||||
return ProviderEvent{}, ErrProviderMalformed
|
||||
}
|
||||
switch message.kind {
|
||||
case gatewayFeedbackTerminated:
|
||||
if len(message.payload) != 4 {
|
||||
return ProviderEvent{}, ErrProviderMalformed
|
||||
}
|
||||
return ProviderEvent{Kind: ProviderEventTerminated, Payload: message.payload}, nil
|
||||
case gatewayFeedbackRumble:
|
||||
if len(message.payload) != 5 || message.payload[0] > 15 {
|
||||
return ProviderEvent{}, ErrProviderMalformed
|
||||
}
|
||||
return ProviderEvent{Kind: ProviderEventRumble, Payload: message.payload}, nil
|
||||
case gatewayFeedbackHDR:
|
||||
if len(message.payload) != 1 || message.payload[0] > 1 {
|
||||
return ProviderEvent{}, ErrProviderMalformed
|
||||
}
|
||||
return ProviderEvent{Kind: ProviderEventHDR, Payload: message.payload}, nil
|
||||
case gatewayFeedbackDisconnected:
|
||||
if len(message.payload) != 0 {
|
||||
return ProviderEvent{}, ErrProviderMalformed
|
||||
}
|
||||
return ProviderEvent{Kind: ProviderEventDisconnected}, nil
|
||||
default:
|
||||
return ProviderEvent{}, ErrProviderMalformed
|
||||
}
|
||||
}
|
||||
|
||||
func encodeGatewayFeedback(direction, kind byte, payload []byte) ([]byte, error) {
|
||||
if len(payload) > 1016 {
|
||||
return nil, ErrProviderMalformed
|
||||
}
|
||||
encoded := make([]byte, gatewayFeedbackHeaderSize+len(payload))
|
||||
copy(encoded, "VGF1")
|
||||
encoded[4], encoded[5] = direction, kind
|
||||
binary.BigEndian.PutUint16(encoded[6:8], uint16(len(payload)))
|
||||
copy(encoded[8:], payload)
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
func decodeGatewayFeedback(data []byte) (gatewayFeedbackMessage, error) {
|
||||
if len(data) < gatewayFeedbackHeaderSize || len(data) > 1024 || string(data[:4]) != "VGF1" || len(data) != gatewayFeedbackHeaderSize+int(binary.BigEndian.Uint16(data[6:8])) {
|
||||
return gatewayFeedbackMessage{}, ErrProviderMalformed
|
||||
}
|
||||
message := gatewayFeedbackMessage{direction: data[4], kind: data[5], payload: append([]byte(nil), data[8:]...)}
|
||||
if message.direction != gatewayFeedbackClient && message.direction != gatewayFeedbackGateway {
|
||||
return gatewayFeedbackMessage{}, ErrProviderMalformed
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
frameV1HeaderSize = 21
|
||||
frameV2HeaderSize = 23
|
||||
frameV1PayloadSize = 1179
|
||||
frameV2PayloadSize = 1177
|
||||
maxV1FragmentCount = 16
|
||||
maxV2FragmentCount = 891
|
||||
maxCompleteFrameBytes = 1 << 20
|
||||
maxFrameSize = 1 << 16
|
||||
frameHeaderSize = frameV2HeaderSize
|
||||
)
|
||||
|
||||
const (
|
||||
ChannelControl = byte(1)
|
||||
ChannelAck = byte(2)
|
||||
ChannelText = byte(3)
|
||||
ChannelVideo = byte(10)
|
||||
ChannelAudio = byte(11)
|
||||
ChannelInput = byte(12)
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFrameTruncated = errors.New("gateway frame truncated")
|
||||
ErrFrameMagic = errors.New("gateway frame magic mismatch")
|
||||
ErrFrameVersion = errors.New("gateway frame version unsupported")
|
||||
ErrFrameChannel = errors.New("gateway frame channel unsupported")
|
||||
ErrFrameFlags = errors.New("gateway frame flags unsupported")
|
||||
ErrFrameFragment = errors.New("gateway frame fragment invalid")
|
||||
ErrFrameLength = errors.New("gateway frame length mismatch")
|
||||
ErrFramePayloadLimit = errors.New("gateway frame payload exceeds channel limit")
|
||||
ErrFrameSize = errors.New("gateway frame exceeds size limit")
|
||||
ErrFrameFragmentedLimit = errors.New("gateway payload requires too many fragments")
|
||||
)
|
||||
|
||||
type Frame struct {
|
||||
Version byte
|
||||
Channel byte
|
||||
Flags byte
|
||||
Sequence uint32
|
||||
TimestampMS uint64
|
||||
FragmentIndex uint16
|
||||
FragmentCount uint16
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
func channelLimit(version, channel byte) (int, bool) {
|
||||
if version == 2 {
|
||||
switch channel {
|
||||
case ChannelVideo, ChannelAudio:
|
||||
return frameV2PayloadSize, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
switch channel {
|
||||
case ChannelControl:
|
||||
return 1024, true
|
||||
case ChannelAck:
|
||||
return 2048, true
|
||||
case ChannelText:
|
||||
return 65515, true
|
||||
case ChannelVideo, ChannelAudio, ChannelInput:
|
||||
return frameV1PayloadSize, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func EncodeFrame(frame Frame) ([]byte, error) {
|
||||
version := frame.Version
|
||||
if version == 0 {
|
||||
version = 1
|
||||
}
|
||||
if version != 1 && version != 2 {
|
||||
return nil, ErrFrameVersion
|
||||
}
|
||||
limit, ok := channelLimit(version, frame.Channel)
|
||||
if !ok {
|
||||
return nil, ErrFrameChannel
|
||||
}
|
||||
if frame.Flags != 0 {
|
||||
return nil, ErrFrameFlags
|
||||
}
|
||||
maxFragments := uint16(maxV1FragmentCount)
|
||||
headerSize := frameV1HeaderSize
|
||||
if version == 2 {
|
||||
maxFragments = maxV2FragmentCount
|
||||
headerSize = frameV2HeaderSize
|
||||
}
|
||||
if frame.FragmentCount == 0 || frame.FragmentCount > maxFragments || frame.FragmentIndex >= frame.FragmentCount {
|
||||
return nil, ErrFrameFragment
|
||||
}
|
||||
if len(frame.Payload) > limit {
|
||||
return nil, ErrFramePayloadLimit
|
||||
}
|
||||
if len(frame.Payload) > 1<<16-headerSize {
|
||||
return nil, ErrFrameSize
|
||||
}
|
||||
encoded := make([]byte, headerSize+len(frame.Payload))
|
||||
encoded[0], encoded[1], encoded[2], encoded[3], encoded[4] = 'V', 'D', version, frame.Channel, frame.Flags
|
||||
binary.BigEndian.PutUint32(encoded[5:9], frame.Sequence)
|
||||
binary.BigEndian.PutUint64(encoded[9:17], frame.TimestampMS)
|
||||
if version == 1 {
|
||||
encoded[17], encoded[18] = byte(frame.FragmentIndex), byte(frame.FragmentCount)
|
||||
binary.BigEndian.PutUint16(encoded[19:21], uint16(len(frame.Payload)))
|
||||
} else {
|
||||
binary.BigEndian.PutUint16(encoded[17:19], frame.FragmentIndex)
|
||||
binary.BigEndian.PutUint16(encoded[19:21], frame.FragmentCount)
|
||||
binary.BigEndian.PutUint16(encoded[21:23], uint16(len(frame.Payload)))
|
||||
}
|
||||
copy(encoded[headerSize:], frame.Payload)
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
func DecodeFrame(raw []byte) (Frame, error) {
|
||||
if len(raw) < 3 {
|
||||
return Frame{}, ErrFrameTruncated
|
||||
}
|
||||
if raw[0] != 'V' || raw[1] != 'D' {
|
||||
return Frame{}, ErrFrameMagic
|
||||
}
|
||||
version := raw[2]
|
||||
if version != 1 && version != 2 {
|
||||
return Frame{}, ErrFrameVersion
|
||||
}
|
||||
headerSize := frameV1HeaderSize
|
||||
maxFragments := uint16(maxV1FragmentCount)
|
||||
if version == 2 {
|
||||
headerSize = frameV2HeaderSize
|
||||
maxFragments = maxV2FragmentCount
|
||||
}
|
||||
if len(raw) < headerSize {
|
||||
return Frame{}, ErrFrameTruncated
|
||||
}
|
||||
if version == 1 && len(raw) > 1<<16 || version == 2 && len(raw) > 1200 {
|
||||
return Frame{}, ErrFrameSize
|
||||
}
|
||||
limit, ok := channelLimit(version, raw[3])
|
||||
if !ok {
|
||||
return Frame{}, ErrFrameChannel
|
||||
}
|
||||
if raw[4] != 0 {
|
||||
return Frame{}, ErrFrameFlags
|
||||
}
|
||||
var fragmentIndex, fragmentCount uint16
|
||||
payloadOffset := 19
|
||||
if version == 1 {
|
||||
fragmentIndex, fragmentCount = uint16(raw[17]), uint16(raw[18])
|
||||
} else {
|
||||
fragmentIndex = binary.BigEndian.Uint16(raw[17:19])
|
||||
fragmentCount = binary.BigEndian.Uint16(raw[19:21])
|
||||
payloadOffset = 21
|
||||
}
|
||||
if fragmentCount == 0 || fragmentCount > maxFragments || fragmentIndex >= fragmentCount {
|
||||
return Frame{}, ErrFrameFragment
|
||||
}
|
||||
payloadLength := int(binary.BigEndian.Uint16(raw[payloadOffset : payloadOffset+2]))
|
||||
if payloadLength > limit {
|
||||
return Frame{}, ErrFramePayloadLimit
|
||||
}
|
||||
if len(raw) != headerSize+payloadLength {
|
||||
return Frame{}, ErrFrameLength
|
||||
}
|
||||
return Frame{
|
||||
Version: version,
|
||||
Channel: raw[3],
|
||||
Flags: raw[4],
|
||||
Sequence: binary.BigEndian.Uint32(raw[5:9]),
|
||||
TimestampMS: binary.BigEndian.Uint64(raw[9:17]),
|
||||
FragmentIndex: fragmentIndex,
|
||||
FragmentCount: fragmentCount,
|
||||
Payload: append([]byte(nil), raw[headerSize:]...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func FragmentPayload(channel byte, sequence uint32, timestampMS uint64, payload []byte) ([]Frame, error) {
|
||||
version := byte(1)
|
||||
limit := frameV1PayloadSize
|
||||
maxFragments := maxV1FragmentCount
|
||||
if channel == ChannelVideo || channel == ChannelAudio {
|
||||
version = 2
|
||||
limit = frameV2PayloadSize
|
||||
maxFragments = maxV2FragmentCount
|
||||
}
|
||||
if _, ok := channelLimit(version, channel); !ok {
|
||||
return nil, ErrFrameChannel
|
||||
}
|
||||
if len(payload) > maxCompleteFrameBytes {
|
||||
return nil, ErrFrameFragmentedLimit
|
||||
}
|
||||
count := (len(payload) + limit - 1) / limit
|
||||
if count == 0 {
|
||||
count = 1
|
||||
}
|
||||
if count > maxFragments {
|
||||
return nil, ErrFrameFragmentedLimit
|
||||
}
|
||||
frames := make([]Frame, 0, count)
|
||||
for index := 0; index < count; index++ {
|
||||
start := index * limit
|
||||
end := start + limit
|
||||
if end > len(payload) {
|
||||
end = len(payload)
|
||||
}
|
||||
frames = append(frames, Frame{
|
||||
Version: version,
|
||||
Channel: channel,
|
||||
Sequence: sequence,
|
||||
TimestampMS: timestampMS,
|
||||
FragmentIndex: uint16(index),
|
||||
FragmentCount: uint16(count),
|
||||
Payload: append([]byte(nil), payload[start:end]...),
|
||||
})
|
||||
}
|
||||
return frames, nil
|
||||
}
|
||||
|
||||
func ValidateFrame(raw []byte) error {
|
||||
_, err := DecodeFrame(raw)
|
||||
return err
|
||||
}
|
||||
|
||||
func FrameError(channel byte, err error) error {
|
||||
return fmt.Errorf("channel %d: %w", channel, err)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var ErrInputMalformed = errors.New("input event malformed")
|
||||
|
||||
const (
|
||||
gatewayInputHeaderSize = 6
|
||||
gatewayInputKeyboard = 1
|
||||
gatewayInputMouse = 2
|
||||
gatewayInputRelative = 3
|
||||
gatewayInputUTF8 = 4
|
||||
gatewayInputController = 5
|
||||
)
|
||||
|
||||
func EncodeInputEvent(event InputEvent) ([]byte, error) {
|
||||
switch event.Device {
|
||||
case "keyboard":
|
||||
if event.Code < 1 || event.Code > 0xffff || len(event.Payload) > 1 {
|
||||
return nil, ErrInputMalformed
|
||||
}
|
||||
encoded := make([]byte, gatewayInputHeaderSize+4)
|
||||
copy(encoded, "VGI1")
|
||||
encoded[4], encoded[5], encoded[6] = gatewayInputKeyboard, 4, 0
|
||||
if event.Pressed {
|
||||
encoded[6] = 1
|
||||
}
|
||||
if len(event.Payload) == 1 {
|
||||
encoded[7] = event.Payload[0]
|
||||
}
|
||||
binary.BigEndian.PutUint16(encoded[8:10], uint16(event.Code))
|
||||
return encoded, nil
|
||||
case "mouse-button":
|
||||
if event.Code < 1 || event.Code > 5 || len(event.Payload) != 0 {
|
||||
return nil, ErrInputMalformed
|
||||
}
|
||||
encoded := make([]byte, gatewayInputHeaderSize+3)
|
||||
copy(encoded, "VGI1")
|
||||
encoded[4], encoded[5], encoded[7] = gatewayInputMouse, 3, byte(event.Code)
|
||||
if event.Pressed {
|
||||
encoded[6] = 1
|
||||
}
|
||||
return encoded, nil
|
||||
case "mouse-relative":
|
||||
if event.Pressed || event.Code != 0 || len(event.Payload) != 4 {
|
||||
return nil, ErrInputMalformed
|
||||
}
|
||||
encoded := make([]byte, gatewayInputHeaderSize+4)
|
||||
copy(encoded, "VGI1")
|
||||
encoded[4], encoded[5] = gatewayInputRelative, 4
|
||||
copy(encoded[6:], event.Payload)
|
||||
return encoded, nil
|
||||
case "utf8":
|
||||
if event.Pressed || event.Code != 0 || len(event.Payload) == 0 || len(event.Payload) > utf8.UTFMax || !utf8.Valid(event.Payload) || utf8.RuneCount(event.Payload) != 1 {
|
||||
return nil, ErrInputMalformed
|
||||
}
|
||||
encoded := make([]byte, gatewayInputHeaderSize+len(event.Payload))
|
||||
copy(encoded, "VGI1")
|
||||
encoded[4], encoded[5] = gatewayInputUTF8, byte(len(event.Payload))
|
||||
copy(encoded[6:], event.Payload)
|
||||
return encoded, nil
|
||||
case "controller":
|
||||
if event.Code < 0 || event.Code > 15 || len(event.Payload) != 16 {
|
||||
return nil, ErrInputMalformed
|
||||
}
|
||||
active := binary.BigEndian.Uint16(event.Payload[:2])
|
||||
if (!event.Pressed && anyNonzero(event.Payload)) || (event.Pressed && active == 0) {
|
||||
return nil, ErrInputMalformed
|
||||
}
|
||||
encoded := make([]byte, gatewayInputHeaderSize+17)
|
||||
copy(encoded, "VGI1")
|
||||
encoded[4], encoded[5], encoded[6] = gatewayInputController, 17, byte(event.Code)
|
||||
copy(encoded[7:], event.Payload)
|
||||
return encoded, nil
|
||||
default:
|
||||
return nil, ErrInputMalformed
|
||||
}
|
||||
}
|
||||
|
||||
func DecodeInputEvent(data []byte) (InputEvent, error) {
|
||||
if len(data) < gatewayInputHeaderSize || len(data) > 1179 || string(data[:4]) != "VGI1" || len(data) != gatewayInputHeaderSize+int(data[5]) {
|
||||
return InputEvent{}, ErrInputMalformed
|
||||
}
|
||||
kind, body := data[4], data[gatewayInputHeaderSize:]
|
||||
switch kind {
|
||||
case gatewayInputKeyboard:
|
||||
if len(body) != 4 || body[0] > 1 || binary.BigEndian.Uint16(body[2:4]) == 0 {
|
||||
return InputEvent{}, ErrInputMalformed
|
||||
}
|
||||
return InputEvent{Device: "keyboard", Code: int32(binary.BigEndian.Uint16(body[2:4])), Pressed: body[0] == 1, Payload: []byte{body[1]}}, nil
|
||||
case gatewayInputMouse:
|
||||
if len(body) != 3 || body[0] > 1 || body[1] < 1 || body[1] > 5 || body[2] != 0 {
|
||||
return InputEvent{}, ErrInputMalformed
|
||||
}
|
||||
return InputEvent{Device: "mouse-button", Code: int32(body[1]), Pressed: body[0] == 1}, nil
|
||||
case gatewayInputRelative:
|
||||
if len(body) != 4 {
|
||||
return InputEvent{}, ErrInputMalformed
|
||||
}
|
||||
return InputEvent{Device: "mouse-relative", Payload: append([]byte(nil), body...)}, nil
|
||||
case gatewayInputUTF8:
|
||||
if len(body) == 0 || len(body) > utf8.UTFMax || !utf8.Valid(body) || utf8.RuneCount(body) != 1 {
|
||||
return InputEvent{}, ErrInputMalformed
|
||||
}
|
||||
return InputEvent{Device: "utf8", Payload: append([]byte(nil), body...)}, nil
|
||||
case gatewayInputController:
|
||||
if len(body) != 17 || body[0] > 15 {
|
||||
return InputEvent{}, ErrInputMalformed
|
||||
}
|
||||
payload := append([]byte(nil), body[1:]...)
|
||||
active := binary.BigEndian.Uint16(payload[:2])
|
||||
if active == 0 && anyNonzero(payload[2:]) {
|
||||
return InputEvent{}, ErrInputMalformed
|
||||
}
|
||||
return InputEvent{Device: "controller", Code: int32(body[0]), Pressed: active != 0, Payload: payload}, nil
|
||||
default:
|
||||
return InputEvent{}, ErrInputMalformed
|
||||
}
|
||||
}
|
||||
|
||||
func anyNonzero(data []byte) bool {
|
||||
for _, value := range data {
|
||||
if value != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"debug/buildinfo"
|
||||
"debug/elf"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCIActionReferencesAreImmutable(t *testing.T) {
|
||||
workflow, err := os.Open(filepath.Join("..", ".gitea", "workflows", "verify.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer workflow.Close()
|
||||
immutable := regexp.MustCompile(`@[0-9a-f]{40}(?:\s+#.*)?$`)
|
||||
scanner := bufio.NewScanner(workflow)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if strings.HasPrefix(line, "- uses:") && !immutable.MatchString(line) {
|
||||
t.Errorf("mutable CI action reference: %s", line)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayLinuxArtifactsAreReproduciblePureGoELF(t *testing.T) {
|
||||
first, second := t.TempDir(), t.TempDir()
|
||||
for _, output := range []string{first, second} {
|
||||
command := exec.Command("make", "-C", "..", "gateway-linux", "DIST_DIR="+output)
|
||||
command.Env = append(os.Environ(), "GOCACHE="+filepath.Join(t.TempDir(), "go-cache"))
|
||||
if result, err := command.CombinedOutput(); err != nil {
|
||||
t.Fatalf("gateway-linux: %v\n%s", err, result)
|
||||
}
|
||||
}
|
||||
|
||||
for _, architecture := range []struct {
|
||||
name string
|
||||
machine elf.Machine
|
||||
}{
|
||||
{name: "amd64", machine: elf.EM_X86_64},
|
||||
{name: "arm64", machine: elf.EM_AARCH64},
|
||||
} {
|
||||
firstPath := filepath.Join(first, "verse-gateway-linux-"+architecture.name)
|
||||
secondPath := filepath.Join(second, "verse-gateway-linux-"+architecture.name)
|
||||
firstBytes, err := os.ReadFile(firstPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondBytes, err := os.ReadFile(secondPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(firstBytes, secondBytes) {
|
||||
t.Fatalf("linux/%s gateway build is not byte reproducible", architecture.name)
|
||||
}
|
||||
executable, err := elf.Open(firstPath)
|
||||
if err != nil {
|
||||
t.Fatalf("linux/%s ELF: %v", architecture.name, err)
|
||||
}
|
||||
if executable.FileHeader.Machine != architecture.machine {
|
||||
_ = executable.Close()
|
||||
t.Fatalf("linux/%s machine = %s", architecture.name, executable.FileHeader.Machine)
|
||||
}
|
||||
if err := executable.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := buildinfo.ReadFile(firstPath)
|
||||
if err != nil {
|
||||
t.Fatalf("linux/%s Go build info: %v", architecture.name, err)
|
||||
}
|
||||
settings := make(map[string]string, len(info.Settings))
|
||||
for _, setting := range info.Settings {
|
||||
settings[setting.Key] = setting.Value
|
||||
}
|
||||
if settings["GOOS"] != "linux" || settings["GOARCH"] != architecture.name || settings["CGO_ENABLED"] != "0" {
|
||||
t.Fatalf("linux/%s build settings = %#v", architecture.name, settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderProfileApollo = "apollo"
|
||||
ProviderStateStarting = "starting"
|
||||
ProviderStateReady = "ready"
|
||||
ProviderStateDisconnected = "disconnected"
|
||||
ProviderStateTerminating = "terminating"
|
||||
ProviderStateTerminated = "terminated"
|
||||
ProviderStateCleanup = "cleanup_pending"
|
||||
ProviderStateFailed = "failed"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrProviderIdentity = errors.New("provider identity rejected")
|
||||
ErrProviderMalformed = errors.New("provider response malformed")
|
||||
ErrProviderTimeout = errors.New("provider operation timed out")
|
||||
ErrProviderDisconnected = errors.New("provider disconnected")
|
||||
ErrProviderCleanup = errors.New("provider cleanup pending")
|
||||
ErrProviderTerminated = errors.New("provider session terminated")
|
||||
)
|
||||
|
||||
type ProviderIdentity struct {
|
||||
UniqueID string
|
||||
Fingerprint string
|
||||
NotBefore time.Time
|
||||
NotAfter time.Time
|
||||
}
|
||||
|
||||
func (i ProviderIdentity) Key() string {
|
||||
return i.UniqueID + "#" + i.Fingerprint
|
||||
}
|
||||
|
||||
func providerIdentityFromKey(value string) (ProviderIdentity, bool) {
|
||||
uniqueID, fingerprint, ok := strings.Cut(strings.TrimSpace(value), "#")
|
||||
if !ok || uniqueID == "" || fingerprint == "" || strings.Contains(fingerprint, "#") || len(uniqueID) > 128 || len(fingerprint) > 256 {
|
||||
return ProviderIdentity{}, false
|
||||
}
|
||||
return ProviderIdentity{UniqueID: uniqueID, Fingerprint: fingerprint}, true
|
||||
}
|
||||
|
||||
func (i ProviderIdentity) Validate(now time.Time, expected ProviderIdentity) error {
|
||||
if i.UniqueID == "" || expected.UniqueID == "" || i.UniqueID != expected.UniqueID ||
|
||||
(i.Fingerprint != "" && i.Fingerprint != expected.Fingerprint) {
|
||||
return ErrProviderIdentity
|
||||
}
|
||||
if !i.NotBefore.IsZero() && now.Before(i.NotBefore) {
|
||||
return ErrProviderIdentity
|
||||
}
|
||||
if !i.NotAfter.IsZero() && !now.Before(i.NotAfter) {
|
||||
return ErrProviderIdentity
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ManagementInfo struct {
|
||||
Identity ProviderIdentity
|
||||
Name string
|
||||
ServerCodecModeSupport uint32
|
||||
MaxLumaPixelsHEVC uint64
|
||||
HasServerCodecModeSupport bool
|
||||
HasMaxLumaPixelsHEVC bool
|
||||
}
|
||||
|
||||
func ParseManagementXML(data []byte) (ManagementInfo, error) {
|
||||
if len(data) == 0 || len(data) > 64*1024 {
|
||||
return ManagementInfo{}, ErrProviderMalformed
|
||||
}
|
||||
var document struct {
|
||||
XMLName xml.Name `xml:"root"`
|
||||
UniqueID string `xml:"uniqueid"`
|
||||
LegacyID string `xml:"unique_id"`
|
||||
Fingerprint string `xml:"fingerprint"`
|
||||
NotBefore string `xml:"not_before"`
|
||||
NotAfter string `xml:"not_after"`
|
||||
Name string `xml:"name"`
|
||||
CodecModes string `xml:"ServerCodecModeSupport"`
|
||||
MaxHEVCLuma string `xml:"MaxLumaPixelsHEVC"`
|
||||
}
|
||||
decoder := xml.NewDecoder(strings.NewReader(string(data)))
|
||||
decoder.Strict = true
|
||||
if err := decoder.Decode(&document); err != nil {
|
||||
return ManagementInfo{}, fmt.Errorf("%w: %v", ErrProviderMalformed, err)
|
||||
}
|
||||
if document.UniqueID == "" {
|
||||
document.UniqueID = document.LegacyID
|
||||
}
|
||||
identity := ProviderIdentity{UniqueID: document.UniqueID, Fingerprint: document.Fingerprint}
|
||||
var err error
|
||||
if document.NotBefore != "" {
|
||||
identity.NotBefore, err = time.Parse(time.RFC3339Nano, document.NotBefore)
|
||||
if err != nil {
|
||||
return ManagementInfo{}, ErrProviderMalformed
|
||||
}
|
||||
}
|
||||
if document.NotAfter != "" {
|
||||
identity.NotAfter, err = time.Parse(time.RFC3339Nano, document.NotAfter)
|
||||
if err != nil {
|
||||
return ManagementInfo{}, ErrProviderMalformed
|
||||
}
|
||||
}
|
||||
if identity.UniqueID == "" || len(identity.UniqueID) > 128 || len(identity.Fingerprint) > 256 {
|
||||
return ManagementInfo{}, ErrProviderMalformed
|
||||
}
|
||||
info := ManagementInfo{Identity: identity, Name: document.Name}
|
||||
if document.CodecModes != "" {
|
||||
value, parseErr := strconv.ParseUint(document.CodecModes, 10, 32)
|
||||
if parseErr != nil {
|
||||
return ManagementInfo{}, ErrProviderMalformed
|
||||
}
|
||||
info.ServerCodecModeSupport = uint32(value)
|
||||
info.HasServerCodecModeSupport = true
|
||||
}
|
||||
if document.MaxHEVCLuma != "" {
|
||||
value, parseErr := strconv.ParseUint(document.MaxHEVCLuma, 10, 64)
|
||||
if parseErr != nil {
|
||||
return ManagementInfo{}, ErrProviderMalformed
|
||||
}
|
||||
info.MaxLumaPixelsHEVC = value
|
||||
info.HasMaxLumaPixelsHEVC = true
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
type RTSPResponse struct {
|
||||
StatusCode int
|
||||
Session string
|
||||
Transport string
|
||||
}
|
||||
|
||||
func ParseRTSPResponse(data []byte) (RTSPResponse, error) {
|
||||
if len(data) == 0 || len(data) > 16*1024 {
|
||||
return RTSPResponse{}, ErrProviderMalformed
|
||||
}
|
||||
text := string(data)
|
||||
if !strings.Contains(text, "\r\n") {
|
||||
return RTSPResponse{}, ErrProviderMalformed
|
||||
}
|
||||
lines := strings.Split(text, "\r\n")
|
||||
if len(lines) < 2 {
|
||||
return RTSPResponse{}, ErrProviderMalformed
|
||||
}
|
||||
statusParts := strings.SplitN(lines[0], " ", 3)
|
||||
if len(statusParts) < 2 || statusParts[0] != "RTSP/1.0" {
|
||||
return RTSPResponse{}, ErrProviderMalformed
|
||||
}
|
||||
var response RTSPResponse
|
||||
if _, err := fmt.Sscanf(statusParts[1], "%d", &response.StatusCode); err != nil || response.StatusCode != 200 {
|
||||
return RTSPResponse{}, ErrProviderMalformed
|
||||
}
|
||||
for _, line := range lines[1:] {
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
return RTSPResponse{}, ErrProviderMalformed
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||
case "session":
|
||||
response.Session = strings.TrimSpace(value)
|
||||
case "transport":
|
||||
response.Transport = strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
if response.Session == "" || response.Transport == "" || len(response.Session) > 256 || len(response.Transport) > 1024 {
|
||||
return RTSPResponse{}, ErrProviderMalformed
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
type LaunchRequest struct {
|
||||
SessionID string
|
||||
Capabilities protocol.CapabilityProfile
|
||||
ProviderProfile string
|
||||
ProviderIdentity string
|
||||
ProviderWork protocol.ProviderSessionWork
|
||||
}
|
||||
|
||||
type InputEvent struct {
|
||||
Sequence uint32
|
||||
Device string
|
||||
Code int32
|
||||
Pressed bool
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
type Feedback struct {
|
||||
Sequence uint32
|
||||
Kind FeedbackKind
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
type ProviderEventKind uint8
|
||||
|
||||
const (
|
||||
ProviderEventTerminated ProviderEventKind = iota + 1
|
||||
ProviderEventRumble
|
||||
ProviderEventHDR
|
||||
ProviderEventDisconnected
|
||||
)
|
||||
|
||||
type ProviderEvent struct {
|
||||
Kind ProviderEventKind
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
// ProviderTelemetry holds measured provider-channel state only; it never
|
||||
// contains provider routes, credentials, or payload bytes.
|
||||
type ProviderTelemetry struct {
|
||||
State string
|
||||
ControlRTT time.Duration
|
||||
ControlJitter time.Duration
|
||||
ReliableSent uint64
|
||||
ReliableRetransmits uint64
|
||||
PendingReliable uint64
|
||||
MediaDrops uint64
|
||||
}
|
||||
|
||||
type ProviderMedia struct {
|
||||
Payload []byte
|
||||
ReceivedAt time.Time
|
||||
EnqueuedAt time.Time
|
||||
queueID uint64
|
||||
expiry *time.Timer
|
||||
accounting *providerMediaQueueAccounting
|
||||
}
|
||||
|
||||
type providerMediaQueueAccounting struct {
|
||||
released atomic.Bool
|
||||
bytes int64
|
||||
total *atomic.Int64
|
||||
}
|
||||
|
||||
func (media ProviderMedia) releaseQueue() {
|
||||
if media.accounting != nil && media.accounting.released.CompareAndSwap(false, true) {
|
||||
media.accounting.total.Add(-media.accounting.bytes)
|
||||
}
|
||||
}
|
||||
|
||||
type Provider interface {
|
||||
Start(context.Context, LaunchRequest) (ProviderSession, error)
|
||||
}
|
||||
|
||||
type ProviderSession interface {
|
||||
Ready(context.Context) error
|
||||
Video() <-chan ProviderMedia
|
||||
Audio() <-chan ProviderMedia
|
||||
Events() <-chan ProviderEvent
|
||||
Input(context.Context, InputEvent) error
|
||||
Feedback(context.Context, Feedback) error
|
||||
ReadClipboard(context.Context) (string, error)
|
||||
WriteClipboard(context.Context, string) error
|
||||
Telemetry() ProviderTelemetry
|
||||
ReleaseAll(context.Context) error
|
||||
Terminate(context.Context) error
|
||||
State() protocol.ProviderState
|
||||
}
|
||||
|
||||
type ApolloBackend interface {
|
||||
Management(context.Context, LaunchRequest) ([]byte, error)
|
||||
Setup(context.Context, LaunchRequest, []byte) ([]byte, error)
|
||||
Open(context.Context, LaunchRequest, RTSPResponse) (ProviderSession, error)
|
||||
}
|
||||
|
||||
type ApolloAdapter struct {
|
||||
backend ApolloBackend
|
||||
expected ProviderIdentity
|
||||
now func() time.Time
|
||||
readyTTL time.Duration
|
||||
}
|
||||
|
||||
func NewApolloAdapter(backend ApolloBackend, expected ProviderIdentity) *ApolloAdapter {
|
||||
return &ApolloAdapter{backend: backend, expected: expected, now: time.Now, readyTTL: 2 * time.Second}
|
||||
}
|
||||
|
||||
func (a *ApolloAdapter) Start(ctx context.Context, request LaunchRequest) (ProviderSession, error) {
|
||||
if a == nil || a.backend == nil || request.ProviderProfile != ProviderProfileApollo {
|
||||
return nil, ErrProviderIdentity
|
||||
}
|
||||
management, err := a.backend.Management(ctx, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := ParseManagementXML(management)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expected := a.expected
|
||||
if request.ProviderWork.ProviderIdentity != "" {
|
||||
parsed, ok := providerIdentityFromKey(request.ProviderWork.ProviderIdentity)
|
||||
if !ok {
|
||||
return nil, ErrProviderIdentity
|
||||
}
|
||||
expected = parsed
|
||||
}
|
||||
if err := info.Identity.Validate(a.now(), expected); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if request.ProviderIdentity != "" && info.Identity.UniqueID != expected.UniqueID {
|
||||
return nil, ErrProviderIdentity
|
||||
}
|
||||
rawRTSP, err := a.backend.Setup(ctx, request, management)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rtsp, err := ParseRTSPResponse(rawRTSP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session, err := a.backend.Open(ctx, request, rtsp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
readyCtx, cancel := context.WithTimeout(ctx, a.readyTTL)
|
||||
defer cancel()
|
||||
if err := session.Ready(readyCtx); err != nil {
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), time.Second)
|
||||
cleanupErr := session.Terminate(cleanupCtx)
|
||||
cleanupCancel()
|
||||
if cleanupErr != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrProviderCleanup, err)
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, ErrProviderTimeout
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
type FakeFailure string
|
||||
|
||||
const (
|
||||
FakeFailureNone FakeFailure = ""
|
||||
FakeFailureIdentity FakeFailure = "identity"
|
||||
FakeFailureMalformed FakeFailure = "malformed"
|
||||
FakeFailureReadinessTimeout FakeFailure = "readiness-timeout"
|
||||
FakeFailureProviderDisconnect FakeFailure = "provider-disconnect"
|
||||
FakeFailureTerminationTimeout FakeFailure = "termination-timeout"
|
||||
)
|
||||
|
||||
type FakeApolloConfig struct {
|
||||
Identity ProviderIdentity
|
||||
Failure FakeFailure
|
||||
Video [][]byte
|
||||
Audio [][]byte
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
type FakeApollo struct {
|
||||
config FakeApolloConfig
|
||||
mu sync.Mutex
|
||||
last *fakeSession
|
||||
}
|
||||
|
||||
func NewFakeApollo(config FakeApolloConfig) *FakeApollo {
|
||||
if config.Identity.UniqueID == "" {
|
||||
config.Identity.UniqueID = "apollo-fixture-1"
|
||||
}
|
||||
if config.Identity.Fingerprint == "" {
|
||||
config.Identity.Fingerprint = "sha256:fixture-apollo-1"
|
||||
}
|
||||
if config.Now.IsZero() {
|
||||
config.Now = time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
if len(config.Video) == 0 {
|
||||
config.Video = [][]byte{[]byte{0x00, 0x00, 0x01, 0x65, 0x01, 0x02}}
|
||||
}
|
||||
if len(config.Audio) == 0 {
|
||||
config.Audio = [][]byte{[]byte{0x4f, 0x70, 0x75, 0x73, 0x01}}
|
||||
}
|
||||
return &FakeApollo{config: config}
|
||||
}
|
||||
|
||||
func (f *FakeApollo) Management(context.Context, LaunchRequest) ([]byte, error) {
|
||||
if f.config.Failure == FakeFailureMalformed {
|
||||
return []byte("<root>"), nil
|
||||
}
|
||||
identity := f.config.Identity
|
||||
if f.config.Failure == FakeFailureIdentity {
|
||||
identity.Fingerprint = "sha256:changed-fixture"
|
||||
}
|
||||
return []byte(fmt.Sprintf("<root><unique_id>%s</unique_id><fingerprint>%s</fingerprint><not_before>%s</not_before><not_after>%s</not_after><name>fixture-apollo</name></root>", identity.UniqueID, identity.Fingerprint, f.config.Now.Add(-time.Hour).Format(time.RFC3339), f.config.Now.Add(time.Hour).Format(time.RFC3339))), nil
|
||||
}
|
||||
|
||||
func (f *FakeApollo) Setup(context.Context, LaunchRequest, []byte) ([]byte, error) {
|
||||
if f.config.Failure == FakeFailureMalformed {
|
||||
return []byte("RTSP/1.0 200 OK\r\n\r\n"), nil
|
||||
}
|
||||
return []byte("RTSP/1.0 200 OK\r\nSession: fixture-session\r\nTransport: unicast;server_port=43000\r\n\r\n"), nil
|
||||
}
|
||||
|
||||
func (f *FakeApollo) Open(_ context.Context, request LaunchRequest, _ RTSPResponse) (ProviderSession, error) {
|
||||
session := &fakeSession{
|
||||
failure: f.config.Failure,
|
||||
video: make(chan ProviderMedia, 16),
|
||||
audio: make(chan ProviderMedia, 16),
|
||||
events: make(chan ProviderEvent, 16),
|
||||
clipboardWrites: make(chan string, 1),
|
||||
state: protocol.ProviderState{Version: "1", SessionID: request.SessionID, State: ProviderStateStarting, Channels: []string{"video", "audio", "input", "feedback"}},
|
||||
pressed: make(map[string]struct{}),
|
||||
}
|
||||
for _, payload := range f.config.Video {
|
||||
session.EmitVideo(payload)
|
||||
}
|
||||
for _, payload := range f.config.Audio {
|
||||
session.EmitAudio(payload)
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.last = session
|
||||
f.mu.Unlock()
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (f *FakeApollo) Start(ctx context.Context, request LaunchRequest) (ProviderSession, error) {
|
||||
adapter := NewApolloAdapter(f, f.config.Identity)
|
||||
adapter.now = func() time.Time { return f.config.Now }
|
||||
return adapter.Start(ctx, request)
|
||||
}
|
||||
|
||||
func (f *FakeApollo) LastSession() ProviderSession {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.last
|
||||
}
|
||||
|
||||
func (f *FakeApollo) DisconnectProvider() {
|
||||
f.mu.Lock()
|
||||
session := f.last
|
||||
f.mu.Unlock()
|
||||
if session != nil {
|
||||
session.Disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
type fakeSession struct {
|
||||
mu sync.Mutex
|
||||
failure FakeFailure
|
||||
video chan ProviderMedia
|
||||
audio chan ProviderMedia
|
||||
events chan ProviderEvent
|
||||
state protocol.ProviderState
|
||||
pressed map[string]struct{}
|
||||
inputs []InputEvent
|
||||
feedback []Feedback
|
||||
clipboard string
|
||||
clipboardWrites chan string
|
||||
releaseAll int
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func (s *fakeSession) Ready(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
if s.failure == FakeFailureReadinessTimeout {
|
||||
s.mu.Unlock()
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
defer s.mu.Unlock()
|
||||
if s.state.State == ProviderStateDisconnected {
|
||||
return ErrProviderDisconnected
|
||||
}
|
||||
s.state.State = ProviderStateReady
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeSession) Video() <-chan ProviderMedia { return s.video }
|
||||
func (s *fakeSession) Audio() <-chan ProviderMedia { return s.audio }
|
||||
func (s *fakeSession) Events() <-chan ProviderEvent { return s.events }
|
||||
|
||||
func (s *fakeSession) EmitEvent(event ProviderEvent) {
|
||||
select {
|
||||
case s.events <- ProviderEvent{Kind: event.Kind, Payload: append([]byte(nil), event.Payload...)}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeSession) EmitVideo(payload []byte) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.state.State == ProviderStateTerminating || s.state.State == ProviderStateTerminated || s.state.State == ProviderStateDisconnected {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
media := ProviderMedia{Payload: append([]byte(nil), payload...), ReceivedAt: now, EnqueuedAt: now}
|
||||
select {
|
||||
case s.video <- media:
|
||||
default:
|
||||
select {
|
||||
case <-s.video:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case s.video <- media:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeSession) EmitAudio(payload []byte) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.state.State == ProviderStateTerminating || s.state.State == ProviderStateTerminated || s.state.State == ProviderStateDisconnected {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
media := ProviderMedia{Payload: append([]byte(nil), payload...), ReceivedAt: now, EnqueuedAt: now}
|
||||
select {
|
||||
case s.audio <- media:
|
||||
default:
|
||||
select {
|
||||
case <-s.audio:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case s.audio <- media:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeSession) Input(_ context.Context, event InputEvent) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.state.State != ProviderStateReady {
|
||||
return ErrProviderDisconnected
|
||||
}
|
||||
s.inputs = append(s.inputs, event)
|
||||
key := fmt.Sprintf("%s:%d", event.Device, event.Code)
|
||||
if event.Pressed {
|
||||
s.pressed[key] = struct{}{}
|
||||
} else {
|
||||
delete(s.pressed, key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeSession) Feedback(_ context.Context, feedback Feedback) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.state.State != ProviderStateReady {
|
||||
return ErrProviderDisconnected
|
||||
}
|
||||
s.feedback = append(s.feedback, feedback)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeSession) ReadClipboard(ctx context.Context) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.state.State != ProviderStateReady {
|
||||
return "", ErrProviderDisconnected
|
||||
}
|
||||
return s.clipboard, nil
|
||||
}
|
||||
|
||||
func (s *fakeSession) WriteClipboard(ctx context.Context, value string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.state.State != ProviderStateReady {
|
||||
s.mu.Unlock()
|
||||
return ErrProviderDisconnected
|
||||
}
|
||||
s.clipboard = value
|
||||
s.mu.Unlock()
|
||||
select {
|
||||
case s.clipboardWrites <- value:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeSession) ReleaseAll(_ context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.failure == FakeFailureProviderDisconnect {
|
||||
return ErrProviderDisconnected
|
||||
}
|
||||
s.pressed = make(map[string]struct{})
|
||||
s.releaseAll++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeSession) Terminate(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
terminationTimeout := s.failure == FakeFailureTerminationTimeout
|
||||
s.mu.Unlock()
|
||||
if terminationTimeout {
|
||||
<-ctx.Done()
|
||||
s.mu.Lock()
|
||||
s.state.State = ProviderStateCleanup
|
||||
s.state.CleanupPending = true
|
||||
s.mu.Unlock()
|
||||
return ctx.Err()
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.state.State == ProviderStateTerminated {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
disconnected := s.state.State == ProviderStateDisconnected
|
||||
s.state.State = ProviderStateTerminating
|
||||
s.closeOnce.Do(func() {
|
||||
close(s.video)
|
||||
close(s.audio)
|
||||
})
|
||||
if disconnected {
|
||||
s.state.State = ProviderStateDisconnected
|
||||
} else {
|
||||
s.state.State = ProviderStateTerminated
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeSession) State() protocol.ProviderState {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.state
|
||||
}
|
||||
|
||||
func (s *fakeSession) Telemetry() ProviderTelemetry {
|
||||
return ProviderTelemetry{State: s.State().State}
|
||||
}
|
||||
|
||||
func (s *fakeSession) Disconnect() {
|
||||
s.mu.Lock()
|
||||
s.state.State = ProviderStateDisconnected
|
||||
s.mu.Unlock()
|
||||
s.EmitEvent(ProviderEvent{Kind: ProviderEventDisconnected})
|
||||
}
|
||||
|
||||
func (s *fakeSession) ReleaseCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.releaseAll
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,556 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol"
|
||||
)
|
||||
|
||||
const qualificationProcessTokenHeader = "X-VerseVDI-Qualification-Token"
|
||||
|
||||
type qualificationGatewayProcessConfig struct {
|
||||
ServerCertificatePEM string
|
||||
ServerPrivateKeyPEM string
|
||||
ClientCAPEM string
|
||||
Authority protocol.SessionAuthority
|
||||
Work protocol.ProviderSessionWork
|
||||
PacerKbps int64
|
||||
ReadyPath string
|
||||
Token string
|
||||
}
|
||||
|
||||
type qualificationGatewayProcessReady struct {
|
||||
GatewayAddress string
|
||||
ControlAddress string
|
||||
}
|
||||
|
||||
type qualificationGatewayProcessSnapshot struct {
|
||||
Metrics MetricsSnapshot
|
||||
NativeSetups uint64
|
||||
NativeOpens uint64
|
||||
MediaIngress uint64
|
||||
MediaRecovered uint64
|
||||
MediaEnqueued uint64
|
||||
MediaDrops uint64
|
||||
MediaQueueMaximum uint64
|
||||
MediaQueueMaximumBytes uint64
|
||||
PacerReservations uint64
|
||||
ProviderTelemetry ProviderTelemetry
|
||||
VideoReceiveBuffer int
|
||||
VideoReceiveBufferAvailable bool
|
||||
KernelDrops uint64
|
||||
KernelDropsAvailable bool
|
||||
}
|
||||
|
||||
type qualificationProcessRecordRequest struct {
|
||||
RawPath string
|
||||
ResourcePath string
|
||||
}
|
||||
|
||||
type qualificationProcessRecordResult struct {
|
||||
Count int
|
||||
ClockOverhead time.Duration
|
||||
ClockMethod string
|
||||
ResourceSamples int
|
||||
CPUSeconds float64
|
||||
PeakHeapBytes uint64
|
||||
PeakGoroutines int
|
||||
AllocatedObjects uint64
|
||||
AllocatedBytes uint64
|
||||
RecordingElapsed time.Duration
|
||||
}
|
||||
|
||||
type qualificationProcessTimingSample struct {
|
||||
elapsed time.Duration
|
||||
observation mediaTimingObservation
|
||||
}
|
||||
|
||||
type qualificationProcessRecorder struct {
|
||||
mu sync.Mutex
|
||||
active bool
|
||||
started time.Time
|
||||
rawFile *os.File
|
||||
rawCompressed *gzip.Writer
|
||||
rawBuffered *bufio.Writer
|
||||
resourcePath string
|
||||
resources []qualificationResourceSample
|
||||
samples int
|
||||
recordErr error
|
||||
tickerStop chan struct{}
|
||||
tickerDone chan struct{}
|
||||
timingSamples chan qualificationProcessTimingSample
|
||||
timingDone chan struct{}
|
||||
clock time.Duration
|
||||
}
|
||||
|
||||
func (r *qualificationProcessRecorder) start(request qualificationProcessRecordRequest) error {
|
||||
if err := validateQualificationOutputDir(filepath.Dir(request.RawPath)); err != nil {
|
||||
return err
|
||||
}
|
||||
if filepath.Dir(request.RawPath) != filepath.Dir(request.ResourcePath) || request.RawPath == request.ResourcePath {
|
||||
return errors.New("qualification process output paths invalid")
|
||||
}
|
||||
file, err := os.OpenFile(request.RawPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
compressed, err := gzip.NewWriterLevel(file, gzip.BestSpeed)
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
buffered := bufio.NewWriterSize(compressed, 1<<20)
|
||||
if _, err = buffered.WriteString("elapsed_ns,queue_ns,processing_ns,pacing_ns\n"); err != nil {
|
||||
_ = compressed.Close()
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.active {
|
||||
_ = buffered.Flush()
|
||||
_ = compressed.Close()
|
||||
_ = file.Close()
|
||||
return errors.New("qualification process recording already active")
|
||||
}
|
||||
clock := qualificationClockOverhead()
|
||||
r.active = true
|
||||
r.started = time.Now()
|
||||
r.rawFile = file
|
||||
r.rawCompressed = compressed
|
||||
r.rawBuffered = buffered
|
||||
r.resourcePath = request.ResourcePath
|
||||
r.resources = []qualificationResourceSample{qualificationRuntimeSample(r.started)}
|
||||
r.samples = 0
|
||||
r.recordErr = nil
|
||||
r.clock = clock
|
||||
r.tickerStop = make(chan struct{})
|
||||
r.tickerDone = make(chan struct{})
|
||||
r.timingSamples = make(chan qualificationProcessTimingSample, 4096)
|
||||
r.timingDone = make(chan struct{})
|
||||
go r.writeTimings()
|
||||
go r.sampleResources()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *qualificationProcessRecorder) observe(observation mediaTimingObservation) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.active {
|
||||
return
|
||||
}
|
||||
r.timingSamples <- qualificationProcessTimingSample{
|
||||
elapsed: time.Since(r.started), observation: observation,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *qualificationProcessRecorder) writeTimings() {
|
||||
defer close(r.timingDone)
|
||||
line := make([]byte, 0, 96)
|
||||
for sample := range r.timingSamples {
|
||||
if r.recordErr == nil {
|
||||
line = line[:0]
|
||||
line = strconv.AppendInt(line, sample.elapsed.Nanoseconds(), 10)
|
||||
line = append(line, ',')
|
||||
line = strconv.AppendInt(line, sample.observation.QueueDelay.Nanoseconds(), 10)
|
||||
line = append(line, ',')
|
||||
line = strconv.AppendInt(line, sample.observation.ProcessingDelay.Nanoseconds(), 10)
|
||||
line = append(line, ',')
|
||||
line = strconv.AppendInt(line, sample.observation.PacingDelay.Nanoseconds(), 10)
|
||||
line = append(line, '\n')
|
||||
_, r.recordErr = r.rawBuffered.Write(line)
|
||||
}
|
||||
r.samples++
|
||||
}
|
||||
}
|
||||
|
||||
func (r *qualificationProcessRecorder) sampleResources() {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
defer close(r.tickerDone)
|
||||
for {
|
||||
select {
|
||||
case <-r.tickerStop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
r.mu.Lock()
|
||||
if r.active {
|
||||
r.resources = append(r.resources, qualificationRuntimeSample(r.started))
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *qualificationProcessRecorder) stop() (qualificationProcessRecordResult, error) {
|
||||
r.mu.Lock()
|
||||
if !r.active {
|
||||
r.mu.Unlock()
|
||||
return qualificationProcessRecordResult{}, errors.New("qualification process recording is not active")
|
||||
}
|
||||
r.active = false
|
||||
stop, done := r.tickerStop, r.tickerDone
|
||||
timings, timingDone := r.timingSamples, r.timingDone
|
||||
close(timings)
|
||||
r.mu.Unlock()
|
||||
close(stop)
|
||||
<-done
|
||||
<-timingDone
|
||||
|
||||
r.mu.Lock()
|
||||
r.resources = append(r.resources, qualificationRuntimeSample(r.started))
|
||||
elapsed := time.Since(r.started)
|
||||
err := r.recordErr
|
||||
if flushErr := r.rawBuffered.Flush(); err == nil {
|
||||
err = flushErr
|
||||
}
|
||||
if closeErr := r.rawCompressed.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if closeErr := r.rawFile.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
resources := append([]qualificationResourceSample(nil), r.resources...)
|
||||
result := qualificationProcessRecordResult{
|
||||
Count: r.samples, ClockOverhead: r.clock, ClockMethod: qualificationClockOverheadMethod,
|
||||
ResourceSamples: len(resources), RecordingElapsed: elapsed,
|
||||
}
|
||||
resourcePath := r.resourcePath
|
||||
r.mu.Unlock()
|
||||
if err != nil {
|
||||
return qualificationProcessRecordResult{}, err
|
||||
}
|
||||
if err := writeQualificationResourceSamples(resourcePath, resources); err != nil {
|
||||
return qualificationProcessRecordResult{}, err
|
||||
}
|
||||
first, last := resources[0], resources[len(resources)-1]
|
||||
result.CPUSeconds = qualificationCPUSecondsDelta(first.CPUSeconds, last.CPUSeconds)
|
||||
result.AllocatedObjects = last.AllocatedObjects - first.AllocatedObjects
|
||||
result.AllocatedBytes = last.AllocatedBytes - first.AllocatedBytes
|
||||
for _, sample := range resources {
|
||||
result.PeakHeapBytes = max(result.PeakHeapBytes, sample.HeapBytes)
|
||||
result.PeakGoroutines = max(result.PeakGoroutines, sample.Goroutines)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func qualificationCPUSecondsDelta(first, last float64) float64 {
|
||||
cpuSeconds := -1.0
|
||||
if first >= 0 && last >= first {
|
||||
cpuSeconds = last - first
|
||||
}
|
||||
return cpuSeconds
|
||||
}
|
||||
|
||||
func TestQualificationCPUSecondsDeltaRejectsUnavailableOrDecreasingSamples(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
first, last float64
|
||||
want float64
|
||||
}{
|
||||
{name: "positive", first: 1.25, last: 1.75, want: 0.5},
|
||||
{name: "zero", first: 1.25, last: 1.25, want: 0},
|
||||
{name: "unavailable first", first: -1, last: 1.25, want: -1},
|
||||
{name: "unavailable last", first: 1.25, last: -1, want: -1},
|
||||
{name: "decreasing", first: 1.75, last: 1.25, want: -1},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := qualificationCPUSecondsDelta(test.first, test.last); got != test.want {
|
||||
t.Fatalf("CPU delta for first=%f last=%f = %f, want %f", test.first, test.last, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type qualificationGatewayProcess struct {
|
||||
command *exec.Cmd
|
||||
cancel context.CancelFunc
|
||||
done chan error
|
||||
output *bytes.Buffer
|
||||
ready qualificationGatewayProcessReady
|
||||
token string
|
||||
client *http.Client
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func startQualificationGatewayProcess(t *testing.T, serverTLS *tls.Config, authority protocol.SessionAuthority, work protocol.ProviderSessionWork, pacerKbps int64) *qualificationGatewayProcess {
|
||||
t.Helper()
|
||||
temp := t.TempDir()
|
||||
configPath := filepath.Join(temp, "gateway-config.json")
|
||||
readyPath := filepath.Join(temp, "gateway-ready.json")
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := qualificationGatewayProcessConfig{
|
||||
ServerCertificatePEM: qualificationCertificateChainPEM(t, serverTLS.Certificates[0]),
|
||||
ServerPrivateKeyPEM: privateKeyPEM(t, serverTLS.Certificates[0]),
|
||||
ClientCAPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: serverTLS.Certificates[0].Certificate[1]})),
|
||||
Authority: authority, Work: work, PacerKbps: pacerKbps, ReadyPath: readyPath,
|
||||
Token: hex.EncodeToString(tokenBytes),
|
||||
}
|
||||
encoded, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(configPath, encoded, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestQualificationGatewayProcessChild$", "-test.count=1")
|
||||
command.Env = append(os.Environ(), "VERSEVDI_QUALIFICATION_GATEWAY_CONFIG="+configPath)
|
||||
output := &bytes.Buffer{}
|
||||
command.Stdout, command.Stderr = output, output
|
||||
if err := command.Start(); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
process := &qualificationGatewayProcess{
|
||||
command: command, cancel: cancel, done: make(chan error, 1), output: output,
|
||||
token: config.Token, client: &http.Client{Timeout: 5 * time.Second},
|
||||
}
|
||||
go func() { process.done <- command.Wait() }()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
raw, readErr := os.ReadFile(readyPath)
|
||||
if readErr == nil && json.Unmarshal(raw, &process.ready) == nil &&
|
||||
process.ready.GatewayAddress != "" && process.ready.ControlAddress != "" {
|
||||
return process
|
||||
}
|
||||
select {
|
||||
case waitErr := <-process.done:
|
||||
cancel()
|
||||
t.Fatalf("qualification gateway child exited before ready: %v\n%s", waitErr, output)
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
process.Close()
|
||||
t.Fatalf("qualification gateway child did not become ready\n%s", output)
|
||||
return nil
|
||||
}
|
||||
|
||||
func qualificationCertificateChainPEM(t *testing.T, certificate tls.Certificate) string {
|
||||
t.Helper()
|
||||
var encoded strings.Builder
|
||||
for _, der := range certificate.Certificate {
|
||||
if err := pem.Encode(&encoded, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return encoded.String()
|
||||
}
|
||||
|
||||
func (p *qualificationGatewayProcess) request(method, path string, body any, response any) error {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
encoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader = bytes.NewReader(encoded)
|
||||
}
|
||||
request, err := http.NewRequest(method, "http://"+p.ready.ControlAddress+path, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set(qualificationProcessTokenHeader, p.token)
|
||||
result, err := p.client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer result.Body.Close()
|
||||
if result.StatusCode != http.StatusOK {
|
||||
raw, _ := io.ReadAll(io.LimitReader(result.Body, 4096))
|
||||
return fmt.Errorf("qualification gateway control %s: %s", result.Status, raw)
|
||||
}
|
||||
if response != nil {
|
||||
return json.NewDecoder(io.LimitReader(result.Body, 1<<20)).Decode(response)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *qualificationGatewayProcess) startRecording(rawPath, resourcePath string) error {
|
||||
return p.request(http.MethodPost, "/record/start", qualificationProcessRecordRequest{RawPath: rawPath, ResourcePath: resourcePath}, nil)
|
||||
}
|
||||
|
||||
func (p *qualificationGatewayProcess) stopRecording() (qualificationProcessRecordResult, error) {
|
||||
var result qualificationProcessRecordResult
|
||||
err := p.request(http.MethodPost, "/record/stop", nil, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (p *qualificationGatewayProcess) snapshot() (qualificationGatewayProcessSnapshot, error) {
|
||||
var result qualificationGatewayProcessSnapshot
|
||||
err := p.request(http.MethodGet, "/snapshot", nil, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (p *qualificationGatewayProcess) Close() {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.once.Do(func() {
|
||||
_ = p.request(http.MethodPost, "/shutdown", nil, nil)
|
||||
select {
|
||||
case <-p.done:
|
||||
case <-time.After(5 * time.Second):
|
||||
p.cancel()
|
||||
<-p.done
|
||||
}
|
||||
p.cancel()
|
||||
})
|
||||
}
|
||||
|
||||
func TestQualificationGatewayProcessChild(t *testing.T) {
|
||||
configPath := os.Getenv("VERSEVDI_QUALIFICATION_GATEWAY_CONFIG")
|
||||
if configPath == "" {
|
||||
return
|
||||
}
|
||||
raw, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var config qualificationGatewayProcessConfig
|
||||
if err := json.Unmarshal(raw, &config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certificate, err := tls.X509KeyPair([]byte(config.ServerCertificatePEM), []byte(config.ServerPrivateKeyPEM))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clientCAs := x509.NewCertPool()
|
||||
if !clientCAs.AppendCertsFromPEM([]byte(config.ClientCAPEM)) {
|
||||
t.Fatal("qualification gateway client CA invalid")
|
||||
}
|
||||
serverTLS := &tls.Config{
|
||||
MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{certificate},
|
||||
ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: clientCAs,
|
||||
}
|
||||
admission := &oneTimeAdmission{
|
||||
authority: config.Authority, released: make(chan struct{}), disableClipboard: true,
|
||||
providerWork: &config.Work,
|
||||
}
|
||||
backend := &qualificationTracingBackend{native: NewNativeApolloBackend()}
|
||||
recorder := &qualificationProcessRecorder{}
|
||||
server, err := NewServer(ServerConfig{
|
||||
ListenAddress: "127.0.0.1:0", TLSConfig: serverTLS, GatewayID: config.Authority.GatewayID,
|
||||
Capabilities: DefaultCapabilities(), ProviderCapabilities: DefaultCapabilities(),
|
||||
Admission: admission, ProviderStateReporter: &recordingProviderStateReporter{},
|
||||
Provider: NewApolloAdapter(backend, ProviderIdentity{}), PacerKbps: config.PacerKbps,
|
||||
mediaObserver: recorder.observe,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
serveDone := make(chan error, 1)
|
||||
go func() { serveDone <- server.Serve(ctx) }()
|
||||
shutdown := make(chan struct{})
|
||||
var shutdownOnce sync.Once
|
||||
handler := http.NewServeMux()
|
||||
authorized := func(response http.ResponseWriter, request *http.Request) bool {
|
||||
if request.Header.Get(qualificationProcessTokenHeader) != config.Token {
|
||||
http.Error(response, "unauthorized", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
handler.HandleFunc("/snapshot", func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodGet || !authorized(response, request) {
|
||||
return
|
||||
}
|
||||
snapshot := qualificationGatewayProcessSnapshot{
|
||||
Metrics: server.Metrics(), NativeSetups: backend.setups.Load(), NativeOpens: backend.opens.Load(),
|
||||
PacerReservations: server.pacer.reservations.Load(),
|
||||
}
|
||||
if session := backend.session(config.Authority.SessionID); session != nil {
|
||||
snapshot.MediaIngress = session.mediaIngress.Load()
|
||||
snapshot.MediaRecovered = session.mediaRecovered.Load()
|
||||
snapshot.MediaEnqueued = session.mediaEnqueued.Load()
|
||||
snapshot.MediaDrops = session.mediaDrops.Load()
|
||||
snapshot.MediaQueueMaximum = session.mediaQueueMaximum.Load()
|
||||
snapshot.MediaQueueMaximumBytes = session.mediaQueueMaximumBytes.Load()
|
||||
snapshot.ProviderTelemetry = session.Telemetry()
|
||||
snapshot.VideoReceiveBuffer, snapshot.VideoReceiveBufferAvailable,
|
||||
snapshot.KernelDrops, snapshot.KernelDropsAvailable =
|
||||
qualificationProviderVideoSocketDiagnostics(session.videoConn)
|
||||
}
|
||||
_ = json.NewEncoder(response).Encode(snapshot)
|
||||
})
|
||||
handler.HandleFunc("/record/start", func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPost || !authorized(response, request) {
|
||||
return
|
||||
}
|
||||
var recordRequest qualificationProcessRecordRequest
|
||||
if err := json.NewDecoder(io.LimitReader(request.Body, 4096)).Decode(&recordRequest); err != nil {
|
||||
http.Error(response, "invalid record request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := recorder.start(recordRequest); err != nil {
|
||||
http.Error(response, err.Error(), http.StatusConflict)
|
||||
}
|
||||
})
|
||||
handler.HandleFunc("/record/stop", func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPost || !authorized(response, request) {
|
||||
return
|
||||
}
|
||||
result, err := recorder.stop()
|
||||
if err != nil {
|
||||
http.Error(response, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(response).Encode(result)
|
||||
})
|
||||
handler.HandleFunc("/shutdown", func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPost || !authorized(response, request) {
|
||||
return
|
||||
}
|
||||
shutdownOnce.Do(func() { close(shutdown) })
|
||||
})
|
||||
control, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
controlServer := &http.Server{Handler: handler, ReadHeaderTimeout: time.Second}
|
||||
go func() { _ = controlServer.Serve(control) }()
|
||||
ready, err := json.Marshal(qualificationGatewayProcessReady{
|
||||
GatewayAddress: server.Addr().String(), ControlAddress: control.Addr().String(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(config.ReadyPath, ready, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-shutdown
|
||||
_, _ = recorder.stop()
|
||||
cancel()
|
||||
_ = server.Close()
|
||||
_ = controlServer.Shutdown(context.Background())
|
||||
if err := <-serveDone; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !darwin && !linux
|
||||
|
||||
package gateway
|
||||
|
||||
import "net"
|
||||
|
||||
func qualificationProcessCPUSeconds() float64 { return -1 }
|
||||
|
||||
func qualificationProviderVideoSocketDiagnostics(*net.UDPConn) (receiveBuffer int, receiveBufferAvailable bool, kernelDrops uint64, kernelDropsAvailable bool) {
|
||||
return 0, false, 0, false
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//go:build darwin || linux
|
||||
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func qualificationProcessCPUSeconds() float64 {
|
||||
var usage syscall.Rusage
|
||||
if syscall.Getrusage(syscall.RUSAGE_SELF, &usage) != nil {
|
||||
return -1
|
||||
}
|
||||
return float64(usage.Utime.Sec+usage.Stime.Sec) +
|
||||
float64(usage.Utime.Usec+usage.Stime.Usec)/1_000_000
|
||||
}
|
||||
|
||||
func qualificationProviderVideoSocketDiagnostics(connection *net.UDPConn) (receiveBuffer int, receiveBufferAvailable bool, kernelDrops uint64, kernelDropsAvailable bool) {
|
||||
if connection == nil {
|
||||
return 0, false, 0, false
|
||||
}
|
||||
raw, err := connection.SyscallConn()
|
||||
if err != nil {
|
||||
return 0, false, 0, false
|
||||
}
|
||||
var inode uint64
|
||||
var socketErr error
|
||||
if err := raw.Control(func(descriptor uintptr) {
|
||||
receiveBuffer, socketErr = syscall.GetsockoptInt(int(descriptor), syscall.SOL_SOCKET, syscall.SO_RCVBUF)
|
||||
if runtime.GOOS == "linux" {
|
||||
var stat syscall.Stat_t
|
||||
if statErr := syscall.Fstat(int(descriptor), &stat); statErr == nil {
|
||||
inode = stat.Ino
|
||||
}
|
||||
}
|
||||
}); err != nil || socketErr != nil {
|
||||
return 0, false, 0, false
|
||||
}
|
||||
receiveBufferAvailable = true
|
||||
if runtime.GOOS == "linux" {
|
||||
kernelDrops, kernelDropsAvailable = qualificationLinuxUDPDrops(inode)
|
||||
}
|
||||
return receiveBuffer, receiveBufferAvailable, kernelDrops, kernelDropsAvailable
|
||||
}
|
||||
|
||||
func qualificationLinuxUDPDrops(inode uint64) (uint64, bool) {
|
||||
if inode == 0 {
|
||||
return 0, false
|
||||
}
|
||||
inodeText := strconv.FormatUint(inode, 10)
|
||||
for _, path := range []string{"/proc/net/udp", "/proc/net/udp6"} {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
raw, readErr := io.ReadAll(io.LimitReader(file, 1<<20))
|
||||
closeErr := file.Close()
|
||||
if readErr != nil || closeErr != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 11 || fields[9] != inodeText {
|
||||
continue
|
||||
}
|
||||
drops, err := strconv.ParseUint(fields[len(fields)-1], 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return drops, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var ErrQueueClosed = errors.New("gateway queue closed")
|
||||
|
||||
// BoundedQueue is deliberately fixed-size. Media uses PushLatest so a slow
|
||||
// client drops old frames instead of allowing provider output to accumulate.
|
||||
type BoundedQueue[T any] struct {
|
||||
mu sync.Mutex
|
||||
items []T
|
||||
limit int
|
||||
dropped uint64
|
||||
closed bool
|
||||
wake chan struct{}
|
||||
}
|
||||
|
||||
func NewBoundedQueue[T any](limit int) *BoundedQueue[T] {
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
return &BoundedQueue[T]{limit: limit, wake: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
func (q *BoundedQueue[T]) PushLatest(item T) error {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if q.closed {
|
||||
return ErrQueueClosed
|
||||
}
|
||||
if len(q.items) == q.limit {
|
||||
var zero T
|
||||
q.items[0] = zero
|
||||
q.items = q.items[1:]
|
||||
q.dropped++
|
||||
}
|
||||
q.items = append(q.items, item)
|
||||
select {
|
||||
case q.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *BoundedQueue[T]) Pop(ctx context.Context) (T, error) {
|
||||
for {
|
||||
q.mu.Lock()
|
||||
if len(q.items) > 0 {
|
||||
item := q.items[0]
|
||||
q.items[0] = *new(T)
|
||||
q.items = q.items[1:]
|
||||
q.mu.Unlock()
|
||||
return item, nil
|
||||
}
|
||||
if q.closed {
|
||||
q.mu.Unlock()
|
||||
var zero T
|
||||
return zero, ErrQueueClosed
|
||||
}
|
||||
q.mu.Unlock()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
var zero T
|
||||
return zero, ctx.Err()
|
||||
case <-q.wake:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (q *BoundedQueue[T]) Close() {
|
||||
q.mu.Lock()
|
||||
if q.closed {
|
||||
q.mu.Unlock()
|
||||
return
|
||||
}
|
||||
q.closed = true
|
||||
select {
|
||||
case q.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
q.mu.Unlock()
|
||||
}
|
||||
|
||||
func (q *BoundedQueue[T]) Dropped() uint64 {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
return q.dropped
|
||||
}
|
||||
|
||||
func (q *BoundedQueue[T]) Len() int {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
return len(q.items)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGatewaySlowReaderStillCleansUpWithinBound(t *testing.T) {
|
||||
harness := newGatewayTransportHarness(t)
|
||||
for sequence := 0; sequence < 10_000; sequence++ {
|
||||
harness.session.EmitVideo([]byte{byte(sequence)})
|
||||
}
|
||||
if err := harness.client.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
harness.waitReleased(t)
|
||||
}
|
||||
|
||||
func TestGatewayDropsVideoPastQueueResidenceBound(t *testing.T) {
|
||||
harness := newGatewayTransportHarness(t)
|
||||
harness.drainInitialMedia(t)
|
||||
before := harness.server.Metrics().MediaDrops
|
||||
harness.session.video <- ProviderMedia{
|
||||
Payload: []byte("stale-complete-frame"), ReceivedAt: time.Now().Add(-time.Second),
|
||||
EnqueuedAt: time.Now().Add(-nativeApolloVideoQueueLatency - time.Millisecond),
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||
defer cancel()
|
||||
if frame, err := harness.client.ReceiveFrame(ctx); err == nil {
|
||||
t.Fatalf("expired provider frame crossed the public transport: %#v", frame)
|
||||
}
|
||||
if drops := harness.server.Metrics().MediaDrops - before; drops != 1 {
|
||||
t.Fatalf("expired queue drops = %d, want 1", drops)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayMalformedUDPDoesNotAmplify(t *testing.T) {
|
||||
harness := newGatewayTransportHarness(t)
|
||||
connection, err := net.DialUDP("udp", nil, harness.server.Addr().(*net.UDPAddr))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer connection.Close()
|
||||
request := []byte("invalid")
|
||||
if _, err := connection.Write(request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := connection.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response := make([]byte, len(request)*3+1)
|
||||
count, _, err := connection.ReadFromUDP(response)
|
||||
if err != nil {
|
||||
if timeout, ok := err.(net.Error); ok && timeout.Timeout() {
|
||||
return
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count > len(request)*3 {
|
||||
t.Fatalf("malformed UDP amplified %d bytes to %d", len(request), count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Metrics struct {
|
||||
ActiveSessions atomic.Int64
|
||||
AdmittedSessions atomic.Uint64
|
||||
AdmissionRejects atomic.Uint64
|
||||
Reconnects atomic.Uint64
|
||||
DrainTransitions atomic.Uint64
|
||||
MediaDrops atomic.Uint64
|
||||
MediaPackets atomic.Uint64
|
||||
MediaBytes atomic.Uint64
|
||||
QueueDelayNanos atomic.Uint64
|
||||
ProcessingDelayNanos atomic.Uint64
|
||||
ProcessingSamples atomic.Uint64
|
||||
PacingDelayNanos atomic.Uint64
|
||||
ProviderErrors atomic.Uint64
|
||||
InputRejected atomic.Uint64
|
||||
ControlRTTNanos atomic.Uint64
|
||||
ControlJitterNanos atomic.Uint64
|
||||
ControlLossPPM atomic.Uint64
|
||||
PendingReliable atomic.Uint64
|
||||
ProviderState atomic.Uint64
|
||||
}
|
||||
|
||||
type MetricsSnapshot struct {
|
||||
ActiveSessions int64
|
||||
AdmittedSessions uint64
|
||||
AdmissionRejects uint64
|
||||
Reconnects uint64
|
||||
DrainTransitions uint64
|
||||
MediaDrops uint64
|
||||
MediaPackets uint64
|
||||
MediaBytes uint64
|
||||
QueueDelayNanos uint64
|
||||
ProcessingDelayNanos uint64
|
||||
ProcessingSamples uint64
|
||||
PacingDelayNanos uint64
|
||||
ProviderErrors uint64
|
||||
InputRejected uint64
|
||||
ControlRTTNanos uint64
|
||||
ControlJitterNanos uint64
|
||||
ControlLossPPM uint64
|
||||
PendingReliable uint64
|
||||
ProviderState uint64
|
||||
}
|
||||
|
||||
func (m *Metrics) Snapshot() MetricsSnapshot {
|
||||
return MetricsSnapshot{
|
||||
ActiveSessions: m.ActiveSessions.Load(),
|
||||
AdmittedSessions: m.AdmittedSessions.Load(),
|
||||
AdmissionRejects: m.AdmissionRejects.Load(),
|
||||
Reconnects: m.Reconnects.Load(),
|
||||
DrainTransitions: m.DrainTransitions.Load(),
|
||||
MediaDrops: m.MediaDrops.Load(),
|
||||
MediaPackets: m.MediaPackets.Load(),
|
||||
MediaBytes: m.MediaBytes.Load(),
|
||||
QueueDelayNanos: m.QueueDelayNanos.Load(),
|
||||
ProcessingDelayNanos: m.ProcessingDelayNanos.Load(),
|
||||
ProcessingSamples: m.ProcessingSamples.Load(),
|
||||
PacingDelayNanos: m.PacingDelayNanos.Load(),
|
||||
ProviderErrors: m.ProviderErrors.Load(),
|
||||
InputRejected: m.InputRejected.Load(),
|
||||
ControlRTTNanos: m.ControlRTTNanos.Load(),
|
||||
ControlJitterNanos: m.ControlJitterNanos.Load(),
|
||||
ControlLossPPM: m.ControlLossPPM.Load(),
|
||||
PendingReliable: m.PendingReliable.Load(),
|
||||
ProviderState: m.ProviderState.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Metrics) observeProviderTelemetry(telemetry ProviderTelemetry) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.ControlRTTNanos.Store(uint64(telemetry.ControlRTT))
|
||||
m.ControlJitterNanos.Store(uint64(telemetry.ControlJitter))
|
||||
m.PendingReliable.Store(telemetry.PendingReliable)
|
||||
if telemetry.ReliableSent == 0 {
|
||||
m.ControlLossPPM.Store(0)
|
||||
} else {
|
||||
m.ControlLossPPM.Store(telemetry.ReliableRetransmits * 1_000_000 / telemetry.ReliableSent)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Metrics) observeProviderState(state string) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
switch state {
|
||||
case ProviderStateStarting:
|
||||
m.ProviderState.Store(1)
|
||||
case ProviderStateReady:
|
||||
m.ProviderState.Store(2)
|
||||
case ProviderStateDisconnected:
|
||||
m.ProviderState.Store(3)
|
||||
case ProviderStateTerminating:
|
||||
m.ProviderState.Store(4)
|
||||
case ProviderStateTerminated:
|
||||
m.ProviderState.Store(5)
|
||||
case ProviderStateCleanup:
|
||||
m.ProviderState.Store(6)
|
||||
case ProviderStateFailed:
|
||||
m.ProviderState.Store(7)
|
||||
default:
|
||||
m.ProviderState.Store(0)
|
||||
}
|
||||
}
|
||||
|
||||
// fairPacer is the gateway's one shared, equal-tier media scheduler. Each
|
||||
// session can hold only its existing bounded provider media channel while it
|
||||
// waits for the next reservation, so a slow client cannot grow a global queue.
|
||||
type fairPacer struct {
|
||||
mu sync.Mutex
|
||||
bytesPerSecond int64
|
||||
flows map[string]fairPacerFlow
|
||||
reservations atomic.Uint64
|
||||
}
|
||||
|
||||
type fairPacerFlow struct {
|
||||
next time.Time
|
||||
lastSeen time.Time
|
||||
debt time.Duration
|
||||
}
|
||||
|
||||
const fairPacerMaximumCatchup = 5 * time.Millisecond
|
||||
|
||||
func newFairPacer(kbps int64) *fairPacer {
|
||||
pacer := &fairPacer{flows: make(map[string]fairPacerFlow)}
|
||||
pacer.setKbps(kbps)
|
||||
return pacer
|
||||
}
|
||||
|
||||
func (p *fairPacer) setKbps(kbps int64) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
if kbps > 0 {
|
||||
p.bytesPerSecond = kbps * 1000 / 8
|
||||
} else {
|
||||
p.bytesPerSecond = 0
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *fairPacer) remove(flow string) {
|
||||
if p == nil || flow == "" {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
delete(p.flows, flow)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *fairPacer) reserveAt(now time.Time, flow string, bytes int) time.Time {
|
||||
if p == nil || flow == "" || bytes < 1 {
|
||||
return now
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.bytesPerSecond < 1 {
|
||||
return now
|
||||
}
|
||||
for key, state := range p.flows {
|
||||
if now.Sub(state.lastSeen) > time.Second {
|
||||
delete(p.flows, key)
|
||||
}
|
||||
}
|
||||
state := p.flows[flow]
|
||||
state.lastSeen = now
|
||||
p.flows[flow] = state
|
||||
base := state.next
|
||||
if base.IsZero() {
|
||||
base = now
|
||||
} else if lag := now.Sub(base); lag > fairPacerMaximumCatchup {
|
||||
base = now.Add(-fairPacerMaximumCatchup)
|
||||
state.debt = min(state.debt+lag-fairPacerMaximumCatchup, nativeApolloVideoQueueLatency-fairPacerMaximumCatchup)
|
||||
}
|
||||
numerator := int64(bytes) * int64(len(p.flows)) * int64(time.Second)
|
||||
delay := time.Duration((numerator + p.bytesPerSecond - 1) / p.bytesPerSecond)
|
||||
if repayment := min(delay/21, state.debt); repayment > 0 {
|
||||
delay -= repayment
|
||||
state.debt -= repayment
|
||||
}
|
||||
state.next = base.Add(delay)
|
||||
p.flows[flow] = state
|
||||
return state.next
|
||||
}
|
||||
|
||||
func (p *fairPacer) wait(ctx context.Context, flow string, bytes int) error {
|
||||
target := p.reserveAt(time.Now(), flow, bytes)
|
||||
p.reservations.Add(1)
|
||||
if delay := time.Until(target); delay > 0 {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
<root><unique_id>apollo-fixture-1</unique_id><fingerprint>sha256:fixture-apollo-1</fingerprint><not_before>2025-12-31T23:00:00Z</not_before><not_after>2026-01-01T01:00:00Z</not_after><name>fixture-apollo</name></root>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
4f70757301
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
000001650102
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
494e503100000001000000070000014b6579626f617264
|
||||
+1
@@ -0,0 +1 @@
|
||||
RTSP/1.0 200 OK\r\nSession: fixture-session\r\nTransport: unicast;server_port=43000\r\n\r\n
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,14 @@
|
||||
module git.sechmachine.io.vn/sechmachine/VerseVDI-Data-Plane
|
||||
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.10
|
||||
github.com/quic-go/quic-go v0.61.0
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.3 h1:ZoXbg9CRwlypVbDO0EaXwHVOKTGlIfZDC7s/4JuOISE=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.3/go.mod h1:7PhFIDhjtr20btWoEb2GqB+7dBpzJt43olrnHVutWoc=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.4 h1:uwNoKtzRlpdbvq9kHblncKr+K+dck0ydtozzPKItQzA=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.4/go.mod h1:7PhFIDhjtr20btWoEb2GqB+7dBpzJt43olrnHVutWoc=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.5 h1:F+Ig0OVpHcKr/G+uYy7Vm8BVxfei/Rk1yqYHtX9BcL0=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.5/go.mod h1:7PhFIDhjtr20btWoEb2GqB+7dBpzJt43olrnHVutWoc=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.6 h1:xCL6VYjn+alrcxIU85wmYOvLMyvIFzUWbTNxMP46ij4=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.6/go.mod h1:7PhFIDhjtr20btWoEb2GqB+7dBpzJt43olrnHVutWoc=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.7 h1:vQWELUD8bTjEI9rsJYinH2PegOlGwOkyndcD+jgLMBQ=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.7/go.mod h1:7PhFIDhjtr20btWoEb2GqB+7dBpzJt43olrnHVutWoc=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.8 h1:DqD2I3bjiVt+mr741o7hw4wDUp2vYZx32CNDSkqADwY=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.8/go.mod h1:7PhFIDhjtr20btWoEb2GqB+7dBpzJt43olrnHVutWoc=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.9 h1:X7v4Xcjs2xuxcRv95w22gpBYz7Fv+LFAe4lSB2GooBM=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.9/go.mod h1:7PhFIDhjtr20btWoEb2GqB+7dBpzJt43olrnHVutWoc=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.10 h1:9KcV44asmhURVQJ6NbuRXaoTf0UI7Zmx/b3b2/kfHlM=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.10/go.mod h1:7PhFIDhjtr20btWoEb2GqB+7dBpzJt43olrnHVutWoc=
|
||||
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||
github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA=
|
||||
github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,24 @@
|
||||
## Decisions
|
||||
|
||||
- Use quic-go v0.61.0 with TLS 1.3, DATAGRAM enabled, bounded stream windows, bounded
|
||||
datagram sizes, and no migration fallback in the application contract.
|
||||
- Authenticate a client hello over a reliable stream, consume authority exactly once through
|
||||
an injected admission client, then open lifecycle/control/input streams and media/audio
|
||||
datagrams.
|
||||
- Keep management/readiness/lifecycle, channel translation, and encoded relay separate.
|
||||
- Use a deterministic fake Apollo provider behind the same adapter interface as the future
|
||||
network client. Fixtures are non-live evidence and carry no host/credential material.
|
||||
- On authority loss, close admission, release every pressed input, stop queues, and report
|
||||
cleanup pending if provider termination is not acknowledged.
|
||||
|
||||
## Bounds
|
||||
|
||||
JSON hello/control is limited to 64 KiB, datagrams to 65,536 bytes with a configurable
|
||||
path-MTU payload cap, fragments to 16, queues to fixed capacities, clipboard text to 65,536
|
||||
bytes and rate-limited, and each session owns only bounded goroutines/timers.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
TLS/authentication, identity/protection, version, audience, grant, capability, parser,
|
||||
provider, and cleanup failures close the relevant session with stable codes. Media payloads
|
||||
are copied only for framing and are asserted byte-identical in tests.
|
||||
@@ -0,0 +1,23 @@
|
||||
## Why
|
||||
|
||||
The Data Plane is an empty gateway boundary. Phase 3C-G needs a pure-Go QUIC process that
|
||||
admits only Connection Server authority, adapts one Apollo/GameStream provider profile, and
|
||||
relays encoded bytes without exposing the provider or decoding media.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add bounded QUIC/TLS streams and DATAGRAM framing with authenticated admission.
|
||||
- Add a native-Go Apollo profile, deterministic fixtures, a bounded fake provider, lifecycle
|
||||
cleanup, input release, capability intersection, pacing, telemetry, and packaging.
|
||||
|
||||
## Provenance
|
||||
|
||||
Provider behavior is independently implemented from the exact Apollo pin
|
||||
`adc5c5a0bd80831ce495434bb16aee2cd4175fb8` and the Planning Hub's recorded public
|
||||
Moonlight/common-C protocol evidence. Only the used source paths are recorded; no source
|
||||
tree or proprietary capture is copied.
|
||||
|
||||
## Non-goals
|
||||
|
||||
No cgo, native sidecar, decoder/encoder/transcoder/render path, direct provider route,
|
||||
database credential, provider plugin framework, or live Apollo qualification.
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Authenticated bounded gateway transport
|
||||
The gateway SHALL require TLS 1.3 client authentication and a valid versioned grant hello
|
||||
before allocating provider state. Reliable lifecycle/control/critical-input messages SHALL
|
||||
use streams; encoded media/audio and approved sequenced input SHALL use bounded DATAGRAMs.
|
||||
|
||||
#### Scenario: Grant replay or wrong audience
|
||||
- **WHEN** a client presents a consumed, expired, revoked, or audience-mismatched grant
|
||||
- **THEN** the gateway rejects before provider allocation and emits no provider route or
|
||||
credential to the client.
|
||||
|
||||
### Requirement: No-transcode encoded relay
|
||||
The gateway SHALL relay provider encoded payload bytes through a codec-neutral Verse envelope
|
||||
without decode, encode, transcode, render, or codec conversion.
|
||||
|
||||
#### Scenario: Payload relay
|
||||
- **WHEN** the fake Apollo provider emits an encoded video or audio payload
|
||||
- **THEN** the corresponding Verse payload is byte-identical except for the approved transport
|
||||
framing and the gateway records no decoder/encoder operation.
|
||||
|
||||
### Requirement: Provider identity and protection
|
||||
The Apollo profile SHALL reject changed, malformed, expired, or not-yet-valid pinned identity
|
||||
and SHALL never silently retry with weaker protection.
|
||||
|
||||
#### Scenario: Identity change
|
||||
- **WHEN** the provider identity differs from the enrolled fingerprint or unique ID
|
||||
- **THEN** launch fails closed and the session remains unavailable for new media.
|
||||
|
||||
### Requirement: Bounded lifecycle and input safety
|
||||
Authority loss, tunnel close, drain, provider disconnect, and explicit termination SHALL be
|
||||
distinct states; every pressed key/button/controller SHALL be released before session cleanup.
|
||||
|
||||
#### Scenario: Authority expiry during input
|
||||
- **WHEN** authority expires while input is pressed
|
||||
- **THEN** new input is rejected, release-all is sent to the provider, queues stop, and
|
||||
cleanup is reported as pending until termination is acknowledged.
|
||||
|
||||
### Requirement: Deterministic fake-provider qualification
|
||||
The complete management, launch/readiness, channel, feedback/input, termination, and cleanup
|
||||
sequence SHALL pass against a bounded fake provider plus malformed and timeout fixtures.
|
||||
|
||||
#### Scenario: Fake provider timeout
|
||||
- **WHEN** readiness or termination times out
|
||||
- **THEN** the adapter returns a bounded stable error and marks cleanup pending without
|
||||
spawning unbounded retries or goroutines.
|
||||
@@ -0,0 +1,5 @@
|
||||
- [x] Add strict QUIC/TLS transport, reliable stream framing, and bounded datagram parser.
|
||||
- [x] Add capability intersection, queues/pacing, input release, telemetry, and lifecycle.
|
||||
- [x] Add deterministic Apollo fixtures, provenance, bounded fake provider, and adapter.
|
||||
- [x] Add process-level mTLS/QUIC integration, fuzz/race/resource/impairment/scheduler tests.
|
||||
- [x] Add gateway command/package smoke checks and archive after candidate evidence matches.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-29
|
||||
@@ -0,0 +1,54 @@
|
||||
## Context
|
||||
|
||||
P3C-029 through P3C-033 require raw, reproducible processing, payload-rate,
|
||||
fairness, cap, and impairment evidence. Existing focused tests cover the
|
||||
framer, bounded queues, native Apollo fake, and fair pacer, but do not emit the
|
||||
normative ten-minute or six-profile artifacts.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Run the three 20/50/80 Mbps encoded-payload profiles for ten wall-clock
|
||||
minutes each after a recorded warm-up.
|
||||
- Measure the existing gateway framing path with a monotonic clock and retain
|
||||
compressed raw latency samples plus full summary statistics.
|
||||
- Run the exact six Section 7.2 configurations once using a deterministic,
|
||||
bounded virtual packet discipline and retain configured and observed values.
|
||||
- Exercise one real mTLS/QUIC fake-provider traversal for every media profile
|
||||
and reuse the real fair-pacer implementation for fairness and cap evidence.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Live Apollo/macOS/firewall qualification, real encoder fidelity, codec
|
||||
processing, host network mutation, or multi-host scale.
|
||||
- A production impairment framework, new gateway API, dependency, cgo, or
|
||||
sidecar.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Implement the harness as an opt-in `go test` in package `gateway`. This
|
||||
keeps qualification access to the actual unexported fair pacer without
|
||||
adding a production API. Normal suites skip the long run unless an explicit
|
||||
absolute evidence directory is supplied.
|
||||
- Use wall-clock duration and target-rate pacing for performance profiles.
|
||||
Measure only receive-to-framed-payload processing; pacing wait and raw-file
|
||||
writes stay outside the measured interval and are reported separately.
|
||||
- Stream every raw sample into gzip-compressed CSV while retaining one bounded
|
||||
duration slice per profile for exact percentiles.
|
||||
- Use a fixed-seed virtual FIFO for impairment. It records no host claim and
|
||||
identifies its queue discipline and deterministic topology explicitly.
|
||||
- Treat any payload mutation, p95 above 5 ms, catalog mismatch, fairness error
|
||||
above 10%, cap excess above 5%, or step convergence beyond ten seconds as a
|
||||
hard command failure.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Local virtual impairment cannot prove deployed route behavior] → label every
|
||||
artifact deterministic and retain live Apollo/macOS/firewall as
|
||||
deferred-owner-e2e.
|
||||
- [Raw samples can be large] → stream gzip output and bound in-memory samples
|
||||
to the exact profile packet budget.
|
||||
- [Host load can invalidate latency] → record OS, architecture, Go version,
|
||||
timing overhead, actual duration, packet count, and observed bitrate; fail
|
||||
rather than substitute configured capacity for measured egress.
|
||||
@@ -0,0 +1,35 @@
|
||||
## Why
|
||||
|
||||
The Phase 3C gateway candidate has deterministic transport and scheduler tests
|
||||
but no executable artifact generator for the normative ten-minute media
|
||||
measurements and six bounded impairment profiles. Without that evidence,
|
||||
P3C-029 through P3C-033 cannot be frozen truthfully.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add one stdlib-only qualification command for the three fixed encoded-media
|
||||
profiles and the exact six Section 7.2 impairment profiles.
|
||||
- Emit bounded machine-readable configuration, raw observations, summaries,
|
||||
environment, topology, direction, queue discipline, and tool version.
|
||||
- Fail the command when payload integrity, the 5 ms processing p95, impairment
|
||||
bounds, fairness, capacity-step convergence, or aggregate cap gates fail.
|
||||
- Keep live Apollo, macOS, physical firewall, real encoder fidelity, and real
|
||||
multi-host scale explicitly deferred-owner-e2e.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `gateway-qualification`: Deterministic P3C-029 through P3C-033 media,
|
||||
processing, fairness, cap, and impairment evidence generation.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
None.
|
||||
|
||||
## Impact
|
||||
|
||||
The Data Plane gains a qualification-only Go command, focused tests, and
|
||||
documented evidence output. It adds no dependency, production transport
|
||||
abstraction, provider route, codec operation, cgo, sidecar, or Connection
|
||||
Server code.
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Fixed media processing qualification
|
||||
The qualification harness SHALL run 1080p60 H.264 at 20 Mbps, 1440p120 HEVC
|
||||
at 50 Mbps, and 4K60 HEVC at 80 Mbps for ten wall-clock minutes each after a
|
||||
recorded warm-up. It SHALL preserve encoded payload bytes, record every
|
||||
monotonic processing sample, report count, min, median, p90, p95, p99, max,
|
||||
mean, standard deviation, timing overhead, and observed bitrate, and fail when
|
||||
any p95 exceeds 5 ms.
|
||||
|
||||
#### Scenario: Healthy fixed profile
|
||||
- **WHEN** a frozen candidate runs one fixed profile for the normative duration
|
||||
- **THEN** the harness emits compressed raw samples and a summary tied to the
|
||||
exact source commit, Protocol version, environment, and payload hash.
|
||||
|
||||
#### Scenario: Processing gate failure
|
||||
- **WHEN** payload integrity fails or measured p95 exceeds 5 ms
|
||||
- **THEN** the qualification command exits unsuccessfully without recording a
|
||||
passing candidate.
|
||||
|
||||
### Requirement: Bounded impairment qualification
|
||||
The harness SHALL run exactly the baseline, latency, jitter, loss, reorder,
|
||||
and constrained Section 7.2 profiles once. Baseline SHALL cover all three
|
||||
media profiles and the other profiles SHALL cover 1080p60. Each artifact SHALL
|
||||
record tool version, exact command/configuration, direction, queue discipline,
|
||||
topology, fixed seed, and observed RTT, jitter, loss, reorder, throughput,
|
||||
drops, and capacity-step statistics.
|
||||
|
||||
#### Scenario: Complete six-profile run
|
||||
- **WHEN** the frozen candidate runs impairment qualification
|
||||
- **THEN** one result exists for each named profile, with no Cartesian
|
||||
expansion and with observed rather than configured statistics.
|
||||
|
||||
#### Scenario: Unsupported or unbounded configuration
|
||||
- **WHEN** a profile name, packet count, queue bound, loss, reorder, or
|
||||
bandwidth step falls outside the fixed catalog
|
||||
- **THEN** the harness rejects it before allocating or running the simulation.
|
||||
|
||||
### Requirement: Fairness and cap qualification
|
||||
The harness SHALL exercise the production fair pacer with eight equal-tier
|
||||
synthetic sessions for the required 60-second virtual interval, report every
|
||||
share error and Jain's fairness index, and fail above 10% share error. It SHALL
|
||||
apply 25% and 50% capacity steps, fail convergence beyond ten virtual seconds,
|
||||
and fail aggregate egress above 105% of the cap over any rolling five-second
|
||||
window.
|
||||
|
||||
#### Scenario: Equal-tier and capacity-step evidence
|
||||
- **WHEN** the frozen candidate runs scheduler qualification
|
||||
- **THEN** the artifact contains per-flow bytes, share errors, Jain's index,
|
||||
step convergence, and rolling cap observations derived from the production
|
||||
pacer.
|
||||
|
||||
### Requirement: Honest qualification boundary
|
||||
Qualification artifacts SHALL contain no provider endpoint, credential,
|
||||
clipboard text, input payload, secret, raw media content, or claim of live
|
||||
Apollo/macOS/firewall interoperability. The harness SHALL add no codec
|
||||
operation, production dependency, cgo, sidecar, or direct provider route.
|
||||
|
||||
#### Scenario: Deterministic evidence publication
|
||||
- **WHEN** qualification completes
|
||||
- **THEN** the manifest labels fake-provider, virtual impairment, and local
|
||||
processing evidence separately and leaves live interoperability
|
||||
deferred-owner-e2e.
|
||||
@@ -0,0 +1,24 @@
|
||||
## 1. Contract and focused regressions
|
||||
|
||||
- [x] 1.1 Add fixed catalog tests for the three media profiles, ten-minute
|
||||
duration, exact six impairment profiles, and bounded output configuration.
|
||||
- [x] 1.2 Add summary, payload-integrity, fairness, cap, and failure-threshold
|
||||
tests before implementing the harness.
|
||||
|
||||
## 2. Qualification harness
|
||||
|
||||
- [x] 2.1 Implement opt-in real-duration processing measurement with compressed
|
||||
raw samples, full statistics, timing overhead, and environment metadata.
|
||||
- [x] 2.2 Implement deterministic bounded impairment observations and reuse the
|
||||
production fair pacer for fairness and capacity-step evidence.
|
||||
- [x] 2.3 Add one mTLS/QUIC fake-provider traversal per fixed encoded profile
|
||||
and prove the artifact boundary contains no provider route or secret.
|
||||
|
||||
## 3. Freeze and evidence
|
||||
|
||||
- [x] 3.1 Run focused red/green checks, strict OpenSpec validation, `make
|
||||
verify`, race/fuzz/resource checks, and freeze the harness commit.
|
||||
- [x] 3.2 Run the opt-in ten-minute and six-profile command exactly once
|
||||
against the frozen candidate and archive raw artifacts and hashes.
|
||||
- [x] 3.3 Sync the canonical specification, archive the completed change, and
|
||||
revalidate strictly without claiming live Apollo/macOS/firewall evidence.
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-30
|
||||
@@ -0,0 +1,36 @@
|
||||
## Context
|
||||
|
||||
The qualification driver already reaches the production Apollo-to-QUIC path, but its source shaper reorders jitter even when reorder is disabled, its packet accounting cannot identify unexplained loss, and in-process resource counters include the provider/client driver.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Attribute every source unit to one bounded production-path outcome.
|
||||
- Keep impairment axes independently configured and observed.
|
||||
- Sample CPU, heap, allocation, and goroutine use from the gateway process only.
|
||||
- Record measured monotonic-clock overhead.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- No second simulator, profiling service, production dependency, or expanded impairment matrix.
|
||||
- No larger queues or relaxed acceptance limits without measured need.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Reuse the existing source-boundary shaper, preserve source order unless explicit reorder is enabled, and limit catch-up to one media serialization interval. Record the fixed-seed applied-delay standard deviation separately from the jitter observed after ordered traversal.
|
||||
- Drive processing sends at the configured source rate while a separate public-client receive loop validates ordered payload delivery. Use cooperative scheduling with a bounded high-resolution final wait in the parent driver so sub-millisecond packet spacing does not depend on host sleep granularity.
|
||||
- Assign stable source sequence identifiers and retain per-stage counts so injected loss, provider/FEC drop, queue replacement, QUIC failure, and client miss are disjoint.
|
||||
- Reuse the established gateway child-test pattern for the actual gateway server; the Apollo fixture and QUIC client remain in the parent driver. A token-protected loopback test control endpoint starts and stops bounded child-owned recording and returns aggregate stage state.
|
||||
- Stream queue, processing, and pacing samples from the production `sendMedia` boundary to child-owned raw evidence. Sample child `RUSAGE_SELF`, Go heap, allocations, and goroutines once per second with independent per-run baselines.
|
||||
- Buffer at most 4,096 child-owned timing samples before the gzip writer; encode rows into a reused byte buffer, drain every sample before recording stops, and backpressure on sustained writer overload instead of dropping evidence or formatting/compressing synchronously in the media loop.
|
||||
- Bound native video at 256 packets (about 30 ms and less than 0.4 MiB per session at the largest fixture unit) after the sustained public-path regression observed a 141-packet scheduler/GC stall with the 64-packet bound; keep audio and events at 16 and retain latest-unit replacement. Sample heap objects, allocated objects/bytes, and live goroutines through `runtime/metrics` while retaining `RUSAGE_SELF` for CPU.
|
||||
- Measure clock overhead as the median elapsed time per read across 1,000 batches of 100 monotonic reads and record that method.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Ordered release suppresses some delivered jitter] → Retain both the applied fixed-seed delay distribution and the separately observed ordered-traversal jitter.
|
||||
- [Stage attribution double-counts a unit] → Record one terminal outcome per source sequence and validate accounting equality.
|
||||
- [Process sampling perturbs qualification] → Use bounded low-rate samples and include the sampling method in evidence.
|
||||
- [The source driver consumes CPU for precise pacing] → Keep it in the parent process excluded by the gateway-only resource sampler, and yield cooperatively until the final 50 microseconds.
|
||||
- [Shared private runners cannot sustain the reviewed 20/50/80 Mbps gates] → Run the complete verifier on the registered on-demand xhigh runner; the frozen qualification remains authoritative for the normative duration.
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
## Why
|
||||
|
||||
RC8 qualification evidence cannot support candidate readiness because clean traffic loses packets without stage attribution, reorder-off jitter reorders traffic, and resource counters include the provider/client driver rather than the gateway process alone.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Attribute every production-path packet outcome at the source fixture, native provider queue, gateway forwarding, QUIC, and public-client stages.
|
||||
- Preserve source order for reorder-off profiles while retaining configured latency and jitter; inject bounded reorder only when enabled.
|
||||
- Measure gateway CPU, heap, allocations, and goroutines from the gateway process only, with isolated per-profile counters.
|
||||
- Measure and record bounded nonzero monotonic-clock overhead using a batched method.
|
||||
- Retain the existing six-profile matrix and real provider-to-public-client traversal.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
None.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `gateway-qualification`: Require attributable clean-path delivery, independent impairment axes, gateway-process-only resource evidence, and truthful timing-overhead evidence.
|
||||
|
||||
## Impact
|
||||
|
||||
P3C-002, P3C-026, P3C-028, P3C-029, and P3C-033; the GPL Data Plane qualification driver, production gateway subprocess boundary, raw evidence, and append-only Phase 3C-G evidence. No Protocol wire contract, provider route, transcode path, or closed Server dependency is introduced.
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Fixed media processing qualification
|
||||
The qualification harness SHALL drive pinned-mTLS Apollo management, encrypted RTSP, ENet, and provider UDP through native source validation, `readUDPMedia`, recovery/FEC, bounded production queues, the production fair pacer, Verse framing/QUIC, and a public or independent client decoder for 1080p60 H.264 at 20 Mbps, 1440p120 HEVC at 50 Mbps, and 4K60 HEVC at 80 Mbps. After a recorded warm-up, the frozen candidate SHALL run each profile for ten wall-clock minutes, preserve encoded payload bytes, retain every monotonic processing sample plus bounded provider-queue observations, and report count, min, median, p90, p95, p99, max, mean, standard deviation, measured batched monotonic-clock overhead and method, and observed bitrate. Processing begins at complete provider-unit receipt and ends at QUIC handoff, excluding client transit and pacing. Queue delay SHALL measure provider-queue residence, processing SHALL measure gateway work before pacing, and pacing delay SHALL measure scheduler waiting. Native queues SHALL remain bounded at 256 video packets and 16 audio or event units per session, retaining latest-unit replacement. CPU, heap, allocations, and goroutines SHALL be measured from the isolated gateway process only; CPU SHALL be actual OS user plus system consumption and MUST NOT include idle wall capacity or unrelated parent fixture/client work. Successive profiles SHALL use independent resource-counter baselines. Any bypass, payload mutation, wall-duration violation, bitrate outside both lower and upper bounds, unexplained clean-path loss, zero or unbounded clock overhead, or p95 above 5 ms SHALL fail.
|
||||
|
||||
#### Scenario: Healthy fixed profile
|
||||
- **WHEN** a frozen candidate runs one fixed profile for the normative duration in the isolated qualification command
|
||||
- **THEN** the harness emits compressed raw path and gateway-process resource samples plus a summary tied to the exact command, CPU scope, timing-overhead method, topology, source commit, immutable Protocol version, environment, and payload hash
|
||||
|
||||
#### Scenario: Processing gate failure
|
||||
- **WHEN** any production path stage lacks a per-traversal observation, stage accounting does not balance, payload integrity fails, duration or bitrate bounds fail, measured p95 exceeds 5 ms, parent work changes gateway CPU, idle capacity is reported as consumed CPU, or timing overhead is absent
|
||||
- **THEN** the qualification command exits unsuccessfully without recording a passing candidate
|
||||
|
||||
### Requirement: Bounded impairment qualification
|
||||
The harness SHALL run exactly the baseline, latency, jitter, loss, reorder, and constrained Section 7.2 profiles once by applying fixed-seed impairment at the source-shaped provider network boundary while traffic concurrently traverses the production gateway path. Baseline SHALL cover all three media profiles and the other profiles SHALL cover 1080p60. The harness MUST NOT serialize a complete provider-to-client traversal per source unit. Reorder-off profiles SHALL preserve source order through an ordered delay queue whose catch-up is limited to one media serialization interval; the fixed-seed applied-delay distribution and jitter observed after ordered traversal SHALL be reported separately. Loss-only traffic SHALL NOT gain implicit reorder. Reorder-on profiles SHALL inject and record only the fixed bounded reorder pattern. Each source unit SHALL have one attributable outcome across source emission, injected drop, native provider/FEC handling, bounded queue replacement, gateway forwarding, QUIC send/receive, and public-client delivery. Each artifact SHALL retain raw impairment and queue observations and record tool version, exact command/configuration, environment, candidate commit, immutable Protocol version, direction, queue discipline, topology, fixed seed, observed one-way latency, acknowledged Apollo ENet RTT, applied and observed jitter, injected and unexplained loss, reorder, throughput, drops, and capacity-step statistics.
|
||||
|
||||
#### Scenario: Complete six-profile run
|
||||
- **WHEN** the frozen candidate runs impairment qualification
|
||||
- **THEN** one result exists for each named profile, configured and observed impairment axes remain separately attributable, reorder-off profiles preserve source order, RTT comes from real request/response acknowledgement timing, and raw statistics come from actual traversal
|
||||
|
||||
#### Scenario: Clean production traversal
|
||||
- **WHEN** 10,000 source packets traverse a zero-loss baseline profile
|
||||
- **THEN** stage accounting identifies every packet and fails on any unexplained loss while each fixed media bitrate remains within its reviewed healthy-path contract
|
||||
|
||||
#### Scenario: Unsupported or unbounded configuration
|
||||
- **WHEN** a profile name, packet count, queue bound, loss, reorder, or bandwidth step falls outside the fixed catalog
|
||||
- **THEN** the harness rejects it before allocating or running traffic
|
||||
@@ -0,0 +1,23 @@
|
||||
## 1. Loss Attribution
|
||||
|
||||
- [x] 1.1 Add a 10,000-packet production-path regression that records source, injected-drop, provider/FEC, queue, gateway, QUIC, and public-client outcomes
|
||||
- [x] 1.2 Reproduce and repair unexplained zero-loss baseline loss without relaxing bounds or hiding drops
|
||||
- [x] 1.3 Prove all three fixed baseline bitrates meet the healthy-path contract
|
||||
|
||||
## 2. Impairment Semantics
|
||||
|
||||
- [x] 2.1 Add fixed-seed regressions for reorder-off jitter, loss-only order, and bounded reorder-on behavior
|
||||
- [x] 2.2 Repair the existing source-boundary shaper and retain separately attributable configured and observed axes
|
||||
|
||||
## 3. Resource and Timing Attribution
|
||||
|
||||
- [x] 3.1 Add child-process regressions proving gateway-only CPU, heap, allocation, and goroutine samples
|
||||
- [x] 3.2 Prove parent CPU isolation, idle/work behavior, and independent per-profile counter baselines
|
||||
- [x] 3.3 Measure and record bounded nonzero batched monotonic-clock overhead and method
|
||||
|
||||
## 4. Verification and Evidence
|
||||
|
||||
- [x] 4.1 Run focused production-path, race, fuzz, cancellation, slow-reader, amplification, parser-resource, and bounded soak checks
|
||||
- [x] 4.2 Run strict OpenSpec validation, normal-module verification, and reproducible Linux artifact inspection
|
||||
- [x] 4.3 Freeze all executable inputs and run the corrected normative Section 7 qualification once for the candidate
|
||||
- [x] 4.4 Preserve failed attempts and append superseding evidence and ledger rows without rewriting RC8
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-30
|
||||
@@ -0,0 +1,31 @@
|
||||
## Context
|
||||
|
||||
The native production path and fair pacer already exist. The defect was evidence collection: provider sends waited synchronously for client delivery, RTT was arithmetic, CPU was available capacity, and the repository had no canonical Linux build target.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Observe concurrent source-shaped traversal without a duplicate transport.
|
||||
- Distinguish actual one-way delivery, acknowledged RTT, queue/processing/pacing, and consumed process CPU.
|
||||
- Produce reproducible inspectable Linux artifacts for the deployment architectures.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- A new transport, scheduler, dependency, codec operation, scanner, signer, or container framework.
|
||||
- Live provider/client/firewall evidence or Connection Server image remediation.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Keep the existing provider fixture and production path; overlap its UDP sender with the public QUIC receiver.
|
||||
- Apply fixed-seed impairment before provider UDP injection and derive delivery statistics from decoded payload sequence and timestamps.
|
||||
- Use native ENet acknowledgement timing for RTT instead of doubling one-way completion.
|
||||
- Use OS process user plus system CPU for the isolated qualification command; retain memory, goroutine, and allocation series separately.
|
||||
- Build both Linux architectures with `CGO_ENABLED=0`, `GOWORK=off`, `-trimpath`, no VCS stamping, and an empty build ID, then inspect ELF and embedded Go settings.
|
||||
- Delete the dead exported pacer rather than consolidate it with the sole production `fairPacer`.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Short RTT smoke runs contain ENet smoothing history] → Classify the metric as acknowledged transport RTT and enforce profile tolerances on the full frozen run.
|
||||
- [Process CPU includes the bounded fixture/client harness] → Run only the named qualification test in an isolated process and label the scope exactly; never call it host-wide or binary-only CPU.
|
||||
- [No qualifying vulnerability scanner is installed] → Record unscanned status and deterministic dependency/artifact evidence without zero-finding claims.
|
||||
@@ -0,0 +1,25 @@
|
||||
## Why
|
||||
|
||||
The prior Phase 3C-G artifacts measured serialized simulator timing, runtime CPU capacity, and a Darwin test binary rather than the actual deployable gateway candidate. VER-009, VER-010, VER-015, and OPS-009 require observed production traversal, bounded resource evidence, and exact artifact provenance before engineering exit.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Drive impairment concurrently through the source-shaped provider UDP, native recovery, bounded queue, production pacer, QUIC, and public decoder.
|
||||
- Measure RTT from actual Apollo ENet acknowledgements, one-way latency and jitter from delivery observations, and CPU from isolated process user/system consumption.
|
||||
- Remove the unused legacy pacer so qualification and production share one scheduler.
|
||||
- Build and inspect reproducible pure-Go Linux amd64 and arm64 gateway artifacts.
|
||||
- Report dependency, scanner, architecture, and security evidence only when actually generated.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `gateway-deployment-artifact`: Reproducible, inspectable Linux gateway build and evidence requirements.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `gateway-qualification`: Replace serialized/synthetic timing and CPU-capacity evidence with actual bounded traversal and process-consumption observations.
|
||||
|
||||
## Impact
|
||||
|
||||
This affects only the GPLv3 Data Plane qualification harness, resource evidence, production scheduler inventory, and gateway packaging target. It adds no dependency, cgo, sidecar, codec operation, direct provider route, Server dependency, or proprietary source. Live Apollo, macOS-client, physical-firewall, promotion scanning/signing, and Connection Server Phase 3C-C images remain outside this deterministic gate.
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Reproducible pure-Go Linux gateway artifacts
|
||||
The candidate SHALL build the gateway with the normal immutable Protocol module boundary for Linux amd64 and arm64 using `CGO_ENABLED=0`, deterministic path/VCS/build-ID settings, and no sidecar. Two independent builds of each architecture MUST be byte-identical.
|
||||
|
||||
#### Scenario: Both Linux architectures are built
|
||||
- **WHEN** the canonical gateway Linux target runs twice from the same frozen source and dependency inputs
|
||||
- **THEN** both amd64 and arm64 outputs are byte-identical pure-Go ELF executables with matching embedded GOOS, GOARCH, and cgo settings
|
||||
|
||||
### Requirement: Artifact evidence is inspected and truthful
|
||||
Candidate evidence SHALL record exact source and Protocol revisions, artifact hashes, architecture, embedded dependency inventory, container configuration when built, and the actual scanner/signing status. It MUST NOT claim an SBOM, vulnerability result, signature, image architecture, or deployment that was not produced and inspected.
|
||||
|
||||
#### Scenario: Supplemental scanner is unavailable
|
||||
- **WHEN** no qualifying vulnerability scanner is available in the frozen environment
|
||||
- **THEN** the artifact remains explicitly unscanned, deterministic compiler/dependency/boundary evidence is retained, and no zero-finding security claim is emitted
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Fixed media processing qualification
|
||||
The qualification harness SHALL drive pinned-mTLS Apollo management, encrypted RTSP, ENet, and provider UDP through native source validation, `readUDPMedia`, recovery/FEC, bounded production queues, the production fair pacer, Verse framing/QUIC, and a public or independent client decoder for 1080p60 H.264 at 20 Mbps, 1440p120 HEVC at 50 Mbps, and 4K60 HEVC at 80 Mbps. After a recorded warm-up, the frozen candidate SHALL run each profile for ten wall-clock minutes, preserve encoded payload bytes, retain every monotonic processing sample plus bounded process CPU, memory, goroutine, allocation, and provider-queue observations, and report count, min, median, p90, p95, p99, max, mean, standard deviation, timing overhead, and observed bitrate. Processing begins at complete provider-unit receipt and ends at QUIC handoff, excluding client transit and pacing. CPU SHALL be actual OS user plus system consumption of the isolated gateway qualification process and MUST NOT be GOMAXPROCS-times-wall capacity or unrelated parent test work. Any bypass, payload mutation, wall-duration violation, bitrate outside both lower and upper bounds, or p95 above 5 ms SHALL fail.
|
||||
|
||||
#### Scenario: Healthy fixed profile
|
||||
- **WHEN** a frozen candidate runs one fixed profile for the normative duration in the isolated qualification command
|
||||
- **THEN** the harness emits compressed raw path and resource samples plus a summary tied to the exact command, CPU scope, topology, source commit, immutable Protocol version, environment, and payload hash
|
||||
|
||||
#### Scenario: Processing gate failure
|
||||
- **WHEN** any production path stage lacks a per-traversal observation, payload integrity fails, duration or bitrate bounds fail, measured p95 exceeds 5 ms, or idle capacity is reported as consumed CPU
|
||||
- **THEN** the qualification command exits unsuccessfully without recording a passing candidate
|
||||
|
||||
### Requirement: Bounded impairment qualification
|
||||
The harness SHALL run exactly the baseline, latency, jitter, loss, reorder, and constrained Section 7.2 profiles once by applying fixed-seed impairment at the source-shaped provider network boundary while traffic concurrently traverses the production gateway path. Baseline SHALL cover all three media profiles and the other profiles SHALL cover 1080p60. The harness MUST NOT serialize a complete provider-to-client traversal per source unit. Each artifact SHALL retain raw impairment and queue observations and record tool version, exact command/configuration, environment, candidate commit, immutable Protocol version, direction, queue discipline, topology, fixed seed, observed one-way latency, acknowledged Apollo ENet RTT, jitter, loss, reorder, throughput, drops, and capacity-step statistics.
|
||||
|
||||
#### Scenario: Complete six-profile run
|
||||
- **WHEN** the frozen candidate runs impairment qualification
|
||||
- **THEN** one result exists for each named profile, configured jitter remains observable within reviewed fixed-seed tolerances, RTT comes from real request/response acknowledgement timing, and raw statistics come from actual traversal
|
||||
|
||||
#### Scenario: Unsupported or unbounded configuration
|
||||
- **WHEN** a profile name, packet count, queue bound, loss, reorder, or bandwidth step falls outside the fixed catalog
|
||||
- **THEN** the harness rejects it before allocating or running traffic
|
||||
@@ -0,0 +1,18 @@
|
||||
## 1. Qualification observations
|
||||
|
||||
- [x] 1.1 Reproduce synthetic doubled one-way RTT and serialized traversal
|
||||
- [x] 1.2 Overlap source-shaped provider sends with public QUIC receive and retain actual delivery observations
|
||||
- [x] 1.3 Measure RTT from Apollo ENet acknowledgements and verify fixed-seed jitter
|
||||
- [x] 1.4 Replace CPU capacity with isolated OS process consumption and prove idle/work/parent isolation
|
||||
|
||||
## 2. Production and packaging
|
||||
|
||||
- [x] 2.1 Prove the legacy pacer has no production caller and delete it
|
||||
- [x] 2.2 Build and inspect byte-reproducible pure-Go Linux amd64 and arm64 gateway artifacts
|
||||
- [x] 2.3 Generate final dependency/artifact evidence and record scanner/signing status truthfully
|
||||
|
||||
## 3. Frozen verification
|
||||
|
||||
- [x] 3.1 Pass focused race/resource/impairment tests and complete Data Plane verification
|
||||
- [x] 3.2 Freeze immutable Protocol consumer inputs and run corrected Section 7 qualification once
|
||||
- [x] 3.3 Retain raw artifacts and explicit live Apollo/macOS/firewall deferral
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-30
|
||||
@@ -0,0 +1,29 @@
|
||||
## Context
|
||||
|
||||
The native Apollo session and production QUIC gateway already quiesce media before terminal delivery. A public independent-client test proved that immediate `CloseWithError` can overtake the queued reliable stream frame, while waiting for client connection closure leaves tunnel ownership unbounded.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Deliver one terminal event before gateway-owned closure.
|
||||
- Bound closure when a client remains open or omits the receipt.
|
||||
- Preserve cleanup, input release, reservation, and durable state behavior.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- A generic acknowledgement or lifecycle framework.
|
||||
- Any Apollo protocol, media, Server authority, or dependency change.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Reuse Protocol `control.ack.v1` type `0x03` as an empty terminal receipt.
|
||||
- Hold the receipt-state lock across the terminal write, arm one receipt slot only after a successful write, and consume it in the gateway rather than provider feedback.
|
||||
- Wait at most two seconds for receipt, then close and clean up regardless.
|
||||
- Serialize the bounded native event queue and evict one older feedback item only when necessary to retain a terminal event.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Client omits receipt] → Close at the two-second bound and retain durable cleanup behavior.
|
||||
- [Feedback queue is saturated] → Sacrifice one older nonterminal feedback event rather than lose terminal ownership.
|
||||
- [Receipt is malformed, duplicate, or early] → Fail the session closed without provider mutation.
|
||||
@@ -0,0 +1,24 @@
|
||||
## Why
|
||||
|
||||
P3C-018, P3C-019, P3C-021, and P3C-027 require terminal feedback, bounded cleanup, durable state, and explicit input release. A public independent-client regression proved that immediate QUIC closure loses the terminal event, while the old behavior left the tunnel open until the client closed it.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Consume the Protocol-owned terminal receipt on `control.ack.v1` inside the gateway rather than forwarding it to Apollo.
|
||||
- Quiesce media before terminal delivery and close the gateway-owned tunnel after receipt or a bounded receipt deadline.
|
||||
- Guarantee a terminal event survives saturation of the bounded native feedback queue.
|
||||
- Preserve provider cleanup, reservation release, reconnectable disconnect reporting, and `cleanup_pending`.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
None.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `provider-session-lifecycle`: Make terminal delivery and gateway-owned bounded closure executable rather than dependent on client connection closure.
|
||||
|
||||
## Impact
|
||||
|
||||
The pure-Go GPLv3 gateway control and native Apollo session paths change. The Protocol repository remains the wire-contract owner; the Server remains the durable authority. No cgo, sidecar, direct provider route, decode/transcode path, dependency, or proprietary source is introduced. Failure to receive a valid receipt before the bound is a hard session close, not a fallback.
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Provider terminal events end forwarding
|
||||
Encrypted provider termination and unexpected provider disconnect SHALL quiesce provider ingestion and queued/new media forwarding before the existing reliable typed terminal event is delivered. The client SHALL return the Protocol-owned terminal receipt after decoding that event. The gateway SHALL close the Verse tunnel after that receipt or a bounded receipt deadline even when the client keeps the connection open, release the session reservation, and report the appropriate durable provider/session state. The receipt MUST be consumed by the gateway and MUST NOT be forwarded to the provider. A fixed drain delay MUST NOT stand in for reliable control delivery.
|
||||
|
||||
#### Scenario: Host termination closes the tunnel
|
||||
- **WHEN** the native provider emits an authenticated termination event and an independent client returns its terminal receipt
|
||||
- **THEN** queued and newly injected media cannot cross the Verse transport after observation, and the gateway closes the tunnel, releases the reservation, and completes the durable lifecycle transition
|
||||
|
||||
#### Scenario: Unexpected provider disconnect is reconnectable
|
||||
- **WHEN** required provider transport disconnects without acknowledged provider termination
|
||||
- **THEN** forwarding stops, the final typed disconnect reaches the client, and the Server receives the existing reconnectable lifecycle state rather than a termination claim
|
||||
|
||||
#### Scenario: Client omits terminal receipt
|
||||
- **WHEN** the terminal event is written but the client remains open without returning a valid receipt
|
||||
- **THEN** the gateway closes the tunnel at the bounded receipt deadline and continues cleanup without restoring media forwarding
|
||||
|
||||
### Requirement: Cleanup failure remains durable
|
||||
Gateway cleanup MUST preserve `cleanup_pending` when provider input release, transport cleanup, authorized cancellation, or durable reporting fails.
|
||||
|
||||
#### Scenario: Terminal cleanup fails
|
||||
- **WHEN** a provider terminal event is handled but required cleanup cannot complete
|
||||
- **THEN** the session is not reported reusable and durable state remains cleanup pending
|
||||
@@ -0,0 +1,16 @@
|
||||
## 1. Regressions
|
||||
|
||||
- [x] 1.1 Reproduce terminal event loss with an independent QUIC client and immediate gateway closure
|
||||
- [x] 1.2 Reproduce terminal loss behind a saturated native feedback queue
|
||||
|
||||
## 2. Lifecycle repair
|
||||
|
||||
- [x] 2.1 Consume the scoped terminal receipt without provider forwarding
|
||||
- [x] 2.2 Close acknowledged and non-acknowledged terminal tunnels within bounds
|
||||
- [x] 2.3 Preserve media quiescence, reservation release, durable state, and cleanup-pending
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [x] 3.1 Pin the final immutable Protocol version and pass focused lifecycle/race/resource checks
|
||||
- [x] 3.2 Pass complete Data Plane verification and the frozen normative Section 7 qualification
|
||||
- [x] 3.3 Record that deterministic fixtures do not prove live Apollo, macOS-client, or physical-firewall interoperability
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-29
|
||||
@@ -0,0 +1,50 @@
|
||||
## Context
|
||||
|
||||
The implementation already contains a source-shaped Apollo fake, native recovery, bounded gateway queues, fair pacing, Verse framing/QUIC, independent client support, lifecycle reporters, and low-cardinality telemetry. Audit defects arise where those existing pieces are bypassed or not connected.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Reuse the existing production path for policy, lifecycle, recovery, telemetry, and qualification.
|
||||
- Delete duplicate qualification simulation.
|
||||
- Preserve all trust, cleanup, and resource bounds.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Add codecs, provider transports, dependencies, or a generic lifecycle/telemetry framework.
|
||||
- Claim live Apollo/macOS/firewall interoperability.
|
||||
- Run the normative qualification before immutable consumer resolution.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Format ANNOUNCE from `ProviderStreamPolicy` using the pinned Moonlight common-c bitrate and codec attributes. H.264 and HEVC with audio enabled are supported; AV1 and audio disabled fail before management/network readiness.
|
||||
- Advertise ordered registered `hevc-opus` and `h264-opus` profiles and delegate policy-compatible selection to generated Protocol intersection behavior. There is no Data Plane capability grammar.
|
||||
- Before `/applist` or `/launch`, validate the selected policy against source-backed Apollo `/serverinfo` codec flags and HEVC luma bounds plus reviewed source limits for dimensions, frame rate, bitrate, and audio where Apollo exposes no dynamic field. Reject rather than cap or downgrade.
|
||||
- Quiesce provider media sockets and the bounded forwarding path before emitting an existing terminal or disconnected event. Reuse current cleanup/release/reporting machinery and its cleanup-pending result; final control delivery has no fixed drain delay.
|
||||
- On a full audio FEC map, evict the oldest block according to existing block ordering and increment existing drop telemetry.
|
||||
- Carry provider receipt and queue-enqueue timestamps through the existing bounded media value. Queue residence, active processing, and scheduler pacing are sampled separately, once per complete provider media unit.
|
||||
- Sample existing process counters at heartbeat time; calculate rate from byte and monotonic-time deltas while leaving configured capacity in registration.
|
||||
- Build qualification on source-shaped pinned-mTLS Apollo management, encrypted RTSP, ENet, and provider UDP plus the public QUIC client path. Per-traversal stage deltas replace the standalone codec/parser and arithmetic impairment simulator; short smoke gates freeze the wiring, while normative durations remain deferred.
|
||||
- Preserve the production fair-pacer schedule across short host-timer overshoots so
|
||||
measured allocation can catch up within the already bounded provider queue
|
||||
instead of accumulating timer granularity as lost capacity.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Apollo cannot represent disabled audio truthfully] → Reject it rather than silently streaming stereo.
|
||||
- [Provider event races with queued or new media] → Quiesce ingestion and serialize forwarding with terminal event delivery before cleanup.
|
||||
- [Counter reset or zero elapsed time] → Emit zero measured rate and establish a new baseline.
|
||||
- [Corrected qualification is more expensive] → Run only short smoke tests until the immutable candidate is frozen.
|
||||
- [Apollo exposes incomplete dynamic capability detail] → Use only source-backed fields and explicit reviewed bounds; never infer support by silent capping.
|
||||
- [Pacer catch-up can emit a short burst after timer overshoot] → Clamp schedule
|
||||
debt to five milliseconds in addition to the existing 16-packet provider
|
||||
queue.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Land focused red/green repairs locally, verify through the temporary Protocol workspace, preserve old artifacts as superseded, and stop at the publication boundary. After a separately authorized immutable Protocol release is pinned, freeze inputs and run the corrected normative qualification once.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
Fresh audit evidence shows the gateway ignores the immutable launch policy, leaves tunnels alive after provider termination/disconnect, can permanently stall audio after sustained loss, reports configured capacity as measured egress, and qualifies a standalone simulator instead of the production path.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Negotiate registered decode profiles through the shared Protocol intersection, apply the effective policy to Apollo ANNOUNCE, and reject client or provider/source mismatch before launch (P3C-009, P3C-016, P3C-038).
|
||||
- Quiesce media immediately on provider termination/disconnect, deliver the final typed event reliably, and preserve bounded tunnel and durable lifecycle transitions including cleanup-pending (P3C-018–021, P3C-027).
|
||||
- Evict bounded stale audio FEC blocks so newer recoverable media continues (P3C-001, P3C-026).
|
||||
- Derive heartbeat egress and semantically separated queue, processing, and pacing observations from the production path (P3C-022, P3C-028).
|
||||
- Replace standalone processing/impairment simulation with a driver around the source-shaped provider, production queues/pacer/framing, QUIC, and an independent client (P3C-029–033, VER-008, VER-010).
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `apollo-stream-policy`: Native Apollo launch consumes the authenticated effective stream policy without downgrade.
|
||||
- `provider-session-lifecycle`: Provider terminal events close forwarding and report the correct durable lifecycle outcome.
|
||||
- `audio-fec-resilience`: Bounded Apollo audio recovery continues after permanently incomplete blocks.
|
||||
- `gateway-heartbeat-telemetry`: Authenticated heartbeat telemetry reports observed traffic and provider-path measurements.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `gateway-qualification`: Normative evidence must traverse the production gateway path and retain raw resource, impairment, fairness, cap, and convergence observations.
|
||||
|
||||
## Impact
|
||||
|
||||
The native Apollo adapter, transport lifecycle, audio FEC state, heartbeat sampling, qualification driver, focused fixtures, and canonical qualification spec change. No dependency, cgo, sidecar, codec operation, direct provider route, or live interoperability claim is added.
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Apollo launch consumes the effective policy
|
||||
The native Apollo backend SHALL derive ANNOUNCE resolution, frame rate, supported codec, selected bitrate, and audio profile from authenticated `ProviderSessionWork`, and MUST NOT substitute local defaults.
|
||||
|
||||
#### Scenario: Supported HEVC policy reaches Apollo
|
||||
- **WHEN** provider work selects HEVC at 2560×1440, 120 FPS, 40000 Kbps, with audio enabled
|
||||
- **THEN** the encrypted ANNOUNCE carries those settings and the source-backed HEVC and bitrate attributes
|
||||
|
||||
### Requirement: Provider policy cannot downgrade
|
||||
The gateway MUST use the generated Protocol intersection to select only a registered profile compatible with the immutable policy. It MUST reject invalid, unsupported, no-overlap, downgrade, audio-disabled, AV1, or provider/source-mismatched Apollo policy before `/applist`, `/launch`, or provider readiness because the current native path cannot truthfully honor those combinations.
|
||||
|
||||
#### Scenario: Unsupported policy fails closed
|
||||
- **WHEN** authenticated provider work selects audio disabled, AV1, a codec outside the registered peer intersection, or a resolution, frame rate, bitrate, audio, or codec combination outside source-backed Apollo support
|
||||
- **THEN** setup fails before application discovery or launch without falling back to H.264, stereo, a cap, or another local default
|
||||
|
||||
#### Scenario: Independent peers negotiate one registered profile
|
||||
- **WHEN** a production gateway and independent client advertise overlapping registered H.264 or HEVC profiles
|
||||
- **THEN** admission selects the first policy-compatible common profile using shared Protocol behavior
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Bounded audio FEC state advances after loss
|
||||
The Apollo audio recovery window SHALL remain bounded and SHALL evict the oldest incomplete block when accepting a newer block would otherwise be rejected.
|
||||
|
||||
#### Scenario: Newer complete block follows sustained loss
|
||||
- **WHEN** more than the bounded number of permanently incomplete audio blocks arrive before a complete newer block
|
||||
- **THEN** the oldest stale state is dropped, drop telemetry advances, and the newer encoded payload is relayed unchanged
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Heartbeat egress is observed
|
||||
Authenticated gateway heartbeat telemetry SHALL calculate egress from monotonic transmitted-byte deltas over monotonic elapsed time and MUST NOT report configured capacity as measured traffic.
|
||||
|
||||
#### Scenario: Controlled byte delta is sampled
|
||||
- **WHEN** transmitted bytes increase by a known amount during a known interval
|
||||
- **THEN** heartbeat egress equals the measured rate while configured capacity remains a separate registration value
|
||||
|
||||
### Requirement: Required telemetry remains bounded and low cardinality
|
||||
The established authenticated path SHALL expose observed bytes, packets, drops, RTT, loss, jitter, queue delay, processing delay, pacing, reconnect, and provider state without session, route, credential, or payload labels.
|
||||
|
||||
#### Scenario: Telemetry snapshot is published
|
||||
- **WHEN** the gateway emits a heartbeat after forwarding traffic
|
||||
- **THEN** it carries the bounded process-level observations and no high-cardinality or secret-bearing value
|
||||
|
||||
### Requirement: Production delays have disjoint sample semantics
|
||||
Queue delay SHALL measure provider-queue residence, processing delay SHALL measure provider recovery plus framing and QUIC handoff work excluding queue and pacing, and pacing delay SHALL measure scheduler waiting only. The gateway SHALL advance processing samples once per complete provider media unit even when it emits multiple Verse frames.
|
||||
|
||||
#### Scenario: Known production waits
|
||||
- **WHEN** one provider media unit has controlled enqueue, processing, and pacing intervals and fragments across multiple frames
|
||||
- **THEN** each cumulative total reports only its intended interval and exactly one processing sample is retained through authenticated Server persistence
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Fixed media processing qualification
|
||||
The qualification harness SHALL drive pinned-mTLS Apollo management, encrypted RTSP, ENet, and provider UDP through native source validation, `readUDPMedia`, recovery/FEC, bounded production queues, the production fair pacer, Verse framing/QUIC, and a public or independent client decoder for 1080p60 H.264 at 20 Mbps, 1440p120 HEVC at 50 Mbps, and 4K60 HEVC at 80 Mbps. After a recorded warm-up, the frozen candidate SHALL run each profile for ten wall-clock minutes, preserve encoded payload bytes, retain every monotonic processing sample plus bounded CPU, memory, goroutine, allocation, and provider-queue observations, and report count, min, median, p90, p95, p99, max, mean, standard deviation, timing overhead, and observed bitrate. Processing begins at complete provider-unit receipt and ends at QUIC handoff, excluding client transit. Any bypass, payload mutation, wall-duration violation, bitrate outside both lower and upper bounds, or p95 above 5 ms SHALL fail.
|
||||
|
||||
#### Scenario: Healthy fixed profile
|
||||
- **WHEN** a frozen candidate runs one fixed profile for the normative duration
|
||||
- **THEN** the harness emits compressed raw path and resource samples plus a summary tied to the exact command, topology, source commit, immutable Protocol version, environment, and payload hash
|
||||
|
||||
#### Scenario: Processing gate failure
|
||||
- **WHEN** any production path stage lacks a per-traversal observation, payload integrity fails, duration or bitrate bounds fail, or measured p95 exceeds 5 ms
|
||||
- **THEN** the qualification command exits unsuccessfully without recording a passing candidate
|
||||
|
||||
### Requirement: Bounded impairment qualification
|
||||
The harness SHALL run exactly the baseline, latency, jitter, loss, reorder, and constrained Section 7.2 profiles once by applying impairment at the source-shaped provider UDP boundary while traffic traverses the production gateway path. Baseline SHALL cover all three media profiles and the other profiles SHALL cover 1080p60. Each artifact SHALL retain raw impairment and queue observations and record tool version, exact command/configuration, environment, candidate commit, immutable Protocol version, direction, queue discipline, topology, fixed seed, and observed RTT, jitter, loss, reorder, throughput, drops, and capacity-step statistics.
|
||||
|
||||
#### Scenario: Complete six-profile run
|
||||
- **WHEN** the frozen candidate runs impairment qualification
|
||||
- **THEN** one result exists for each named profile, with no Cartesian expansion and with raw observed rather than configured statistics from the real traversal
|
||||
|
||||
#### Scenario: Unsupported or unbounded configuration
|
||||
- **WHEN** a profile name, packet count, queue bound, loss, reorder, or bandwidth step falls outside the fixed catalog
|
||||
- **THEN** the harness rejects it before allocating or running traffic
|
||||
|
||||
### Requirement: Fairness and cap qualification
|
||||
The harness SHALL exercise the production fair pacer with eight equal-tier synthetic sessions for the required 60-second virtual interval, retain every per-flow and aggregate observation, report every share error and Jain's fairness index, and fail above 10% share error. It SHALL apply 25% and 50% capacity steps, measure convergence of observed allocation rather than first delivery, fail convergence beyond ten virtual seconds, and fail aggregate egress above 105% of the cap over any rolling five-second window.
|
||||
|
||||
#### Scenario: Equal-tier and capacity-step evidence
|
||||
- **WHEN** the frozen candidate runs scheduler qualification
|
||||
- **THEN** the artifact contains raw per-flow bytes, aggregate-cap series, share errors, Jain's index, measured allocation convergence, and rolling cap observations derived from the production pacer
|
||||
|
||||
### Requirement: Honest qualification boundary
|
||||
Qualification artifacts SHALL contain no provider endpoint, credential, clipboard text, input payload, secret, or raw media content and SHALL make no claim of live Apollo/macOS/firewall interoperability. The harness SHALL add no codec operation, production dependency, cgo, sidecar, direct provider route, or duplicate processing/impairment simulator. Deterministic smoke evidence SHALL remain distinct from the single normative run on the frozen immutable consumer candidate.
|
||||
|
||||
#### Scenario: Deterministic evidence publication
|
||||
- **WHEN** qualification completes
|
||||
- **THEN** the manifest labels fake-provider, path impairment, and local processing evidence separately and leaves live interoperability deferred-owner-e2e
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Provider terminal events end forwarding
|
||||
Encrypted provider termination and unexpected provider disconnect SHALL quiesce provider ingestion and queued/new media forwarding before the existing reliable typed terminal event is delivered, close the Verse tunnel within a bounded interval, release the session reservation, and report the appropriate durable provider/session state. A fixed drain delay MUST NOT stand in for reliable control delivery.
|
||||
|
||||
#### Scenario: Host termination closes the tunnel
|
||||
- **WHEN** the native provider emits an authenticated termination event
|
||||
- **THEN** queued and newly injected media cannot cross the Verse transport after observation, and the client tunnel, reservation, and durable lifecycle transition complete
|
||||
|
||||
#### Scenario: Unexpected provider disconnect is reconnectable
|
||||
- **WHEN** required provider transport disconnects without acknowledged termination
|
||||
- **THEN** forwarding stops and the Server receives the existing reconnectable lifecycle state rather than a termination claim
|
||||
|
||||
### Requirement: Cleanup failure remains durable
|
||||
Gateway cleanup MUST preserve `cleanup_pending` when provider input release, transport cleanup, authorized cancellation, or durable reporting fails.
|
||||
|
||||
#### Scenario: Terminal cleanup fails
|
||||
- **WHEN** a provider terminal event is handled but required cleanup cannot complete
|
||||
- **THEN** the session is not reported released or reusable and durable state remains cleanup pending
|
||||
@@ -0,0 +1,24 @@
|
||||
## 1. Native Policy and Media
|
||||
|
||||
- [x] 1.1 Drive supported immutable policy values into encrypted Apollo ANNOUNCE
|
||||
- [x] 1.2 Negotiate registered profiles through shared Protocol intersection behavior
|
||||
- [x] 1.3 Reject unsupported client or provider/source policy before `/applist`, `/launch`, or readiness
|
||||
- [x] 1.4 Evict oldest incomplete audio FEC state and pass sustained-loss relay regression
|
||||
|
||||
## 2. Lifecycle and Telemetry
|
||||
|
||||
- [x] 2.1 Quiesce queued and new media before terminal event delivery and report durable state
|
||||
- [x] 2.2 Preserve cleanup-pending on terminal cleanup failure
|
||||
- [x] 2.3 Report measured heartbeat egress and separately sampled queue, processing, and pacing telemetry
|
||||
|
||||
## 3. Qualification Path
|
||||
|
||||
- [x] 3.1 Add a red-to-green native UDP-to-public-client production-path smoke gate with per-traversal stage evidence
|
||||
- [x] 3.2 Remove direct native internals, private QUIC/parser, and arithmetic impairment shortcuts
|
||||
- [x] 3.3 Retain raw processing, impairment, fairness, cap, convergence, queue, and resource observations
|
||||
- [x] 3.4 Pass short fixed-profile, impairment, fairness, race, parser fuzz, and resource smoke checks
|
||||
|
||||
## 4. Immutable Freeze
|
||||
|
||||
- [x] 4.1 Pin and verify a separately published never-reused Protocol version from an empty cache
|
||||
- [x] 4.2 Freeze all normative inputs and run the corrected Section 7 qualification exactly once
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-30
|
||||
@@ -0,0 +1,33 @@
|
||||
## Context
|
||||
|
||||
`FragmentPayload` currently stops at 16 × 1,179 bytes and the independent test client assumes ordered fragments. Native Apollo output enters count-only buffered channels, so realistic complete frames have neither a byte ceiling nor an explicit residence bound.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Implement Protocol datagram-v2 for complete encoded frames up to 1 MiB.
|
||||
- Reassemble bounded duplicate/reordered QUIC datagrams independently.
|
||||
- Bound native video queue count, bytes, and residence time while retaining latest-frame replacement and drop telemetry.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Codec inspection, retransmission, provider fallback, generic queue/transport APIs, or Server behavior changes.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Keep the existing `Frame`/QUIC path and add version-aware encode/decode rather than a second transport.
|
||||
- Use one sequence per provider frame and the Protocol 1,177-byte fragment size.
|
||||
- Keep the existing native video channel at 16 entries, add exact atomic byte
|
||||
accounting capped at 4 MiB, and use per-entry timers for the 250 ms residence
|
||||
bound. This matches the Protocol's reviewed incomplete-unit timeout and covers
|
||||
bounded keyframe serialization; the transport performs a final stale check.
|
||||
- Audio and events keep their independent existing limits.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Latest-frame eviction drops decodable dependencies] → preserve truthful drops and existing IDR feedback; never grow memory or block every session.
|
||||
- [Large frames multiply fragment sends] → cap both complete bytes and fragment count before allocation.
|
||||
- [Expiry races with dequeue or cleanup] → stop each package-private timer on
|
||||
dequeue/replacement, serialize channel expiry and close, and retain the
|
||||
transport stale check.
|
||||
@@ -0,0 +1,24 @@
|
||||
## Why
|
||||
|
||||
The production gateway cannot forward complete encoded video frames larger than 18,864 bytes, and its native video queue is bounded only by entry count. Realistic Phase 3C frame distributions therefore fail before QUIC delivery or can consume unreviewed memory.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Implement the Protocol-owned complete-frame datagram profile and independent bounded client reassembly.
|
||||
- Relay full recovered Apollo frames without mutation or unrelated sequence splitting.
|
||||
- Bound native video queuing by frame count, encoded bytes, and residence time with latest-frame replacement and truthful drops.
|
||||
- Preserve independent audio and event bounds and all no-transcode/provider isolation rules.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `complete-encoded-frame-transport`: Production fragmentation, reassembly, and byte/latency/count-bounded native frame queuing.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
None.
|
||||
|
||||
## Impact
|
||||
|
||||
Gateway framing, native Apollo media queues, QUIC send/receive tests, telemetry, and bounded resource checks. No new dependency or Server change. Requirements: P3C-006–P3C-009, P3C-025, P3C-026, P3C-028, P3C-030, P3C-038, VER-001, VER-006, VER-010.
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Production transport preserves complete encoded frames
|
||||
The gateway SHALL carry each recovered Apollo encoded frame as one Protocol datagram-v2 sequence, preserve exact bytes and frame boundaries through the production media queue, pacer, QUIC transport, and independent reassembler, and reject frames outside Protocol bounds before forwarding.
|
||||
|
||||
#### Scenario: Large source-shaped frame
|
||||
- **WHEN** Apollo UDP/FEC recovers a valid encoded frame above 18,864 bytes within the reviewed maximum
|
||||
- **THEN** the independent client receives one byte-identical frame with the same boundary
|
||||
|
||||
#### Scenario: Invalid fragment stream
|
||||
- **WHEN** fragments are oversized, inconsistent, conflicting duplicates, outside the reorder/state/time bounds, or claim an oversized frame
|
||||
- **THEN** the client emits no partial payload and bounded state is released
|
||||
|
||||
### Requirement: Native video queue has count byte and latency bounds
|
||||
The native provider video queue SHALL retain at most 16 complete frames, at
|
||||
most 4 MiB of encoded frame bytes, and no frame for more than 250 milliseconds.
|
||||
It SHALL replace the oldest entry when full, expire stale entries independently
|
||||
of queue activity, and increment truthful drop telemetry for every replacement
|
||||
or expiry. Cleanup and cancellation MUST stop expiry work and release all queued
|
||||
payload references.
|
||||
|
||||
#### Scenario: Sustained realistic frames
|
||||
- **WHEN** a provider produces realistic variable-size complete frames faster than a slow Verse reader can forward them
|
||||
- **THEN** retained entries, bytes, and age remain within the reviewed per-session limits and newer frames continue to progress
|
||||
|
||||
#### Scenario: Session cleanup
|
||||
- **WHEN** a session terminates, disconnects, or is cancelled with queued video
|
||||
- **THEN** queued frames are released, blocked readers wake, and no media crosses after quiescence
|
||||
|
||||
### Requirement: Other provider queues remain independently bounded
|
||||
Audio and provider event queues SHALL retain independent count and payload bounds and MUST NOT share the video byte budget.
|
||||
|
||||
#### Scenario: Video saturation
|
||||
- **WHEN** the video queue reaches its byte or age bound
|
||||
- **THEN** audio and terminal event delivery retain their existing independent bounded capacity
|
||||
@@ -0,0 +1,20 @@
|
||||
## 1. Red production path
|
||||
|
||||
- [x] 1.1 Add a public Apollo-UDP-to-independent-client regression for complete frames above 18,864 bytes
|
||||
- [x] 1.2 Add malformed, duplicate, reorder, timeout, and maximum-allocation reassembly cases
|
||||
|
||||
## 2. Complete-frame transport
|
||||
|
||||
- [x] 2.1 Implement negotiated datagram-v2 fragmentation and bounded independent reassembly
|
||||
- [x] 2.2 Prove deterministic 1080p60, 1440p120, and 4K60 frame distributions preserve exact bytes and boundaries
|
||||
|
||||
## 3. Native queue bounds
|
||||
|
||||
- [x] 3.1 Add sustained realistic-frame regressions for count, byte, latency, cleanup, cancellation, slow-reader, and amplification bounds
|
||||
- [x] 3.2 Bound the existing native video channel by 16 entries, 4 MiB, and 250 ms with latest-frame replacement and truthful drops
|
||||
- [x] 3.3 Preserve independent bounded audio and terminal event paths
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [x] 4.1 Run focused framing, native media, queue, race, cancellation, and resource checks
|
||||
- [x] 4.2 Run strict OpenSpec validation and the final affected Data Plane verification once
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-30
|
||||
@@ -0,0 +1,28 @@
|
||||
## Context
|
||||
|
||||
The repository already produces reproducible pure-Go Linux binaries and can read exact Go module/build metadata. A standard deterministic document is missing; adding an external SBOM tool is unnecessary for this bounded artifact.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Generate byte-stable SPDX 2.3 JSON with Go standard library encoding.
|
||||
- Describe the repository, Protocol dependency, all resolved modules, both Linux binaries, relationships, hashes, architectures, notices, and truthful licenses.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Vulnerability scanning, signing, image remediation, public release, or inferred license conclusions.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Use a small repository command that reads each binary with
|
||||
`debug/buildinfo`, verifies Linux architecture and cgo settings, and compares
|
||||
embedded module inventories before sorting every package and relationship.
|
||||
- Use fixed SPDX identifiers and a source-date timestamp supplied by the caller; reject dirty/ambiguous inputs rather than embedding current time.
|
||||
- Use `NOASSERTION` for unavailable concluded/declared license evidence and record no vulnerability result.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Go module metadata lacks complete license conclusions] → retain notices and use `NOASSERTION`.
|
||||
- [Artifact paths make output host-dependent] → encode architecture, filename, size, and digest only.
|
||||
- [A hand-built serializer could drift] → validate required SPDX fields and require byte-identical double generation.
|
||||
@@ -0,0 +1,24 @@
|
||||
## Why
|
||||
|
||||
The gateway has reproducible Linux binaries and a dependency inventory but no deterministic standard SBOM, while OPS-009 and the Phase 3C gateway plan require one for engineering exit.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Generate byte-stable SPDX 2.3 JSON using repository, Go module, and artifact metadata.
|
||||
- Record exact source revision, immutable Protocol version/checksum, module relationships, Linux artifact hashes and architectures, and truthful license fields.
|
||||
- Retain notices/provenance and use `NOASSERTION` where license evidence is unavailable.
|
||||
- Keep vulnerability scanning, signing, and Phase 3C-C image remediation explicitly separate.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
None.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `gateway-deployment-artifact`: Require an inspected deterministic standard SBOM for shipped gateway binaries.
|
||||
|
||||
## Impact
|
||||
|
||||
Data Plane packaging tooling, deterministic tests, Makefile targets, canonical deployment-artifact OpenSpec, and evidence records. Uses Go standard library only; no new dependency. Requirements: SYS-019, P3C-002, P3C-035, OPS-009, VER-015, VER-017.
|
||||
@@ -0,0 +1,12 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Artifact evidence is inspected and truthful
|
||||
Candidate evidence SHALL record exact source and immutable Protocol revisions/checksums, artifact hashes, architecture, embedded dependency inventory, container configuration when built, and the actual scanner/signing status. It SHALL include one byte-stable SPDX 2.3 JSON SBOM for the shipped Linux gateway artifacts containing the source package, resolved Go modules, dependency and generated-from relationships, artifact hashes and architectures, retained notices/provenance, and truthful license fields using `NOASSERTION` where evidence is unavailable. It MUST NOT claim a vulnerability result, signature, image architecture, deployment, or license conclusion that was not produced and inspected.
|
||||
|
||||
#### Scenario: Deterministic gateway SBOM
|
||||
- **WHEN** the canonical SBOM command runs twice with the same clean source revision, Protocol module/checksum, module graph, source date, and Linux artifacts
|
||||
- **THEN** both SPDX JSON outputs are byte-identical and every declared artifact/module relationship and hash matches the inspected inputs
|
||||
|
||||
#### Scenario: Supplemental scanner is unavailable
|
||||
- **WHEN** no qualifying vulnerability scanner is available in the frozen environment
|
||||
- **THEN** the artifact remains explicitly unscanned and unsigned, the deterministic SBOM/compiler/dependency/boundary evidence is retained, and no zero-finding security claim is emitted
|
||||
@@ -0,0 +1,14 @@
|
||||
## 1. Red deterministic contract
|
||||
|
||||
- [x] 1.1 Add a focused test requiring SPDX 2.3 fields, source/Protocol/module relationships, two architectures, and exact artifact hashes
|
||||
- [x] 1.2 Prove current packaging cannot produce the required standard SBOM
|
||||
|
||||
## 2. Standard-library generator
|
||||
|
||||
- [x] 2.1 Implement bounded deterministic SPDX JSON generation from explicit build and Go module metadata
|
||||
- [x] 2.2 Record truthful license fields, notices/provenance, unscanned status, and no signing claim
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [x] 3.1 Prove byte-stable regeneration and rejection of dirty, missing, mismatched, or ambiguous inputs
|
||||
- [x] 3.2 Reconcile the deployment-artifact canonical spec and run strict validation
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-30
|
||||
@@ -0,0 +1,66 @@
|
||||
## Context
|
||||
|
||||
The current harness sends one fixed 1,179-byte payload per logical sample. It reaches the production path but does not represent encoded frames at 60/120 FPS or exercise realistic fragmentation, reassembly, queue bytes, and keyframe pressure.
|
||||
|
||||
The complete-frame fixture also must preserve the pinned Apollo source schedule. For each frame it derives packets per millisecond from the raw UDP block size at 80% of 1 Gbps, limits source batches to both 64 KiB and 64 packets, and carries the next-send time into the following frame. Waiting is context-cancellable. This is qualification-fixture behavior only; production transport and queue behavior remain unchanged.
|
||||
|
||||
Because the bounded fixture uses loopback rather than a physical 1 Gbps link, v8 writes the first shard of a batch successfully, captures that actual monotonic emission start, and schedules the next batch no earlier than that start plus the current batch's raw-block serialization interval. The persistent schedule carries across frames. A delayed batch therefore remains late instead of collapsing overdue batches into a catch-up burst.
|
||||
|
||||
The retained v6 qualification run passed its then-current checks but is superseded because its tight-loop sender contradicted the pinned Apollo schedule. Private Linux runs 123 and 124 remain failed evidence. One local v8 sustained run passed on Darwin, but it is neither Linux proof nor normative Section 7 evidence.
|
||||
|
||||
The later Darwin non-sustained pre-CI invocation was not green and was not retried. Its 1440p120 profile delivered the exact 6,250,000 bytes in 120 frames plus all 6,483 source and warm-up shards with zero drops, but measured 46,973.13 kbps over an implied approximately 1.0644383 seconds and failed the 5% throughput gate. Private Linux full verification/artifact retention and the replacement v10 normative run remain open.
|
||||
|
||||
Private Linux run 125 at the frozen v8 harness head is retained as failed evidence. Its exact 33-datagram gap between successful fixture writes and production `MediaIngress` equaled the Linux socket's 33 measured kernel UDP drops. The complete-frame queue, fair pacer, QUIC fragmentation, and public decoder were downstream and did not account for the loss.
|
||||
|
||||
The one authorized v8 Section 7 invocation at production candidate `55afea72a1487fa071501615d806e68efc0a436b` was consumed and failed. Its directory `gateway-rc10-55afea7` is retained byte-for-byte with two partial processing files and no manifest. The failure occurred at payload sequence 16801 after 16,834 provider frames had been recovered and enqueued; the provider queue reached 15 entries and dropped one valid frame while source-write and ingress accounting remained balanced at the diagnostic boundary. This attempt is failed evidence and is not eligible for retry or relabeling.
|
||||
|
||||
The production fair pacer previously limited instantaneous recovery to 5 ms by moving an overdue flow's schedule to `now-5ms`, but silently discarded every additional valid scheduling interval. Repeated host stalls therefore accumulated complete frames in the existing provider queue until its 250 ms residence horizon correctly expired one. The repair keeps the 5 ms instantaneous ceiling, carries only the remaining debt up to that existing horizon, and shortens later nominal intervals by at most one twenty-first. That 20/21 interval is exactly 5% above nominal rate; once the debt reaches zero, the flow returns to its unchanged nominal interval. Per-flow debt and the shared nominal fair-share calculation preserve the existing eight-flow fairness and rolling aggregate cap through the existing 25% and 50% capacity changes.
|
||||
|
||||
Private Linux run 127/job 481 at exact source `122080ab342d20585d9a45db0017337b9ece570a` is retained as failed evidence. `TestQualificationLossAndSteppedThroughputBounds` reported the 25% step's 11-second convergence sentinel and a 5,207,475-byte five-second maximum. No artifact was uploaded and the run was not retried. The retained log `/private/tmp/versevdi-gitea-run-127-job-481.log` has SHA-256 `2b368413d4b0e954c64ba6f6dcefb1e165166d773b6d8f84ee372bc2c92ff5f1`. The failure exposed a measurement-unit defect: capacity samples were emitted only after complete logical-payload reassembly and were compared with a payload-derived target even though the pacer reserves encoded public datagram bytes. It did not establish a production pacer defect, and `122080ab` is superseded as a final source candidate.
|
||||
|
||||
Qualification v9 observes every raw public QUIC datagram immediately after the independent client's `ReceiveDatagram` returns and before the existing decoder/reassembler. Capacity convergence and rolling five-second maxima use those monotonic receive times and encoded lengths. Their target bytes per second and five-second cap derive from `qualificationMediaPacerKbps(profile, reduction) * 1000 / 8`. Complete logical-payload observations remain separate and continue to own payload integrity, loss, reorder, latency, throughput, and queue assertions. The first public delivery at or after a step anchors the four consecutive 250 ms windows so an arbitrary control-plane timestamp cannot split the first observed datagram pair. The delivery-after-step boundary, 90%-105% window bounds, ten-second convergence ceiling, and rolling-five-second 105% gate are unchanged.
|
||||
|
||||
Private Linux run 128/job 482 was the single push-triggered attempt at exact source `22433e5c45c179e9d487b59e5d80f1dcf3b285ce`. Linux `make verify`, the sustained gate, strict OpenSpec, deterministic artifact generation, upload, and the clean-checkout step passed. Artifact 28's binaries and SPDX matched the frozen hashes and source metadata. The retained log `/private/tmp/versevdi-gitea-run-128-job-482.log` has SHA-256 `6f5852dce815ba87a55d61aae34cb2fce17a1a62c5ee50e18c0034259ad0a029`. The workflow requested 30 retention days, but Gitea 1.27 floored the positive request delay and the API recorded `2026-08-09T23:18:18+07:00` through `2026-09-07T23:18:18+07:00`, exactly 2,505,600 seconds or 29 elapsed days. The run was not retried. It is passing Linux execution and artifact-byte evidence but retention-nonconforming, so it neither satisfies the private Linux artifact gate nor authorizes normative Section 7. The local 31-day request compensates for verified platform rounding without changing the acceptance threshold; a separately authorized future run must prove an API interval of at least 2,592,000 seconds.
|
||||
|
||||
At exact source `c0e362c0285d267f8af4087d07943822311f60e1`, the first Section 7 process created mode-0750 `gateway-rc10-c0e362c` and stopped before fixture startup because the sandbox denied its required loopback bind. The directory remains empty as environment-boundary evidence. The separately authorized escalated attempt retained `gateway-rc10-c0e362c-a2`, passed the v9 runtime checks, and wrote 16 files with manifest SHA-256 `61ea55140dfe6b37332de03879ac33206dd5d47763d09fb0fc2f65bb1f8e02b4`. Independent review denied normative acceptance: the constrained logical CSV and manifest aggregates did not persist every raw public QUIC datagram receive offset/encoded length, and `fairness.csv.gz` lacked manifest transition offsets. A2 therefore remains runtime-passing but normative-raw-evidence-incomplete, without mutation or relabeling.
|
||||
|
||||
Qualification v10 keeps the logical impairment CSV unchanged and adds only `impairment-constrained-1080p60-h264-wire.csv.gz`. One monotonic epoch is captured before the constrained delivery and transition sequence. The bounded CSV interleaves two explicit transition records with every public datagram observation in monotonic order using `record_type,reduction_percent,transition_after_ns,received_after_ns,encoded_bytes`; transition-only and delivery-only fields remain empty and are validated as such. Its maximum row count is derived from the existing constrained job bound, the frame-fragment count, and the two configured transitions rather than a captured-run row constant. The manifest binds the file name, SHA-256, compressed bytes, total/delivery/transition row counts, timebase, exact transition offsets, and each capacity summary's recomputation source. Each impairment step is recomputed from every delivery at or after its transition through completion, preserving the original v9 classifier semantics even after the next transition. Fairness remains stage-bounded because `runQualificationFleetStage` records each capacity stage as a separate slice; its summaries retain their 25% and 50% offsets relative to the existing fairness CSV epoch.
|
||||
|
||||
The independent parser's caller explicitly selects smoke or normative authority. Normative validation requires exactly 10,000 sent logical units and always enforces the ten-second convergence and 105% rolling-cap gates; retained `sent` data cannot weaken them. Both wire and fairness readers reject compressed input before hashing when it exceeds a writer-derived bound, feed gzip output through a bounded standard-library reader before CSV parsing, and bound fields, offsets, flows, and encoded lengths from the canonical row count, schema, time horizon, and writer values. Aggregate-only v9 data and malformed schema/hash/count/order/transition/length or oversized inputs remain rejected.
|
||||
|
||||
The ingress repair follows reviewed behavior rather than copying implementation source:
|
||||
|
||||
- Apollo `adc5c5a0bd80831ce495434bb16aee2cd4175fb8`, GPL-3.0, `src/stream.cpp:1463-1474,1573-1627`, supplies the 80%-of-1-Gbps raw-block pacing, 64-KiB/64-packet batch cap, and cross-frame send schedule used by the fixture.
|
||||
- Moonlight common-C pin `2ea47752c3051d72a64bcca190024e8b354fa1ef`, GPL-3.0, `src/VideoStream.c:28-35,331-333` and `src/PlatformSockets.c:364-405`, supplies the reviewed 2,048-video-packet receive-buffer request and dedicated receive-thread behavior. The cited `VideoStream.c` blob is byte-identical at the local standalone `703a06946861ff82cd33e5e13c59c1b017f7ded9` checkout.
|
||||
|
||||
The native provider therefore requests `2,048 * 1,072 = 2,195,456` bytes with `SetReadBuffer()` on the connected video socket immediately after dialing it. A setter error aborts setup; an OS-imposed cap is accepted without privilege or getter dependence. A dedicated drain owns a fixed 2,048-slot FIFO pool. Every slot is 1,433 bytes (`apolloMediaMaximumPacket + 1`), so oversized datagrams remain observably invalid rather than being truncated into the accepted range; packet storage is 2,934,784 bytes (about 2.80 MiB) plus fixed index and timestamp metadata. The existing single decrypt/FEC processor consumes those slots. When every slot is occupied, the drain keeps reading into one fixed 1,433-byte scratch buffer and counts each accepted-size discard in both ingress and drop telemetry; oversized datagrams retain the existing rejection semantics. Socket close cancels the blocking read, and media channels close only after the unchanged audio reader, video drain, and video processor exit. Audio and control behavior are unchanged.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Deterministically generate complete variable-size frame units at exact profile frame rates and target bitrates.
|
||||
- Include bounded periodic keyframes while preserving exact aggregate bytes.
|
||||
- Measure the existing production path and independent reassembly with frame-level accounting.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- A real encoder, codec parsing, a second simulator, or a normative run before immutable Protocol publication.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Derive bytes per fixed interval from bitrate and FPS, distribute integer remainder deterministically, and shift bounded bytes into periodic keyframes while keeping the interval total exact.
|
||||
- Carry a deterministic frame index/pattern only in the generated payload bytes; no codec semantics are claimed.
|
||||
- Keep the existing path/impairment/resource driver and change its unit from datagram payload to complete frame.
|
||||
- Keep video decrypt/FEC single-threaded; only the bounded connected-socket drain is separated so crypto stalls cannot become unexplained kernel loss.
|
||||
- Preserve valid scheduling debt after bounded host stalls instead of converting it into provider-queue residence; repay it within the existing fair pacer without a new queue, interface, or configured headroom.
|
||||
- Classify constrained-capacity evidence from actual public datagram observations and configured wire capacity; do not infer transport timing from completed logical frames.
|
||||
- Persist constrained public-wire observations and transition events in one bounded monotonic-offset CSV, recompute impairment summaries from each transition through completion, and bind both impairment and stage-bounded fairness capacity summaries to their retained raw sources.
|
||||
- Select smoke versus normative evidence validation through trusted caller input and bound compressed bytes, decompressed bytes, fields, offsets, flows, and encoded lengths before independent CSV parsing.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Keyframes can exceed queue budget] → use the reviewed 1 MiB frame ceiling and production byte-bound queue.
|
||||
- [Short smoke windows have rounding effects] → assert exact generated totals and report measured duration separately from normative ten-minute gates.
|
||||
- [A stalled video processor exhausts the user-space pool] → keep draining into one fixed scratch buffer and attribute accepted-size overflow to existing ingress/drop counters rather than kernel loss or unbounded allocation.
|
||||
- [Debt repayment creates a burst or aggregate oversubscription] → retain the 5 ms instantaneous ceiling and limit repayment to a 20/21 nominal interval per flow, with every rolling five-second aggregate window bounded to 105%.
|
||||
@@ -0,0 +1,40 @@
|
||||
## Why
|
||||
|
||||
The existing fixed-profile harness treats each 1,179-byte datagram as an encoded frame, so its reported frame rate, frame boundaries, bitrate, queue pressure, and processing evidence do not model the named 60/120 FPS profiles.
|
||||
|
||||
The v6 complete-frame fixture subsequently exposed a source-fidelity defect on ordinary Linux runners: it emitted every UDP shard in one tight loop, unlike pinned Apollo's bounded intra-frame rate and batch schedule. The affected v6 qualification evidence remains retained but is superseded for candidate-readiness purposes.
|
||||
|
||||
Private Linux run 125 then demonstrated a separate production-ingress defect after source pacing was corrected: 33 successful fixture writes missing from `MediaIngress` matched 33 measured kernel UDP drops while decrypt/FEC, queue, pacer, QUIC, and client counters remained downstream of the shortfall.
|
||||
|
||||
The consumed v8 Section 7 attempt at `55afea72` subsequently failed after accumulated host scheduling delays exposed the production pacer's discarded schedule debt beyond its 5 ms instantaneous catch-up allowance. The retained partial evidence remains failed and supersedes `55afea72` as a final executable candidate.
|
||||
|
||||
Private Linux run 127/job 481 at exact source `122080ab342d20585d9a45db0017337b9ece570a` then failed `TestQualificationLossAndSteppedThroughputBounds`: the 25% capacity step reported the 11-second convergence sentinel and 5,207,475 bytes in the measured five-second window. The run produced no artifact and was not retried. Its retained log is `/private/tmp/versevdi-gitea-run-127-job-481.log`, SHA-256 `2b368413d4b0e954c64ba6f6dcefb1e165166d773b6d8f84ee372bc2c92ff5f1`; `122080ab` is superseded as a final source candidate.
|
||||
|
||||
Private Linux run 128/job 482 was the single push-triggered attempt at exact source `22433e5c45c179e9d487b59e5d80f1dcf3b285ce`. Linux `make verify`, the sustained gate, strict OpenSpec, deterministic artifact generation, upload, and the clean-checkout step passed. Artifact 28's binaries and SPDX matched the frozen hashes and source metadata. The retained log `/private/tmp/versevdi-gitea-run-128-job-482.log` has SHA-256 `6f5852dce815ba87a55d61aae34cb2fce17a1a62c5ee50e18c0034259ad0a029`. The workflow requested 30 retention days, but Gitea 1.27 floored the positive request delay and its API scheduled exactly 29 elapsed days. The run was not retried: it is passing Linux execution and artifact-byte evidence but retention-nonconforming, so it does not satisfy the private Linux artifact gate or authorize normative Section 7. The local 31-day request preserves the at-least-30-elapsed-day requirement; a separately authorized future run must prove `expires_at - created_at >= 2,592,000` seconds.
|
||||
|
||||
At exact source `c0e362c0285d267f8af4087d07943822311f60e1`, the first Section 7 attempt created `gateway-rc10-c0e362c` and stopped at the sandbox loopback-bind boundary, leaving that mode-0750 directory empty. The separately authorized escalated attempt `gateway-rc10-c0e362c-a2` then passed the v9 runtime gates and retained 16 files; its manifest has SHA-256 `61ea55140dfe6b37332de03879ac33206dd5d47763d09fb0fc2f65bb1f8e02b4`. Independent review denied normative acceptance because v9 retained only logical impairment rows and aggregate capacity summaries: it did not retain the raw public datagram timestamps/lengths or fairness transition offsets needed to recompute those summaries. Both attempts remain preserved without relabeling; a2 is runtime-passing but normative-raw-evidence-incomplete.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Generate deterministic variable-size encoded frame units at the named frame rates and target bitrates, including bounded keyframes.
|
||||
- Traverse native Apollo recovery, production queues, the production pacer, QUIC framing, and independent reassembly.
|
||||
- Assert frame count/rate, bitrate, exact bytes and boundaries, clean loss attribution, latency, and resource bounds.
|
||||
- Decouple native video socket draining from the single decrypt/FEC processor with a fixed provider-scoped receive pool and request the source-backed video receive-buffer size.
|
||||
- Retain bounded valid per-flow pacing debt after a host stall and repay it at no more than 5% above nominal fair share.
|
||||
- Measure capacity convergence and rolling caps from each raw public QUIC datagram's observed length and receive time while retaining completed logical-payload observations for integrity and traversal results.
|
||||
- Persist the constrained public datagram observations and capacity transitions under one monotonic epoch, bind their hash/counts into the v10 manifest, and retain fairness transition offsets for independent recomputation. Impairment summaries use the full delivery tail after each transition; trusted caller input selects normative validation, and bounded readers reject oversized compressed, decompressed, and field data.
|
||||
- Keep short smoke tests separate and leave all prior normative artifacts unchanged.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
None.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `gateway-qualification`: Fixed-profile evidence measures complete encoded frame units rather than one datagram per frame.
|
||||
|
||||
## Impact
|
||||
|
||||
The qualification harness and its canonical specification, plus the already-reviewed native Apollo video ingress in `gateway/apollo_native.go` and production fair pacer in `gateway/telemetry.go`. This v10 correction changes test-only retained evidence, not production behavior. Downstream complete-frame queues, audio/control ingress, codec/FEC formats, QUIC, dependencies, and public interfaces remain unchanged. No codec operation or normative rerun is included. Requirements: P3C-002, P3C-008, P3C-029, P3C-030, P3C-033, VER-009, VER-010, OPS-015.
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Fixed media processing qualification
|
||||
The qualification harness SHALL drive pinned-mTLS Apollo management, encrypted RTSP, ENet, and provider UDP through native source validation, `readUDPMedia`, recovery/FEC, byte/count/latency-bounded production queues, the production fair pacer, Protocol complete-frame fragmentation, Verse framing/QUIC, and an independent bounded client reassembler for 1080p60 H.264 at 20 Mbps, 1440p120 HEVC at 50 Mbps, and 4K60 HEVC at 80 Mbps. The source fixture SHALL emit deterministic variable-size complete encoded frame units at the named 60/120 FPS rate, preserve exact target bytes over each fixed interval, and include bounded larger keyframes without codec operation. After a recorded warm-up, the frozen candidate SHALL run each profile for ten wall-clock minutes, preserve every frame's bytes and boundary, retain every monotonic processing sample plus bounded provider-queue observations, and report frame count, frame rate, bitrate, count, min, median, p90, p95, p99, max, mean, standard deviation, measured batched monotonic-clock overhead and method, and observed bitrate. Processing begins at complete provider-frame receipt and ends at QUIC handoff, excluding client transit and pacing. Queue delay SHALL measure provider-queue residence, processing SHALL measure gateway work before pacing, and pacing delay SHALL measure scheduler waiting. CPU, heap, allocations, and goroutines SHALL be measured from the isolated gateway process only; CPU SHALL be actual OS user plus system consumption and MUST NOT include idle wall capacity or unrelated parent fixture/client work. Successive profiles SHALL use independent resource-counter baselines. Any bypass, payload or boundary mutation, frame-rate/count mismatch, wall-duration violation, bitrate outside both lower and upper bounds, unexplained clean-path loss, zero or unbounded clock overhead, or p95 above 5 ms SHALL fail.
|
||||
|
||||
Within each complete frame the source fixture SHALL reproduce pinned Apollo's source schedule by deriving packets per millisecond from the raw UDP block size at 80% of 1 Gbps, bounding each source batch to the smaller of 64 KiB or 64 packets, capturing the monotonic batch start immediately after the first successful shard write, scheduling the next batch no earlier than that start plus the current batch's raw-block serialization interval, carrying that schedule across frames, and making pacing waits context-cancellable. A delayed batch SHALL remain late rather than trigger an overdue catch-up burst.
|
||||
|
||||
Native Apollo video ingress SHALL request a 2,195,456-byte socket receive buffer before media ping or worker startup and SHALL drain the connected video socket into a fixed FIFO pool of exactly 2,048 slots before the existing single decrypt/FEC processor. Each slot and the saturation scratch buffer SHALL be `apolloMediaMaximumPacket + 1` bytes so oversized datagrams remain rejected. A full pool SHALL NOT stop socket draining: each successfully read accepted-size discard SHALL increment both media-ingress and media-drop telemetry without allocation, while oversized reads SHALL retain the existing rejection accounting. Socket closure SHALL cancel the video read, and video/audio channels SHALL close only after the audio reader, video drain, and video processor exit. Audio and control ingress SHALL remain unchanged.
|
||||
|
||||
The production fair pacer SHALL retain its 5 ms instantaneous catch-up ceiling. When a flow resumes later than that ceiling, it SHALL carry the remaining valid schedule debt only within the existing 250 ms provider-queue horizon and SHALL repay that debt using an interval no shorter than 20/21 of its nominal equal-tier fair-share interval. It SHALL return to the nominal interval when the debt is repaid. Simultaneous debt across eight equal-tier flows and the existing 25% and 50% capacity changes SHALL preserve the existing share-error contract and SHALL NOT exceed 105% of configured aggregate capacity in any rolling five-second window.
|
||||
|
||||
Capacity-step convergence and rolling-cap evidence SHALL use the monotonic receive time and encoded length of every raw public QUIC datagram observed immediately after the independent client's `ReceiveDatagram` returns and before decode or reassembly. For each reduction, target bytes per second and the five-second cap SHALL derive from the configured public-wire rate, `qualificationMediaPacerKbps(profile, reduction) * 1000 / 8`. Completed logical-payload observations SHALL remain separate and SHALL continue to measure payload integrity, loss, reorder, latency, throughput, and queue behavior. An impairment capacity step SHALL include every delivery observation at or after its recorded transition through constrained-run completion; a later transition SHALL NOT truncate the earlier step's retained tail. It SHALL anchor measurement windows at the first such public delivery, require four consecutive 250 ms windows between 90% and 105% of its target, converge within ten seconds, and remain at or below 105% in every rolling five-second window.
|
||||
|
||||
The constrained profile SHALL retain a bounded gzip CSV containing exactly two capacity-transition records and every observed raw public datagram delivery under one monotonic epoch captured before the constrained sequence. The schema SHALL distinguish transition and delivery records and SHALL contain `record_type`, `reduction_percent`, `transition_after_ns`, `received_after_ns`, and `encoded_bytes`; fields not applicable to a record type SHALL remain empty and SHALL be rejected when populated. The manifest SHALL bind the file name, SHA-256, compressed byte count, total row count, delivery row count, transition row count, monotonic timebase, exact 25% and 50% transition offsets, and each capacity summary's raw recomputation source. The row bound SHALL derive from the configured constrained-job and fragment bounds rather than a prior run's observed row count.
|
||||
|
||||
The retained fairness manifest SHALL bind its 25% and 50% transition offsets to the monotonic epoch of `fairness.csv.gz`. Fairness recomputation SHALL remain bounded to each separately collected `runQualificationFleetStage` capacity slice. An independent parser SHALL be able to reconstruct each stage and reproduce the rolling-five-second maximum, configured cap, and exact two-second fairness convergence from the retained raw files and manifest alone. The parser SHALL select normative versus smoke validation only from trusted caller input; normative validation SHALL require exactly 10,000 sent logical units and SHALL enforce the ten-second convergence and 105% rolling-cap gates unconditionally. Compressed, decompressed, row, field, offset, flow, and encoded-length limits SHALL derive from canonical writer schemas, configured row limits, and canonical run horizons and SHALL be enforced before CSV parsing can allocate an unbounded record. Missing raw-wire evidence; a wrong file hash, size, or count; duplicate or missing transitions; negative or nonmonotonic offsets; invalid encoded lengths; completed-logical-frame substitution; populated not-applicable fields; oversized input; or a summary mismatch SHALL fail qualification evidence acceptance.
|
||||
|
||||
#### Scenario: Healthy fixed profile
|
||||
- **WHEN** a frozen candidate runs one fixed profile for the normative duration in the isolated qualification command
|
||||
- **THEN** the harness emits compressed raw frame/path and gateway-process resource samples plus a summary tied to the exact command, CPU scope, timing-overhead method, topology, source commit, immutable Protocol version, environment, and payload hash
|
||||
|
||||
#### Scenario: Processing gate failure
|
||||
- **WHEN** any production path stage lacks a per-frame observation, stage accounting does not balance, payload or frame boundaries change, duration, frame-rate, frame-count, or bitrate bounds fail, measured p95 exceeds 5 ms, parent work changes gateway CPU, idle capacity is reported as consumed CPU, or timing overhead is absent
|
||||
- **THEN** the qualification command exits unsuccessfully without recording a passing candidate
|
||||
|
||||
#### Scenario: Source-shaped Apollo pacing is preserved
|
||||
- **WHEN** the fixture emits 1,072-byte encrypted video shards with 1,040-byte raw blocks for consecutive complete frames
|
||||
- **THEN** it uses 96 packets per millisecond, batches at most 63 shards, records each batch after its first successful shard write, starts each later batch no earlier than the prior batch's raw serialization interval, carries the schedule into the following frame, and emits no shard after a cancelled pacing wait
|
||||
|
||||
#### Scenario: Video crypto processing stalls
|
||||
- **WHEN** the first video AEAD operation is blocked while a 662-shard keyframe arrives
|
||||
- **THEN** all 662 successful connected-socket reads reach media-ingress accounting before processing resumes, and after release the exact complete frame traverses recovery, the bounded production queue, pacer, QUIC, and independent reassembly
|
||||
|
||||
#### Scenario: Video ingress pool saturates
|
||||
- **WHEN** all 2,048 fixed video slots are occupied
|
||||
- **THEN** accepted-size datagrams are deliberately discarded through the fixed scratch buffer and counted as ingress plus drops, oversized datagrams remain rejected, and cancellation closes every media worker without a race or leak
|
||||
|
||||
#### Scenario: Repeated media-loop host stalls
|
||||
- **WHEN** three approximately 95 ms scheduling debts are introduced at separated completed-public-frame barriers while source recovery continues
|
||||
- **THEN** the pacer limits instantaneous catch-up to 5 ms, repays each remaining debt at no more than 5% above nominal fair share, preserves every frame in exact order and bytes without provider or gateway drops, stays within the existing queue bounds, and closes cleanly on cancellation
|
||||
|
||||
#### Scenario: Capacity measurement crosses a short transition phase
|
||||
- **WHEN** a constrained 1080p flow carries nonzero bounded debt through the approximately 1.572-second 25% phase before the 50% transition
|
||||
- **THEN** convergence and rolling-cap checks use the observed 1,200-byte and 25-byte public datagrams against the configured wire targets, while the separately retained completed-payload observations cannot substitute for transport delivery timing
|
||||
|
||||
#### Scenario: Aggregate-only capacity evidence is retained
|
||||
- **WHEN** a qualification bundle contains logical-frame impairment rows and aggregate capacity summaries but omits raw public-wire rows or fairness transition offsets
|
||||
- **THEN** independent evidence validation rejects the bundle as incomplete even if its in-process runtime assertions passed
|
||||
|
||||
#### Scenario: Raw capacity evidence is independently recomputed
|
||||
- **WHEN** v10 validation reads the retained constrained wire CSV, fairness CSV, and manifest transitions
|
||||
- **THEN** it validates bounded schema, hashes, sizes, counts, monotonic offsets, encoded datagram lengths, and transition uniqueness, then exactly reproduces the full-after-transition impairment targets, four consecutive 250 ms convergence windows, every rolling-five-second maximum, and stage-bounded fairness two-second alignment
|
||||
|
||||
#### Scenario: Retained counts cannot weaken normative gates
|
||||
- **WHEN** a purported normative bundle retains a sent count other than 10,000 or retains an 11-second convergence or over-cap summary
|
||||
- **THEN** validation rejects it regardless of any retained field value, while explicitly selected smoke validation still requires exact raw-summary recomputation
|
||||
|
||||
#### Scenario: Retained CSV exceeds bounded evidence grammar
|
||||
- **WHEN** a wire or fairness gzip exceeds its canonical compressed or decompressed limit or contains an overlong field, out-of-horizon offset, unknown flow, or out-of-range encoded length
|
||||
- **THEN** validation rejects it through the bounded standard-library reader before an unbounded CSV record can be allocated
|
||||
|
||||
### Requirement: Retained private Linux candidate artifact
|
||||
A retained private Linux candidate artifact SHALL have API metadata whose `expires_at - created_at` interval is at least 30 elapsed days (2,592,000 seconds). Workflow intent, cleanup lag, and a local copy SHALL NOT substitute for the recorded API interval. A shorter interval SHALL fail the artifact-retention gate even when execution and artifact bytes pass. The workflow request MAY exceed 30 calendar days only to compensate for verified platform rounding; the acceptance threshold remains at least 30 elapsed days.
|
||||
|
||||
#### Scenario: Platform rounding shortens retention
|
||||
- **WHEN** a private Linux candidate run passes execution and artifact-byte checks but its artifact API metadata records less than 2,592,000 seconds between creation and expiry
|
||||
- **THEN** the artifact-retention gate remains failed until a separately authorized candidate run records an interval of at least 2,592,000 seconds
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user