Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
735d990901 | ||
|
|
9c27a1ebf5 | ||
|
|
079440f7a9 | ||
|
|
7a03644a9d | ||
|
|
0723c10d9a | ||
|
|
519c04e18f | ||
|
|
86a95952b6 | ||
|
|
09f40eb9c7 | ||
|
|
111092becb | ||
|
|
7947ebcc75 | ||
|
|
5cc2d120e7 | ||
|
|
72b3c54ed9 | ||
|
|
67510b65b4 | ||
|
|
cb94f4ad5d | ||
|
|
dc2cbdf4d7 | ||
|
|
48ee082c0b | ||
|
|
12ad2a4daa | ||
|
|
93246c14bf | ||
|
|
e764b0d96d | ||
|
|
a1d68d33e5 | ||
|
|
e5324998d0 | ||
|
|
f981823909 | ||
|
|
bfffea5d2d | ||
|
|
57c5310e67 | ||
|
|
6264e9c2dc | ||
|
|
68608a76a2 | ||
|
|
31f5501f86 | ||
|
|
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:
|
||||
|
||||
+32
-12
@@ -1,19 +1,39 @@
|
||||
# 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`, `src/input.cpp`, `LICENSE`, and `NOTICE`.
|
||||
- Apollo's moonlight-common-c pin
|
||||
`c999436858471dfefa7617af3b7dc03ec1644ce4`, GPLv3: `src/Input.h`,
|
||||
`src/InputStream.c`, and `LICENSE.txt`.
|
||||
- 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,206 @@
|
||||
package gatewaytls
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Material struct {
|
||||
ServerTLS *tls.Config
|
||||
ControlTLS *tls.Config
|
||||
CertificateIdentity string
|
||||
}
|
||||
|
||||
func Load(certFile, keyFile, streamingCAFile, controlCAFile, gatewayID, publicIdentity string) (Material, error) {
|
||||
certificate, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return Material{}, fmt.Errorf("load gateway certificate: %w", err)
|
||||
}
|
||||
if len(certificate.Certificate) == 0 {
|
||||
return Material{}, errors.New("gateway certificate chain is empty")
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(certificate.Certificate[0])
|
||||
if err != nil {
|
||||
return Material{}, errors.New("parse gateway leaf certificate")
|
||||
}
|
||||
certificate.Leaf = leaf
|
||||
if now := time.Now(); now.Before(leaf.NotBefore) || now.After(leaf.NotAfter) {
|
||||
return Material{}, errors.New("gateway leaf certificate is not currently valid")
|
||||
}
|
||||
if !hasUsage(leaf, x509.ExtKeyUsageServerAuth) || !hasUsage(leaf, x509.ExtKeyUsageClientAuth) {
|
||||
return Material{}, errors.New("gateway leaf certificate requires ServerAuth and ClientAuth")
|
||||
}
|
||||
if !gatewayURIAllowed(leaf.URIs, gatewayID) {
|
||||
return Material{}, errors.New("gateway leaf certificate URI identity mismatch")
|
||||
}
|
||||
if !validPublicDNSName(publicIdentity) || !slices.Contains(leaf.DNSNames, publicIdentity) || leaf.VerifyHostname(publicIdentity) != nil {
|
||||
return Material{}, errors.New("gateway public identity must match a DNS SAN")
|
||||
}
|
||||
streamingCAs, err := loadCAPool(streamingCAFile)
|
||||
if err != nil {
|
||||
return Material{}, fmt.Errorf("load streaming CA: %w", err)
|
||||
}
|
||||
controlRoots, err := loadControlRootPool(controlCAFile)
|
||||
if err != nil {
|
||||
return Material{}, fmt.Errorf("load control root: %w", err)
|
||||
}
|
||||
digest := sha256.Sum256(leaf.Raw)
|
||||
return Material{
|
||||
ServerTLS: &tls.Config{
|
||||
MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{certificate},
|
||||
ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: streamingCAs,
|
||||
},
|
||||
ControlTLS: &tls.Config{
|
||||
MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{certificate}, RootCAs: controlRoots,
|
||||
},
|
||||
CertificateIdentity: "sha256:" + hex.EncodeToString(digest[:]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadCAPool(path string) (*x509.CertPool, error) {
|
||||
encoded, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
count := 0
|
||||
for len(bytes.TrimSpace(encoded)) > 0 {
|
||||
block, rest := pem.Decode(encoded)
|
||||
if block == nil {
|
||||
return nil, errors.New("CA PEM contains invalid data")
|
||||
}
|
||||
encoded = rest
|
||||
if block.Type != "CERTIFICATE" {
|
||||
return nil, errors.New("CA PEM contains a non-certificate block")
|
||||
}
|
||||
certificate, parseErr := x509.ParseCertificate(block.Bytes)
|
||||
if parseErr != nil || !certificate.IsCA || certificate.KeyUsage&x509.KeyUsageCertSign == 0 {
|
||||
return nil, errors.New("CA PEM contains a non-CA certificate")
|
||||
}
|
||||
pool.AddCert(certificate)
|
||||
count++
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, errors.New("CA PEM contains no certificate")
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
func loadControlRootPool(path string) (*x509.CertPool, error) {
|
||||
encoded, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var certificates []*x509.Certificate
|
||||
for len(bytes.TrimSpace(encoded)) > 0 {
|
||||
block, rest := pem.Decode(encoded)
|
||||
if block == nil {
|
||||
return nil, errors.New("control trust PEM contains invalid data")
|
||||
}
|
||||
encoded = rest
|
||||
if block.Type != "CERTIFICATE" {
|
||||
return nil, errors.New("control trust PEM contains a non-certificate block")
|
||||
}
|
||||
certificate, parseErr := x509.ParseCertificate(block.Bytes)
|
||||
if parseErr != nil {
|
||||
return nil, errors.New("control trust PEM contains an invalid certificate")
|
||||
}
|
||||
certificates = append(certificates, certificate)
|
||||
}
|
||||
if len(certificates) == 0 {
|
||||
return nil, errors.New("control trust PEM contains no certificate")
|
||||
}
|
||||
|
||||
pool := x509.NewCertPool()
|
||||
allCAs := true
|
||||
for _, certificate := range certificates {
|
||||
if !certificate.IsCA || certificate.KeyUsage&x509.KeyUsageCertSign == 0 {
|
||||
allCAs = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allCAs {
|
||||
for _, certificate := range certificates {
|
||||
pool.AddCert(certificate)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
leaf := certificates[0]
|
||||
if len(certificates) != 1 || leaf.IsCA || !hasUsage(leaf, x509.ExtKeyUsageServerAuth) {
|
||||
return nil, errors.New("control trust PEM must contain CAs or one ServerAuth leaf")
|
||||
}
|
||||
pool.AddCert(leaf)
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
func hasUsage(certificate *x509.Certificate, wanted x509.ExtKeyUsage) bool {
|
||||
for _, usage := range certificate.ExtKeyUsage {
|
||||
if usage == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func gatewayURIAllowed(uris []*url.URL, gatewayID string) bool {
|
||||
if !validUUID(gatewayID) {
|
||||
return false
|
||||
}
|
||||
prefix := "/gateway/" + gatewayID + "/credential/"
|
||||
for _, uri := range uris {
|
||||
if uri == nil || uri.Scheme != "spiffe" || uri.Host != "versevdi" || uri.User != nil || uri.Opaque != "" || uri.RawPath != "" ||
|
||||
uri.RawQuery != "" || uri.ForceQuery || uri.Fragment != "" || uri.RawFragment != "" || !strings.HasPrefix(uri.Path, prefix) {
|
||||
continue
|
||||
}
|
||||
if validUUID(strings.TrimPrefix(uri.Path, prefix)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validPublicDNSName(name string) bool {
|
||||
if len(name) == 0 || len(name) > 253 || net.ParseIP(name) != nil {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(name, ".") {
|
||||
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
for _, character := range []byte(label) {
|
||||
if !(character >= 'a' && character <= 'z' || character >= '0' && character <= '9' || character == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validUUID(value string) bool {
|
||||
if len(value) != 36 || value != strings.ToLower(value) || value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-' {
|
||||
return false
|
||||
}
|
||||
decoded, err := hex.DecodeString(strings.ReplaceAll(value, "-", ""))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, b := range decoded {
|
||||
if b != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
package gatewaytls
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
testGatewayID = "7d0d1308-5e50-45ef-984c-4508ca899f5a"
|
||||
testCredentialID = "ba4d93f8-d692-4210-97db-5a561bfb32d3"
|
||||
)
|
||||
|
||||
type testCA struct {
|
||||
certificate *x509.Certificate
|
||||
key *ecdsa.PrivateKey
|
||||
der []byte
|
||||
}
|
||||
|
||||
func TestLoadSeparatesTrustPoolsAndDerivesGatewayIdentity(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
controlCA := newTestCA(t, "control-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID)
|
||||
leafDER, key := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key)
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
controlFile := writeCertificate(t, "control-ca.pem", controlCA.der)
|
||||
|
||||
material, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, "gateway.example")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
digest := sha256.Sum256(leafDER)
|
||||
if material.CertificateIdentity != "sha256:"+hex.EncodeToString(digest[:]) {
|
||||
t.Fatalf("certificate identity = %q", material.CertificateIdentity)
|
||||
}
|
||||
if material.ServerTLS.MinVersion != tls.VersionTLS13 || material.ControlTLS.MinVersion != tls.VersionTLS13 || material.ServerTLS.ClientAuth != tls.RequireAndVerifyClientCert ||
|
||||
material.ServerTLS.ClientCAs == nil || material.ControlTLS.RootCAs == nil ||
|
||||
len(material.ServerTLS.Certificates) != 1 || len(material.ControlTLS.Certificates) != 1 {
|
||||
t.Fatalf("TLS material = server:%#v control:%#v", material.ServerTLS, material.ControlTLS)
|
||||
}
|
||||
if got := material.ServerTLS.ClientCAs.Subjects(); len(got) != 1 || string(got[0]) != string(streamingCA.certificate.RawSubject) {
|
||||
t.Fatalf("streaming ClientCAs = %x", got)
|
||||
}
|
||||
if got := material.ControlTLS.RootCAs.Subjects(); len(got) != 1 || string(got[0]) != string(controlCA.certificate.RawSubject) {
|
||||
t.Fatalf("control RootCAs = %x", got)
|
||||
}
|
||||
if string(material.ServerTLS.Certificates[0].Certificate[0]) != string(material.ControlTLS.Certificates[0].Certificate[0]) {
|
||||
t.Fatal("server and control TLS did not use the same gateway leaf")
|
||||
}
|
||||
|
||||
trustedControlLeaf, _ := newLeaf(t, controlCA, "control.example", nil, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
|
||||
parsedTrusted, _ := x509.ParseCertificate(trustedControlLeaf)
|
||||
if _, err := parsedTrusted.Verify(x509.VerifyOptions{Roots: material.ControlTLS.RootCAs, DNSName: "control.example"}); err != nil {
|
||||
t.Fatalf("control CA did not verify control server: %v", err)
|
||||
}
|
||||
wrongControlLeaf, _ := newLeaf(t, streamingCA, "control.example", nil, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
|
||||
parsedWrong, _ := x509.ParseCertificate(wrongControlLeaf)
|
||||
if _, err := parsedWrong.Verify(x509.VerifyOptions{Roots: material.ControlTLS.RootCAs, DNSName: "control.example"}); err == nil {
|
||||
t.Fatal("control RootCAs trusted the streaming CA")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTrustsExactSelfSignedControlLeaf(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID)
|
||||
gatewayLeaf, gatewayKey := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, gatewayLeaf, identityCA.der, gatewayKey)
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
|
||||
controlLeaf, controlKey := newSelfSignedServerLeaf(t, net.ParseIP("127.0.0.1"))
|
||||
controlFile := writeCertificate(t, "control-leaf.pem", controlLeaf)
|
||||
material, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, "gateway.example")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if material.ControlTLS.MinVersion != tls.VersionTLS13 {
|
||||
t.Fatalf("ControlTLS.MinVersion = %d, want TLS 1.3", material.ControlTLS.MinVersion)
|
||||
}
|
||||
|
||||
clientCAs := x509.NewCertPool()
|
||||
clientCAs.AddCert(identityCA.certificate)
|
||||
trusted := newControlServer(t, controlLeaf, controlKey, clientCAs)
|
||||
defer trusted.Close()
|
||||
response, err := (&http.Client{Transport: &http.Transport{TLSClientConfig: material.ControlTLS}}).Get(trusted.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("pinned self-signed control leaf handshake: %v", err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("trusted control response status = %d", response.StatusCode)
|
||||
}
|
||||
|
||||
wrongLeaf, wrongKey := newSelfSignedServerLeaf(t, net.ParseIP("127.0.0.1"))
|
||||
wrong := newControlServer(t, wrongLeaf, wrongKey, clientCAs)
|
||||
defer wrong.Close()
|
||||
if _, err := (&http.Client{Transport: &http.Transport{TLSClientConfig: material.ControlTLS}}).Get(wrong.URL); err == nil {
|
||||
t.Fatal("different self-signed leaf with the same SAN completed handshake")
|
||||
}
|
||||
|
||||
mismatchedLeaf, mismatchedKey := newSelfSignedServerLeaf(t, net.ParseIP("127.0.0.2"))
|
||||
mismatchedFile := writeCertificate(t, "mismatched-control-leaf.pem", mismatchedLeaf)
|
||||
mismatchedMaterial, err := Load(certFile, keyFile, streamingFile, mismatchedFile, testGatewayID, "gateway.example")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() mismatched control leaf error = %v", err)
|
||||
}
|
||||
mismatched := newControlServer(t, mismatchedLeaf, mismatchedKey, clientCAs)
|
||||
defer mismatched.Close()
|
||||
if _, err := (&http.Client{Transport: &http.Transport{TLSClientConfig: mismatchedMaterial.ControlTLS}}).Get(mismatched.URL); err == nil {
|
||||
t.Fatal("control leaf without the request hostname/SAN completed handshake")
|
||||
}
|
||||
|
||||
if _, err := Load(certFile, keyFile, controlFile, controlFile, testGatewayID, "gateway.example"); err == nil {
|
||||
t.Fatal("Load() accepted a non-CA leaf as streaming trust")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTrustsExactCAIssuedControlLeaf(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
controlCA := newTestCA(t, "control-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID)
|
||||
gatewayLeaf, gatewayKey := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, gatewayLeaf, identityCA.der, gatewayKey)
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
|
||||
activeLeaf, activeKey := newCAIssuedControlLeaf(t, controlCA, net.ParseIP("127.0.0.1"))
|
||||
controlFile := writeCertificate(t, "control-leaf.pem", activeLeaf)
|
||||
material, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, "gateway.example")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
clientCAs := x509.NewCertPool()
|
||||
clientCAs.AddCert(identityCA.certificate)
|
||||
trusted := newControlServer(t, activeLeaf, activeKey, clientCAs, controlCA.der)
|
||||
defer trusted.Close()
|
||||
response, err := (&http.Client{Transport: &http.Transport{TLSClientConfig: material.ControlTLS}}).Get(trusted.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("pinned CA-issued control leaf handshake: %v", err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.TLS == nil || response.TLS.Version != tls.VersionTLS13 {
|
||||
t.Fatalf("pinned control leaf TLS version = %v, want TLS 1.3", response.TLS)
|
||||
}
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("trusted control response status = %d", response.StatusCode)
|
||||
}
|
||||
|
||||
siblingLeaf, siblingKey := newCAIssuedControlLeaf(t, controlCA, net.ParseIP("127.0.0.1"))
|
||||
sibling := newControlServer(t, siblingLeaf, siblingKey, clientCAs, controlCA.der)
|
||||
defer sibling.Close()
|
||||
if _, err := (&http.Client{Transport: &http.Transport{TLSClientConfig: material.ControlTLS}}).Get(sibling.URL); err == nil {
|
||||
t.Fatal("sibling control leaf from the same issuer and SAN completed handshake")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsControlLeafBundlesAndNonServerAuth(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
controlCA := newTestCA(t, "control-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID)
|
||||
gatewayLeaf, gatewayKey := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, gatewayLeaf, identityCA.der, gatewayKey)
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
|
||||
serverLeaf, _ := newCAIssuedControlLeaf(t, controlCA, net.ParseIP("127.0.0.1"))
|
||||
nonServerLeaf, _ := newLeaf(t, controlCA, "control.example", nil, []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
certificates [][]byte
|
||||
}{
|
||||
{"leaf and issuer chain", [][]byte{serverLeaf, controlCA.der}},
|
||||
{"non ServerAuth leaf", [][]byte{nonServerLeaf}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
controlFile := writeCertificateBundle(t, "control.pem", test.certificates...)
|
||||
if _, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, "gateway.example"); err == nil {
|
||||
t.Fatal("Load() accepted invalid control trust material")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsWrongPublicNameURIAndLeafUsage(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
controlCA := newTestCA(t, "control-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
controlFile := writeCertificate(t, "control-ca.pem", controlCA.der)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
uriGatewayID string
|
||||
publicIdentity string
|
||||
leafDNSName string
|
||||
usages []x509.ExtKeyUsage
|
||||
}{
|
||||
{"wrong public name", testGatewayID, "other.example", "gateway.example", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}},
|
||||
{"noncanonical public identity", testGatewayID, " gateway.example ", "gateway.example", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}},
|
||||
{"wildcard is not an exact public identity", testGatewayID, "gateway.example", "*.example", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}},
|
||||
{"wrong URI gateway", "c1f5d25a-47c6-44ed-b9be-e0e8e87d4ae2", "gateway.example", "gateway.example", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}},
|
||||
{"missing client auth", testGatewayID, "gateway.example", "gateway.example", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+test.uriGatewayID+"/credential/"+testCredentialID)
|
||||
leafDER, key := newLeaf(t, identityCA, test.leafDNSName, []*url.URL{uri}, test.usages)
|
||||
certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key)
|
||||
if _, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, test.publicIdentity); err == nil {
|
||||
t.Fatal("Load() accepted invalid gateway certificate identity")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsNonCanonicalPublicDNSIdentity(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
controlCA := newTestCA(t, "control-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
controlFile := writeCertificate(t, "control-ca.pem", controlCA.der)
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID)
|
||||
tooLongLabel := strings.Repeat("a", 64) + ".example"
|
||||
tooLongName := strings.Repeat("a", 63) + "." + strings.Repeat("b", 63) + "." + strings.Repeat("c", 63) + "." + strings.Repeat("d", 62)
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
leafDNSName string
|
||||
publicIdentity string
|
||||
}{
|
||||
{"uppercase exact SAN", "Gateway.example", "Gateway.example"},
|
||||
{"empty label", "gateway..example", "gateway..example"},
|
||||
{"leading hyphen", "-gateway.example", "-gateway.example"},
|
||||
{"trailing hyphen", "gateway-.example", "gateway-.example"},
|
||||
{"wildcard", "*.example", "*.example"},
|
||||
{"label exceeds 63 bytes", tooLongLabel, tooLongLabel},
|
||||
{"name exceeds 253 bytes", tooLongName, tooLongName},
|
||||
{"IP address", "127.0.0.1", "127.0.0.1"},
|
||||
{"port", "gateway.example:443", "gateway.example:443"},
|
||||
{"path", "gateway.example/path", "gateway.example/path"},
|
||||
{"whitespace", "gateway .example", "gateway .example"},
|
||||
{"trailing dot", "gateway.example.", "gateway.example."},
|
||||
{"Unicode", "gateway.example", "gäteway.example"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
leafDER, key := newLeaf(t, identityCA, test.leafDNSName, []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key)
|
||||
if _, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, test.publicIdentity); err == nil {
|
||||
t.Fatal("Load() accepted a noncanonical public DNS identity")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAcceptsCanonicalPublicDNSIdentity(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
controlCA := newTestCA(t, "control-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
controlFile := writeCertificate(t, "control-ca.pem", controlCA.der)
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID)
|
||||
|
||||
for _, publicIdentity := range []string{"gateway.example", "gateway"} {
|
||||
t.Run(publicIdentity, func(t *testing.T) {
|
||||
leafDER, key := newLeaf(t, identityCA, publicIdentity, []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key)
|
||||
if _, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, publicIdentity); err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsLegacyAndNoncanonicalGatewayURIs(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
controlCA := newTestCA(t, "control-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
controlFile := writeCertificate(t, "control-ca.pem", controlCA.der)
|
||||
canonical := "spiffe://versevdi/gateway/" + testGatewayID + "/credential/" + testCredentialID
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
uri string
|
||||
}{
|
||||
{"legacy URN", "urn:versevdi:gateway:" + testGatewayID + ":credential:" + testCredentialID},
|
||||
{"opaque URI", "spiffe:gateway/" + testGatewayID + "/credential/" + testCredentialID},
|
||||
{"leading path slash", "spiffe://versevdi//gateway/" + testGatewayID + "/credential/" + testCredentialID},
|
||||
{"trailing path slash", canonical + "/"},
|
||||
{"percent encoded credential", "spiffe://versevdi/gateway/" + testGatewayID + "/credential/%62a4d93f8-d692-4210-97db-5a561bfb32d3"},
|
||||
{"force query", canonical + "?"},
|
||||
{"query", canonical + "?version=1"},
|
||||
{"fragment", canonical + "#fragment"},
|
||||
{"user", "spiffe://gateway@versevdi/gateway/" + testGatewayID + "/credential/" + testCredentialID},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
uri := mustURL(t, test.uri)
|
||||
leafDER, key := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key)
|
||||
if _, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, "gateway.example"); err == nil {
|
||||
t.Fatal("Load() accepted a legacy or noncanonical gateway URI")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("uppercase UUIDs", func(t *testing.T) {
|
||||
gatewayID := strings.ToUpper(testGatewayID)
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+gatewayID+"/credential/"+strings.ToUpper(testCredentialID))
|
||||
leafDER, key := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key)
|
||||
if _, err := Load(certFile, keyFile, streamingFile, controlFile, gatewayID, "gateway.example"); err == nil {
|
||||
t.Fatal("Load() accepted noncanonical uppercase UUIDs")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newTestCA(t *testing.T, commonName string) testCA {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: commonName},
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
|
||||
IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certificate, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return testCA{certificate: certificate, key: key, der: der}
|
||||
}
|
||||
|
||||
func newLeaf(t *testing.T, ca testCA, dnsName string, uris []*url.URL, usages []x509.ExtKeyUsage) ([]byte, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
publicKey, key, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: dnsName}, DNSNames: []string{dnsName}, URIs: uris,
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
|
||||
BasicConstraintsValid: true, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: usages,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, ca.certificate, publicKey, ca.key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return der, key
|
||||
}
|
||||
|
||||
func newSelfSignedServerLeaf(t *testing.T, ip net.IP) ([]byte, *ecdsa.PrivateKey) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: "control.example"}, IPAddresses: []net.IP{ip},
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
|
||||
BasicConstraintsValid: true, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return der, key
|
||||
}
|
||||
|
||||
func newCAIssuedControlLeaf(t *testing.T, ca testCA, ip net.IP) ([]byte, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
publicKey, key, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: "control.example"}, IPAddresses: []net.IP{ip},
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
|
||||
BasicConstraintsValid: true, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, ca.certificate, publicKey, ca.key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return der, key
|
||||
}
|
||||
|
||||
func newControlServer(t *testing.T, leaf []byte, key any, clientCAs *x509.CertPool, chain ...[]byte) *httptest.Server {
|
||||
t.Helper()
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.TLS == nil || len(request.TLS.PeerCertificates) == 0 {
|
||||
http.Error(response, "mTLS required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
certificates := append([][]byte{leaf}, chain...)
|
||||
server.TLS = &tls.Config{
|
||||
MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{{Certificate: certificates, PrivateKey: key}},
|
||||
ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: clientCAs,
|
||||
}
|
||||
server.StartTLS()
|
||||
return server
|
||||
}
|
||||
|
||||
func writeKeyPair(t *testing.T, leafDER, issuerDER []byte, key ed25519.PrivateKey) (string, string) {
|
||||
t.Helper()
|
||||
directory := t.TempDir()
|
||||
certFile := filepath.Join(directory, "gateway.pem")
|
||||
keyFile := filepath.Join(directory, "gateway-key.pem")
|
||||
certificatePEM := append(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER}), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: issuerDER})...)
|
||||
privateDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(certFile, certificatePEM, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateDER}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return certFile, keyFile
|
||||
}
|
||||
|
||||
func writeCertificate(t *testing.T, name string, der []byte) string {
|
||||
return writeCertificateBundle(t, name, der)
|
||||
}
|
||||
|
||||
func writeCertificateBundle(t *testing.T, name string, certificates ...[]byte) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
var encoded []byte
|
||||
for _, certificate := range certificates {
|
||||
encoded = append(encoded, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate})...)
|
||||
}
|
||||
if err := os.WriteFile(path, encoded, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func mustURL(t *testing.T, value string) *url.URL {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@@ -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,190 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"net/http"
|
||||
|
||||
"git.sechmachine.io.vn/sechmachine/VerseVDI-Data-Plane/cmd/internal/gatewaytls"
|
||||
"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, streamingCAFile, controlCAFile string
|
||||
var gatewayID, instanceIdentity, 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(&streamingCAFile, "streaming-ca", "", "CA PEM for QUIC streaming clients")
|
||||
flag.StringVar(&controlCAFile, "control-ca", "", "CA bundle or exact Server leaf PEM")
|
||||
flag.StringVar(&gatewayID, "gateway-id", "", "stable gateway identifier")
|
||||
flag.StringVar(&instanceIdentity, "instance-identity", "", "gateway instance identity")
|
||||
flag.StringVar(&publicIdentity, "public-identity", "", "gateway DNS identity from the certificate SAN")
|
||||
flag.Parse()
|
||||
for name, value := range map[string]string{"control-plane": controlPlane, "advertise-address": advertiseAddress, "cert": certFile, "key": keyFile, "streaming-ca": streamingCAFile, "control-ca": controlCAFile, "gateway-id": gatewayID, "instance-identity": instanceIdentity, "public-identity": publicIdentity} {
|
||||
if value == "" {
|
||||
return fmt.Errorf("-%s is required", name)
|
||||
}
|
||||
}
|
||||
if err := validateAdvertisedAddress(advertiseAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
tlsMaterial, err := gatewaytls.Load(certFile, keyFile, streamingCAFile, controlCAFile, gatewayID, publicIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
transport := &http.Transport{TLSClientConfig: tlsMaterial.ControlTLS}
|
||||
controlPlaneClient := gateway.NewControlPlaneClient(controlPlane, &http.Client{Transport: transport, Timeout: 5 * time.Second})
|
||||
provider := gateway.NewApolloAdapter(gateway.NewNativeApolloBackend(), gateway.ProviderIdentity{})
|
||||
capabilities := gateway.DefaultCapabilities()
|
||||
features := gateway.DefaultFeatures()
|
||||
server, err := gateway.NewServer(gateway.ServerConfig{ListenAddress: listen, TLSConfig: tlsMaterial.ServerTLS, GatewayID: gatewayID, Features: features, 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: tlsMaterial.CertificateIdentity, PublicIdentity: publicIdentity, Address: advertiseAddress, ProviderIdentity: "server-derived", ProtocolMinVersion: 1, ProtocolMaxVersion: 1, ConnectionCapacity: 8, BandwidthCapacityKbps: 100000, Features: features, 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 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,
|
||||
}
|
||||
}
|
||||
Generated
+701
@@ -0,0 +1,701 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"rand_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
|
||||
dependencies = [
|
||||
"tinyvec_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec_macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.51.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e608df10a8a5f3c45a2ad4801f93083620b4f3a816551e5e407cdfcd6690f56"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "versevdi-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"getrandom 0.4.3",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "versevdi-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "GPL-3.0-only"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["staticlib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
base64 = "=0.22.1"
|
||||
getrandom = "=0.4.3"
|
||||
quinn = { version = "=0.11.11", default-features = false, features = ["runtime-tokio", "rustls-ring"] }
|
||||
rustls = { version = "=0.23.43", default-features = false, features = ["std", "ring"] }
|
||||
rustls-pemfile = "=2.2.0"
|
||||
serde = { version = "=1.0.229", features = ["derive"] }
|
||||
serde_json = "=1.0.151"
|
||||
tokio = { version = "=1.51.4", features = ["rt-multi-thread", "sync", "time", "net"] }
|
||||
|
||||
[[test]]
|
||||
name = "protocol_fixtures"
|
||||
path = "tests/protocol_fixtures.rs"
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
incremental = false
|
||||
@@ -0,0 +1,4 @@
|
||||
module VerseVDICore {
|
||||
header "versevdi_core.h"
|
||||
export *
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
#ifndef VERSEVDI_CORE_H
|
||||
#define VERSEVDI_CORE_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define VERSE_CORE_ABI_VERSION_1 UINT32_C(1)
|
||||
|
||||
typedef uint32_t verse_status_t;
|
||||
|
||||
#define VERSE_STATUS_OK UINT32_C(0)
|
||||
#define VERSE_STATUS_INVALID_ARGUMENT UINT32_C(1)
|
||||
#define VERSE_STATUS_INVALID_STATE UINT32_C(2)
|
||||
#define VERSE_STATUS_UNSUPPORTED_ABI UINT32_C(3)
|
||||
#define VERSE_STATUS_AUTHORITY_REJECTED UINT32_C(4)
|
||||
#define VERSE_STATUS_TLS UINT32_C(5)
|
||||
#define VERSE_STATUS_TRANSPORT UINT32_C(6)
|
||||
#define VERSE_STATUS_PROTOCOL UINT32_C(7)
|
||||
#define VERSE_STATUS_EXPIRED UINT32_C(8)
|
||||
#define VERSE_STATUS_QUEUE_FULL UINT32_C(9)
|
||||
#define VERSE_STATUS_CANCELLED UINT32_C(10)
|
||||
#define VERSE_STATUS_REENTRANT UINT32_C(11)
|
||||
#define VERSE_STATUS_BUSY UINT32_C(12)
|
||||
#define VERSE_STATUS_INTERNAL UINT32_C(13)
|
||||
|
||||
#define VERSE_STATE_CONNECTING UINT32_C(1)
|
||||
#define VERSE_STATE_CONNECTED UINT32_C(2)
|
||||
#define VERSE_STATE_CANCELLED UINT32_C(3)
|
||||
|
||||
#define VERSE_INPUT_KEYBOARD UINT32_C(1)
|
||||
#define VERSE_INPUT_MOUSE_BUTTON UINT32_C(2)
|
||||
#define VERSE_INPUT_RELATIVE_MOUSE UINT32_C(3)
|
||||
#define VERSE_INPUT_TEXT UINT32_C(4)
|
||||
#define VERSE_INPUT_CONTROLLER UINT32_C(5)
|
||||
#define VERSE_INPUT_ABSOLUTE_MOUSE UINT32_C(6)
|
||||
#define VERSE_INPUT_SCROLL UINT32_C(7)
|
||||
|
||||
typedef struct verse_core verse_core_t;
|
||||
|
||||
/* data may be NULL only when length is zero. The view never transfers ownership. */
|
||||
typedef struct verse_bytes_view {
|
||||
const uint8_t *data;
|
||||
size_t length;
|
||||
} verse_bytes_view_t;
|
||||
|
||||
typedef struct verse_state_event_v1 {
|
||||
uint32_t struct_size;
|
||||
uint32_t abi_version;
|
||||
uint32_t state;
|
||||
uint32_t reason;
|
||||
} verse_state_event_v1_t;
|
||||
|
||||
typedef struct verse_error_event_v1 {
|
||||
uint32_t struct_size;
|
||||
uint32_t abi_version;
|
||||
uint32_t code;
|
||||
uint32_t retryable;
|
||||
uint32_t phase;
|
||||
uint32_t reserved;
|
||||
} verse_error_event_v1_t;
|
||||
|
||||
typedef struct verse_stats_event_v1 {
|
||||
uint32_t struct_size;
|
||||
uint32_t abi_version;
|
||||
uint64_t dropped_callbacks;
|
||||
uint64_t dropped_media_units;
|
||||
uint64_t dropped_input_events;
|
||||
} verse_stats_event_v1_t;
|
||||
|
||||
typedef struct verse_media_event_v1 {
|
||||
uint32_t struct_size;
|
||||
uint32_t abi_version;
|
||||
uint32_t channel;
|
||||
uint32_t sequence;
|
||||
uint64_t timestamp_ms;
|
||||
verse_bytes_view_t encoded_unit;
|
||||
} verse_media_event_v1_t;
|
||||
|
||||
typedef struct verse_control_event_v1 {
|
||||
uint32_t struct_size;
|
||||
uint32_t abi_version;
|
||||
uint32_t kind;
|
||||
uint32_t reserved;
|
||||
verse_bytes_view_t payload;
|
||||
} verse_control_event_v1_t;
|
||||
|
||||
/*
|
||||
* Signers run synchronously inline on the thread invoking the API call.
|
||||
* transcript/tls_message is borrowed only for the callback; signature_out is
|
||||
* exactly 64 writable bytes. Admission may return OK, AUTHORITY_REJECTED,
|
||||
* CANCELLED, or INTERNAL. TLS may return OK, TLS, CANCELLED, or INTERNAL. Any
|
||||
* other value is normalized to INTERNAL.
|
||||
*/
|
||||
typedef verse_status_t (*verse_sign_admission_v1_fn)(
|
||||
void *signer_context,
|
||||
verse_bytes_view_t transcript,
|
||||
uint8_t signature_out[64]);
|
||||
typedef verse_status_t (*verse_sign_tls_ed25519_v1_fn)(
|
||||
void *signer_context,
|
||||
verse_bytes_view_t tls_message,
|
||||
uint8_t signature_out[64]);
|
||||
/*
|
||||
* Event callbacks run asynchronously on one core-owned worker thread and are
|
||||
* serialized with one another. Each event and nested byte view is borrowed only
|
||||
* for its callback and must not be retained.
|
||||
*/
|
||||
typedef void (*verse_state_event_v1_fn)(
|
||||
void *context,
|
||||
const verse_state_event_v1_t *event);
|
||||
typedef void (*verse_error_event_v1_fn)(
|
||||
void *context,
|
||||
const verse_error_event_v1_t *event);
|
||||
typedef void (*verse_stats_event_v1_fn)(
|
||||
void *context,
|
||||
const verse_stats_event_v1_t *event);
|
||||
typedef void (*verse_media_event_v1_fn)(
|
||||
void *context,
|
||||
const verse_media_event_v1_t *event);
|
||||
typedef void (*verse_control_event_v1_fn)(
|
||||
void *context,
|
||||
const verse_control_event_v1_t *event);
|
||||
|
||||
/*
|
||||
* create copies this table. context and every non-NULL callback must remain valid
|
||||
* until destroy succeeds; BUSY does not end that lifetime.
|
||||
*/
|
||||
typedef struct verse_core_config_v1 {
|
||||
uint32_t struct_size;
|
||||
uint32_t abi_version;
|
||||
void *context;
|
||||
verse_sign_admission_v1_fn sign_admission;
|
||||
verse_sign_tls_ed25519_v1_fn sign_tls_ed25519;
|
||||
verse_state_event_v1_fn on_state;
|
||||
verse_error_event_v1_fn on_error;
|
||||
verse_stats_event_v1_fn on_stats;
|
||||
verse_media_event_v1_fn on_media;
|
||||
verse_control_event_v1_fn on_control;
|
||||
} verse_core_config_v1_t;
|
||||
|
||||
typedef struct verse_connect_request_v1 {
|
||||
uint32_t struct_size;
|
||||
uint32_t abi_version;
|
||||
verse_bytes_view_t manifest_json;
|
||||
verse_bytes_view_t tunnel_credential_json;
|
||||
} verse_connect_request_v1_t;
|
||||
|
||||
/* values are kind-specific signed fields; every unused field and flags must be zero. */
|
||||
typedef struct verse_input_event_v1 {
|
||||
uint32_t struct_size;
|
||||
uint32_t abi_version;
|
||||
uint32_t kind;
|
||||
uint32_t flags;
|
||||
int32_t values[12];
|
||||
} verse_input_event_v1_t;
|
||||
|
||||
uint32_t verse_core_abi_version(void);
|
||||
/*
|
||||
* During a signer callback, every API below except verse_core_abi_version returns
|
||||
* REENTRANT for every handle. During an event callback, only cancel on that event's
|
||||
* originating handle is allowed; all other calls and cross-handle cancel return
|
||||
* REENTRANT. Connect copies both byte inputs before returning, so callers may
|
||||
* mutate or release their buffers afterward.
|
||||
*/
|
||||
verse_status_t verse_core_create_v1(
|
||||
const verse_core_config_v1_t *config,
|
||||
verse_core_t **out_core);
|
||||
verse_status_t verse_core_connect_v1(
|
||||
verse_core_t *core,
|
||||
const verse_connect_request_v1_t *request);
|
||||
verse_status_t verse_core_send_input_v1(
|
||||
verse_core_t *core,
|
||||
const verse_input_event_v1_t *event);
|
||||
verse_status_t verse_core_request_idr_v1(verse_core_t *core);
|
||||
/* Idempotent and nonblocking; it does not wait for an internal state lock. */
|
||||
verse_status_t verse_core_cancel_v1(verse_core_t *core);
|
||||
/*
|
||||
* OK suppresses all later callbacks, releases session resources, and invalidates
|
||||
* core. Calls admitted before destruction are accounted safely, but no API call
|
||||
* may begin after OK. BUSY retains core, context, and callback ownership and
|
||||
* requires a later retry.
|
||||
*/
|
||||
verse_status_t verse_core_destroy_v1(verse_core_t *core, uint32_t timeout_ms);
|
||||
|
||||
#if defined(__APPLE__) && defined(__aarch64__)
|
||||
_Static_assert(sizeof(verse_bytes_view_t) == 16, "verse_bytes_view_t arm64 layout");
|
||||
_Static_assert(sizeof(verse_core_config_v1_t) == 72, "verse_core_config_v1_t arm64 layout");
|
||||
_Static_assert(offsetof(verse_core_config_v1_t, sign_admission) == 16, "config callback offset");
|
||||
_Static_assert(sizeof(verse_connect_request_v1_t) == 40, "verse_connect_request_v1_t arm64 layout");
|
||||
_Static_assert(offsetof(verse_connect_request_v1_t, manifest_json) == 8, "request view offset");
|
||||
_Static_assert(sizeof(verse_input_event_v1_t) == 64, "verse_input_event_v1_t arm64 layout");
|
||||
_Static_assert(sizeof(verse_state_event_v1_t) == 16, "verse_state_event_v1_t arm64 layout");
|
||||
_Static_assert(sizeof(verse_error_event_v1_t) == 24, "verse_error_event_v1_t arm64 layout");
|
||||
_Static_assert(sizeof(verse_stats_event_v1_t) == 32, "verse_stats_event_v1_t arm64 layout");
|
||||
_Static_assert(sizeof(verse_media_event_v1_t) == 40, "verse_media_event_v1_t arm64 layout");
|
||||
_Static_assert(sizeof(verse_control_event_v1_t) == 32, "verse_control_event_v1_t arm64 layout");
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,5 @@
|
||||
[toolchain]
|
||||
channel = "1.97.1"
|
||||
components = ["clippy", "rustfmt"]
|
||||
targets = ["aarch64-apple-darwin"]
|
||||
profile = "minimal"
|
||||
Executable
+234
@@ -0,0 +1,234 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
umask 022
|
||||
PATH=/usr/bin:/bin:/usr/sbin:/sbin
|
||||
export PATH
|
||||
|
||||
usage() {
|
||||
echo "usage: $0 --output ABSOLUTE_DIR --target-dir ABSOLUTE_DIR" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
output=
|
||||
target_dir=
|
||||
while test "$#" -gt 0; do
|
||||
case "$1" in
|
||||
--output)
|
||||
test "$#" -ge 2 || usage
|
||||
output=$2
|
||||
shift 2
|
||||
;;
|
||||
--target-dir)
|
||||
test "$#" -ge 2 || usage
|
||||
target_dir=$2
|
||||
shift 2
|
||||
;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
test -n "$output" || usage
|
||||
test -n "$target_dir" || usage
|
||||
ROOT=$(CDPATH= cd -P -- "$(dirname "$0")/../.." && pwd -P)
|
||||
CORE="$ROOT/core"
|
||||
|
||||
resolve_new_path() {
|
||||
requested=$1
|
||||
label=$2
|
||||
case "$requested" in
|
||||
/*) ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
if test -e "$requested" || test -L "$requested"; then
|
||||
echo "$label must not exist: $requested" >&2
|
||||
exit 2
|
||||
fi
|
||||
parent=$(dirname -- "$requested")
|
||||
name=$(basename -- "$requested")
|
||||
case "$name" in
|
||||
''|.|..) usage ;;
|
||||
esac
|
||||
physical_parent=$(CDPATH= cd -P -- "$parent" 2>/dev/null && pwd -P) || {
|
||||
echo "$label parent must already exist: $parent" >&2
|
||||
exit 2
|
||||
}
|
||||
candidate="$physical_parent/$name"
|
||||
case "$candidate" in
|
||||
"$ROOT"|"$ROOT"/*)
|
||||
echo "$label must be outside the repository: $requested" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
printf '%s\n' "$candidate"
|
||||
}
|
||||
|
||||
output=$(resolve_new_path "$output" output)
|
||||
target_dir=$(resolve_new_path "$target_dir" "target directory")
|
||||
case "$output/:$target_dir/" in
|
||||
"$target_dir/"*:*|*:"$output/"*)
|
||||
echo "output and target directory must be separate" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
mkdir "$output"
|
||||
if ! mkdir "$target_dir"; then
|
||||
rmdir "$output"
|
||||
exit 2
|
||||
fi
|
||||
output=$(CDPATH= cd -P -- "$output" && pwd -P)
|
||||
target_dir=$(CDPATH= cd -P -- "$target_dir" && pwd -P)
|
||||
for reserved_path in "$output" "$target_dir"; do
|
||||
case "$reserved_path" in
|
||||
"$ROOT"|"$ROOT"/*)
|
||||
echo "reserved packaging path resolved inside the repository: $reserved_path" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
case "$output/:$target_dir/" in
|
||||
"$target_dir/"*:*|*:"$output/"*)
|
||||
echo "reserved output and target directory must be separate" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
test "$(/usr/bin/uname -s)" = Darwin || {
|
||||
echo "XCFramework packaging requires macOS" >&2
|
||||
exit 2
|
||||
}
|
||||
test "$(/usr/bin/uname -m)" = arm64 || {
|
||||
echo "XCFramework packaging requires an arm64 host" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
user_record=$(/usr/bin/dscacheutil -q user -a name "$(/usr/bin/id -un)")
|
||||
trusted_home=$(printf '%s\n' "$user_record" | awk '$1 == "dir:" { print $2; exit }')
|
||||
rustup="$trusted_home/.cargo/bin/rustup"
|
||||
test -x "$rustup" || {
|
||||
echo "rustup not found at trusted user path" >&2
|
||||
exit 2
|
||||
}
|
||||
cargo_bin=$(/usr/bin/env -i \
|
||||
HOME="$trusted_home" \
|
||||
PATH="$PATH" \
|
||||
RUSTUP_HOME="$trusted_home/.rustup" \
|
||||
"$rustup" which --toolchain 1.97.1 cargo)
|
||||
rustc_bin=$(/usr/bin/env -i \
|
||||
HOME="$trusted_home" \
|
||||
PATH="$PATH" \
|
||||
RUSTUP_HOME="$trusted_home/.rustup" \
|
||||
"$rustup" which --toolchain 1.97.1 rustc)
|
||||
rustc_version=$(/usr/bin/env -i HOME="$trusted_home" PATH="$PATH" "$rustc_bin" --version)
|
||||
cargo_version=$(/usr/bin/env -i HOME="$trusted_home" PATH="$PATH" "$cargo_bin" --version)
|
||||
test "$rustc_version" = 'rustc 1.97.1 (8bab26f4f 2026-07-14)' || {
|
||||
echo "unexpected rustc identity: $rustc_version" >&2
|
||||
exit 2
|
||||
}
|
||||
test "$cargo_version" = 'cargo 1.97.1 (c980f4866 2026-06-30)' || {
|
||||
echo "unexpected cargo identity: $cargo_version" >&2
|
||||
exit 2
|
||||
}
|
||||
xcodebuild=$(/usr/bin/env -i PATH="$PATH" /usr/bin/xcrun --find xcodebuild)
|
||||
xcode_version=$(/usr/bin/env -i HOME="$trusted_home" PATH="$PATH" "$xcodebuild" -version)
|
||||
test "$xcode_version" = 'Xcode 26.6
|
||||
Build version 17F113' || {
|
||||
echo "unexpected Xcode identity: $xcode_version" >&2
|
||||
exit 2
|
||||
}
|
||||
clang=$(/usr/bin/env -i PATH="$PATH" /usr/bin/xcrun --find clang)
|
||||
ar=$(/usr/bin/env -i PATH="$PATH" /usr/bin/xcrun --find ar)
|
||||
clang_identity=$(/usr/bin/env -i HOME="$trusted_home" PATH="$PATH" "$clang" --version)
|
||||
clang_version=$(printf '%s\n' "$clang_identity" | sed -n '1p')
|
||||
sdk=$(/usr/bin/env -i PATH="$PATH" /usr/bin/xcrun --sdk macosx --show-sdk-path)
|
||||
|
||||
config_dir=$CORE
|
||||
while :; do
|
||||
if test -e "$config_dir/.cargo/config" || test -e "$config_dir/.cargo/config.toml"; then
|
||||
echo "Cargo config is not permitted in the packaging path: $config_dir/.cargo" >&2
|
||||
exit 2
|
||||
fi
|
||||
test "$config_dir" = / && break
|
||||
config_dir=$(dirname "$config_dir")
|
||||
done
|
||||
|
||||
source_commit=$(/usr/bin/env -i HOME="$trusted_home" PATH="$PATH" \
|
||||
/usr/bin/git -C "$ROOT" rev-parse HEAD)
|
||||
source_epoch=$(/usr/bin/env -i HOME="$trusted_home" PATH="$PATH" \
|
||||
/usr/bin/git -C "$ROOT" show -s --format=%ct HEAD)
|
||||
rustflags="--remap-path-prefix=$ROOT=. --remap-path-prefix=$target_dir=/cargo-target"
|
||||
cflags="-fdebug-prefix-map=$ROOT=. -ffile-prefix-map=$ROOT=. -fdebug-prefix-map=$target_dir=/cargo-target -ffile-prefix-map=$target_dir=/cargo-target"
|
||||
cargo_home="$target_dir/cargo-home"
|
||||
build_tmp="$target_dir/tmp"
|
||||
mkdir "$cargo_home" "$build_tmp"
|
||||
for cache in registry git; do
|
||||
if test -e "$trusted_home/.cargo/$cache"; then
|
||||
ln -s "$trusted_home/.cargo/$cache" "$cargo_home/$cache"
|
||||
fi
|
||||
done
|
||||
(
|
||||
cd "$CORE"
|
||||
/usr/bin/env -i \
|
||||
AR="$ar" \
|
||||
AR_aarch64_apple_darwin="$ar" \
|
||||
CARGO_HOME="$cargo_home" \
|
||||
CARGO_INCREMENTAL=0 \
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 \
|
||||
CARGO_PROFILE_RELEASE_INCREMENTAL=false \
|
||||
CARGO_PROFILE_RELEASE_LTO=fat \
|
||||
CARGO_PROFILE_RELEASE_PANIC=abort \
|
||||
CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER="$clang" \
|
||||
CARGO_TARGET_DIR="$target_dir" \
|
||||
CC="$clang" \
|
||||
CC_aarch64_apple_darwin="$clang" \
|
||||
CFLAGS="$cflags" \
|
||||
CFLAGS_aarch64_apple_darwin="$cflags" \
|
||||
HOME="$trusted_home" \
|
||||
MACOSX_DEPLOYMENT_TARGET=14.0 \
|
||||
PATH="$PATH" \
|
||||
RUSTC="$rustc_bin" \
|
||||
RUSTFLAGS="$rustflags" \
|
||||
SDKROOT="$sdk" \
|
||||
SOURCE_DATE_EPOCH="$source_epoch" \
|
||||
TMPDIR="$build_tmp" \
|
||||
ZERO_AR_DATE=1 \
|
||||
"$cargo_bin" build \
|
||||
--target aarch64-apple-darwin \
|
||||
--release \
|
||||
--frozen
|
||||
)
|
||||
|
||||
headers="$target_dir/xcframework-headers"
|
||||
mkdir -p "$headers" "$output"
|
||||
cp "$ROOT/core/include/versevdi_core.h" "$headers/"
|
||||
cp "$ROOT/core/include/module.modulemap" "$headers/"
|
||||
|
||||
/usr/bin/env -i \
|
||||
HOME="$trusted_home" \
|
||||
PATH="$PATH" \
|
||||
TMPDIR="$build_tmp" \
|
||||
"$xcodebuild" -create-xcframework \
|
||||
-library "$target_dir/aarch64-apple-darwin/release/libversevdi_core.a" \
|
||||
-headers "$headers" \
|
||||
-output "$output/VerseVDICore.xcframework"
|
||||
|
||||
timestamp=$(date -r "$source_epoch" +%Y%m%d%H%M.%S)
|
||||
find "$output/VerseVDICore.xcframework" -exec touch -h -t "$timestamp" {} +
|
||||
|
||||
{
|
||||
printf 'rustc=%s\n' "$rustc_version"
|
||||
printf 'cargo=%s\n' "$cargo_version"
|
||||
printf 'cargo_path=%s\n' "$cargo_bin"
|
||||
printf 'rustc_path=%s\n' "$rustc_bin"
|
||||
printf 'xcode=%s\n' "$xcode_version"
|
||||
printf 'clang=%s\n' "$clang_version"
|
||||
printf 'sdk=%s\n' "$sdk"
|
||||
printf 'cargo_config=isolated-home-and-no-project-config\n'
|
||||
printf 'target=aarch64-apple-darwin\n'
|
||||
printf 'deployment_target=14.0\n'
|
||||
printf 'source_commit=%s\n' "$source_commit"
|
||||
printf 'source_epoch=%s\n' "$source_epoch"
|
||||
printf 'rustflags=--remap-path-prefix=<repository>=. --remap-path-prefix=<target>=/cargo-target\n'
|
||||
printf 'cflags=-fdebug-prefix-map/-ffile-prefix-map for <repository> and <target>\n'
|
||||
} >"$output/build-environment.txt"
|
||||
touch -t "$timestamp" "$output/build-environment.txt" "$output"
|
||||
+1800
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
use std::fmt;
|
||||
|
||||
/// Stable, provider-free failures returned by the safe core.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum CoreError {
|
||||
InvalidArgument,
|
||||
Internal,
|
||||
AuthorityRejected,
|
||||
Tls,
|
||||
Transport,
|
||||
Protocol,
|
||||
Expired,
|
||||
QueueFull,
|
||||
Cancelled,
|
||||
Truncated,
|
||||
UnsupportedVersion,
|
||||
UnknownChannel,
|
||||
Fragment,
|
||||
FragmentLimit,
|
||||
Length,
|
||||
LengthMismatch,
|
||||
Magic,
|
||||
Kind,
|
||||
Reserved,
|
||||
Utf8,
|
||||
Field,
|
||||
Direction,
|
||||
Type,
|
||||
UnsupportedFeature,
|
||||
ConflictingDuplicate,
|
||||
}
|
||||
|
||||
impl CoreError {
|
||||
/// Returns a stable, provider-free machine code.
|
||||
#[must_use]
|
||||
pub const fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::InvalidArgument => "invalid_argument",
|
||||
Self::Internal => "internal",
|
||||
Self::AuthorityRejected => "authority_rejected",
|
||||
Self::Tls => "tls",
|
||||
Self::Transport => "transport",
|
||||
Self::Protocol => "protocol",
|
||||
Self::Expired => "expired",
|
||||
Self::QueueFull => "queue_full",
|
||||
Self::Cancelled => "cancelled",
|
||||
Self::Truncated => "truncated",
|
||||
Self::UnsupportedVersion => "unsupported_version",
|
||||
Self::UnknownChannel => "unknown_channel",
|
||||
Self::Fragment => "fragment",
|
||||
Self::FragmentLimit => "fragment_limit",
|
||||
Self::Length => "length",
|
||||
Self::LengthMismatch => "length_mismatch",
|
||||
Self::Magic => "magic",
|
||||
Self::Kind => "kind",
|
||||
Self::Reserved => "reserved",
|
||||
Self::Utf8 => "utf8",
|
||||
Self::Field => "field",
|
||||
Self::Direction => "direction",
|
||||
Self::Type => "type",
|
||||
Self::UnsupportedFeature => "unsupported_feature",
|
||||
Self::ConflictingDuplicate => "conflicting_duplicate",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CoreError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.code())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CoreError {}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, CoreError>;
|
||||
@@ -0,0 +1,441 @@
|
||||
use crate::error::{CoreError, Result};
|
||||
|
||||
const INPUT_HEADER: usize = 6;
|
||||
const FEEDBACK_HEADER: usize = 8;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ControllerState {
|
||||
pub controller: u8,
|
||||
pub active_mask: u16,
|
||||
pub button_flags: u16,
|
||||
pub left_trigger: u8,
|
||||
pub right_trigger: u8,
|
||||
pub left_x: i16,
|
||||
pub left_y: i16,
|
||||
pub right_x: i16,
|
||||
pub right_y: i16,
|
||||
pub extra_button_flags: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum InputEvent {
|
||||
Keyboard {
|
||||
pressed: bool,
|
||||
modifiers: u8,
|
||||
scancode: u16,
|
||||
},
|
||||
MouseButton {
|
||||
pressed: bool,
|
||||
button: u8,
|
||||
},
|
||||
RelativeMouse {
|
||||
delta_x: i16,
|
||||
delta_y: i16,
|
||||
},
|
||||
Text(char),
|
||||
Controller(ControllerState),
|
||||
AbsoluteMouse {
|
||||
x: u16,
|
||||
y: u16,
|
||||
viewport_width: u16,
|
||||
viewport_height: u16,
|
||||
},
|
||||
Scroll {
|
||||
vertical_delta: i16,
|
||||
horizontal_delta: i16,
|
||||
},
|
||||
}
|
||||
|
||||
fn feature(features: &[&str], wanted: &str) -> Result<()> {
|
||||
features
|
||||
.contains(&wanted)
|
||||
.then_some(())
|
||||
.ok_or(CoreError::UnsupportedFeature)
|
||||
}
|
||||
|
||||
fn state(value: u8) -> Result<bool> {
|
||||
match value {
|
||||
0 => Ok(false),
|
||||
1 => Ok(true),
|
||||
_ => Err(CoreError::Field),
|
||||
}
|
||||
}
|
||||
|
||||
fn i16_at(bytes: &[u8], offset: usize) -> i16 {
|
||||
i16::from_be_bytes([bytes[offset], bytes[offset + 1]])
|
||||
}
|
||||
|
||||
fn u16_at(bytes: &[u8], offset: usize) -> u16 {
|
||||
u16::from_be_bytes([bytes[offset], bytes[offset + 1]])
|
||||
}
|
||||
|
||||
/// Decodes one bounded VGI1 input envelope.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a stable protocol error for malformed bytes or a missing negotiated feature.
|
||||
pub fn decode_input(bytes: &[u8], features: &[&str]) -> Result<InputEvent> {
|
||||
if bytes.len() < INPUT_HEADER {
|
||||
return Err(CoreError::Truncated);
|
||||
}
|
||||
if &bytes[..4] != b"VGI1" {
|
||||
return Err(CoreError::Magic);
|
||||
}
|
||||
let body_length = usize::from(bytes[5]);
|
||||
if bytes.len() != INPUT_HEADER + body_length {
|
||||
return Err(CoreError::Length);
|
||||
}
|
||||
let body = &bytes[INPUT_HEADER..];
|
||||
match bytes[4] {
|
||||
0x01 if body.len() == 4 => {
|
||||
let scancode = u16_at(body, 2);
|
||||
if scancode == 0 {
|
||||
return Err(CoreError::Field);
|
||||
}
|
||||
Ok(InputEvent::Keyboard {
|
||||
pressed: state(body[0])?,
|
||||
modifiers: body[1],
|
||||
scancode,
|
||||
})
|
||||
}
|
||||
0x02 if body.len() == 3 => {
|
||||
if !(1..=5).contains(&body[1]) {
|
||||
return Err(CoreError::Field);
|
||||
}
|
||||
if body[2] != 0 {
|
||||
return Err(CoreError::Reserved);
|
||||
}
|
||||
Ok(InputEvent::MouseButton {
|
||||
pressed: state(body[0])?,
|
||||
button: body[1],
|
||||
})
|
||||
}
|
||||
0x03 if body.len() == 4 => Ok(InputEvent::RelativeMouse {
|
||||
delta_x: i16_at(body, 0),
|
||||
delta_y: i16_at(body, 2),
|
||||
}),
|
||||
0x04 if (1..=4).contains(&body.len()) => {
|
||||
let text = std::str::from_utf8(body).map_err(|_| CoreError::Utf8)?;
|
||||
let mut chars = text.chars();
|
||||
let value = chars.next().ok_or(CoreError::Utf8)?;
|
||||
if chars.next().is_some() {
|
||||
return Err(CoreError::Utf8);
|
||||
}
|
||||
Ok(InputEvent::Text(value))
|
||||
}
|
||||
0x05 if body.len() == 17 => {
|
||||
if body[0] > 15 {
|
||||
return Err(CoreError::Field);
|
||||
}
|
||||
Ok(InputEvent::Controller(ControllerState {
|
||||
controller: body[0],
|
||||
active_mask: u16_at(body, 1),
|
||||
button_flags: u16_at(body, 3),
|
||||
left_trigger: body[5],
|
||||
right_trigger: body[6],
|
||||
left_x: i16_at(body, 7),
|
||||
left_y: i16_at(body, 9),
|
||||
right_x: i16_at(body, 11),
|
||||
right_y: i16_at(body, 13),
|
||||
extra_button_flags: u16_at(body, 15),
|
||||
}))
|
||||
}
|
||||
0x06 if body.len() == 8 => {
|
||||
feature(features, "input.absolute.v1")?;
|
||||
let x = u16_at(body, 0);
|
||||
let y = u16_at(body, 2);
|
||||
let viewport_width = u16_at(body, 4);
|
||||
let viewport_height = u16_at(body, 6);
|
||||
if viewport_width == 0
|
||||
|| viewport_height == 0
|
||||
|| x >= viewport_width
|
||||
|| y >= viewport_height
|
||||
{
|
||||
return Err(CoreError::Field);
|
||||
}
|
||||
Ok(InputEvent::AbsoluteMouse {
|
||||
x,
|
||||
y,
|
||||
viewport_width,
|
||||
viewport_height,
|
||||
})
|
||||
}
|
||||
0x07 if body.len() == 4 => {
|
||||
feature(features, "input.scroll.v1")?;
|
||||
Ok(InputEvent::Scroll {
|
||||
vertical_delta: i16_at(body, 0),
|
||||
horizontal_delta: i16_at(body, 2),
|
||||
})
|
||||
}
|
||||
0x01..=0x07 => Err(CoreError::Length),
|
||||
_ => Err(CoreError::Kind),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_i16(output: &mut Vec<u8>, value: i16) {
|
||||
output.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
|
||||
fn push_u16(output: &mut Vec<u8>, value: u16) {
|
||||
output.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
|
||||
/// Encodes one bounded VGI1 input envelope.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a stable protocol error for invalid fields or a missing negotiated feature.
|
||||
pub fn encode_input(event: &InputEvent, features: &[&str]) -> Result<Vec<u8>> {
|
||||
let (kind, body) = match event {
|
||||
InputEvent::Keyboard {
|
||||
pressed,
|
||||
modifiers,
|
||||
scancode,
|
||||
} => {
|
||||
if *scancode == 0 {
|
||||
return Err(CoreError::Field);
|
||||
}
|
||||
let mut body = vec![u8::from(*pressed), *modifiers];
|
||||
push_u16(&mut body, *scancode);
|
||||
(0x01, body)
|
||||
}
|
||||
InputEvent::MouseButton { pressed, button } => {
|
||||
if !(1..=5).contains(button) {
|
||||
return Err(CoreError::Field);
|
||||
}
|
||||
(0x02, vec![u8::from(*pressed), *button, 0])
|
||||
}
|
||||
InputEvent::RelativeMouse { delta_x, delta_y } => {
|
||||
let mut body = Vec::with_capacity(4);
|
||||
push_i16(&mut body, *delta_x);
|
||||
push_i16(&mut body, *delta_y);
|
||||
(0x03, body)
|
||||
}
|
||||
InputEvent::Text(value) => {
|
||||
let mut bytes = [0_u8; 4];
|
||||
(0x04, value.encode_utf8(&mut bytes).as_bytes().to_vec())
|
||||
}
|
||||
InputEvent::Controller(controller) => {
|
||||
if controller.controller > 15 {
|
||||
return Err(CoreError::Field);
|
||||
}
|
||||
let mut body = vec![controller.controller];
|
||||
push_u16(&mut body, controller.active_mask);
|
||||
push_u16(&mut body, controller.button_flags);
|
||||
body.extend_from_slice(&[controller.left_trigger, controller.right_trigger]);
|
||||
push_i16(&mut body, controller.left_x);
|
||||
push_i16(&mut body, controller.left_y);
|
||||
push_i16(&mut body, controller.right_x);
|
||||
push_i16(&mut body, controller.right_y);
|
||||
push_u16(&mut body, controller.extra_button_flags);
|
||||
(0x05, body)
|
||||
}
|
||||
InputEvent::AbsoluteMouse {
|
||||
x,
|
||||
y,
|
||||
viewport_width,
|
||||
viewport_height,
|
||||
} => {
|
||||
feature(features, "input.absolute.v1")?;
|
||||
if *viewport_width == 0
|
||||
|| *viewport_height == 0
|
||||
|| x >= viewport_width
|
||||
|| y >= viewport_height
|
||||
{
|
||||
return Err(CoreError::Field);
|
||||
}
|
||||
let mut body = Vec::with_capacity(8);
|
||||
push_u16(&mut body, *x);
|
||||
push_u16(&mut body, *y);
|
||||
push_u16(&mut body, *viewport_width);
|
||||
push_u16(&mut body, *viewport_height);
|
||||
(0x06, body)
|
||||
}
|
||||
InputEvent::Scroll {
|
||||
vertical_delta,
|
||||
horizontal_delta,
|
||||
} => {
|
||||
feature(features, "input.scroll.v1")?;
|
||||
let mut body = Vec::with_capacity(4);
|
||||
push_i16(&mut body, *vertical_delta);
|
||||
push_i16(&mut body, *horizontal_delta);
|
||||
(0x07, body)
|
||||
}
|
||||
};
|
||||
let mut output = Vec::with_capacity(INPUT_HEADER + body.len());
|
||||
output.extend_from_slice(b"VGI1");
|
||||
output.push(kind);
|
||||
output.push(u8::try_from(body.len()).map_err(|_| CoreError::Length)?);
|
||||
output.extend_from_slice(&body);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct FecStatus {
|
||||
pub frame_index: u32,
|
||||
pub highest_received_sequence: u16,
|
||||
pub next_contiguous_sequence: u16,
|
||||
pub missing_before_highest: u16,
|
||||
pub total_data_packets: u16,
|
||||
pub total_parity_packets: u16,
|
||||
pub received_data_packets: u16,
|
||||
pub received_parity_packets: u16,
|
||||
pub fec_percentage: u8,
|
||||
pub multi_fec_block_index: u8,
|
||||
pub multi_fec_block_count: u8,
|
||||
}
|
||||
|
||||
impl FecStatus {
|
||||
fn validate(&self) -> Result<()> {
|
||||
if self.total_data_packets == 0
|
||||
|| self.received_data_packets > self.total_data_packets
|
||||
|| self.received_parity_packets > self.total_parity_packets
|
||||
|| self.fec_percentage > 100
|
||||
|| self.multi_fec_block_count == 0
|
||||
|| self.multi_fec_block_index >= self.multi_fec_block_count
|
||||
{
|
||||
return Err(CoreError::Field);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum FeedbackEvent {
|
||||
IdrRequest,
|
||||
Fec(FecStatus),
|
||||
TerminalReceipt,
|
||||
Termination {
|
||||
exit_code: u32,
|
||||
},
|
||||
Rumble {
|
||||
controller: u8,
|
||||
low_frequency: u16,
|
||||
high_frequency: u16,
|
||||
},
|
||||
Hdr {
|
||||
enabled: bool,
|
||||
},
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
fn u32_at(bytes: &[u8], offset: usize) -> u32 {
|
||||
u32::from_be_bytes([
|
||||
bytes[offset],
|
||||
bytes[offset + 1],
|
||||
bytes[offset + 2],
|
||||
bytes[offset + 3],
|
||||
])
|
||||
}
|
||||
|
||||
/// Decodes one bounded VGF1 feedback envelope.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a stable protocol error for malformed bytes, direction, type, or size.
|
||||
pub fn decode_feedback(bytes: &[u8]) -> Result<FeedbackEvent> {
|
||||
if bytes.len() < FEEDBACK_HEADER {
|
||||
return Err(CoreError::Truncated);
|
||||
}
|
||||
if &bytes[..4] != b"VGF1" {
|
||||
return Err(CoreError::Magic);
|
||||
}
|
||||
let direction = bytes[4];
|
||||
if direction > 1 {
|
||||
return Err(CoreError::Direction);
|
||||
}
|
||||
let body_length = usize::from(u16_at(bytes, 6));
|
||||
if bytes.len() != FEEDBACK_HEADER + body_length {
|
||||
return Err(CoreError::Length);
|
||||
}
|
||||
let body = &bytes[FEEDBACK_HEADER..];
|
||||
match (direction, bytes[5]) {
|
||||
(0, 0x01) if body.is_empty() => Ok(FeedbackEvent::IdrRequest),
|
||||
(0, 0x02) if body.len() == 21 => {
|
||||
let status = FecStatus {
|
||||
frame_index: u32_at(body, 0),
|
||||
highest_received_sequence: u16_at(body, 4),
|
||||
next_contiguous_sequence: u16_at(body, 6),
|
||||
missing_before_highest: u16_at(body, 8),
|
||||
total_data_packets: u16_at(body, 10),
|
||||
total_parity_packets: u16_at(body, 12),
|
||||
received_data_packets: u16_at(body, 14),
|
||||
received_parity_packets: u16_at(body, 16),
|
||||
fec_percentage: body[18],
|
||||
multi_fec_block_index: body[19],
|
||||
multi_fec_block_count: body[20],
|
||||
};
|
||||
status.validate()?;
|
||||
Ok(FeedbackEvent::Fec(status))
|
||||
}
|
||||
(0, 0x03) if body.is_empty() => Ok(FeedbackEvent::TerminalReceipt),
|
||||
(1, 0x10) if body.len() == 4 => Ok(FeedbackEvent::Termination {
|
||||
exit_code: u32_at(body, 0),
|
||||
}),
|
||||
(1, 0x11) if body.len() == 5 => Ok(FeedbackEvent::Rumble {
|
||||
controller: body[0],
|
||||
low_frequency: u16_at(body, 1),
|
||||
high_frequency: u16_at(body, 3),
|
||||
}),
|
||||
(1, 0x12) if body.len() == 1 && body[0] <= 1 => Ok(FeedbackEvent::Hdr {
|
||||
enabled: body[0] == 1,
|
||||
}),
|
||||
(1, 0x13) if body.is_empty() => Ok(FeedbackEvent::Disconnected),
|
||||
(0, 0x10..=0x13) | (1, 0x01..=0x03) => Err(CoreError::Direction),
|
||||
(0, 0x01..=0x03) | (1, 0x10..=0x13) => Err(CoreError::Length),
|
||||
_ => Err(CoreError::Type),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes one bounded VGF1 feedback envelope.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a stable protocol error if the body size is not representable.
|
||||
pub fn encode_feedback(event: &FeedbackEvent) -> Result<Vec<u8>> {
|
||||
let (direction, kind, body) = match event {
|
||||
FeedbackEvent::IdrRequest => (0, 0x01, Vec::new()),
|
||||
FeedbackEvent::Fec(status) => {
|
||||
status.validate()?;
|
||||
let mut body = Vec::with_capacity(21);
|
||||
body.extend_from_slice(&status.frame_index.to_be_bytes());
|
||||
push_u16(&mut body, status.highest_received_sequence);
|
||||
push_u16(&mut body, status.next_contiguous_sequence);
|
||||
push_u16(&mut body, status.missing_before_highest);
|
||||
push_u16(&mut body, status.total_data_packets);
|
||||
push_u16(&mut body, status.total_parity_packets);
|
||||
push_u16(&mut body, status.received_data_packets);
|
||||
push_u16(&mut body, status.received_parity_packets);
|
||||
body.extend_from_slice(&[
|
||||
status.fec_percentage,
|
||||
status.multi_fec_block_index,
|
||||
status.multi_fec_block_count,
|
||||
]);
|
||||
(0, 0x02, body)
|
||||
}
|
||||
FeedbackEvent::TerminalReceipt => (0, 0x03, Vec::new()),
|
||||
FeedbackEvent::Termination { exit_code } => (1, 0x10, exit_code.to_be_bytes().to_vec()),
|
||||
FeedbackEvent::Rumble {
|
||||
controller,
|
||||
low_frequency,
|
||||
high_frequency,
|
||||
} => {
|
||||
let mut body = vec![*controller];
|
||||
push_u16(&mut body, *low_frequency);
|
||||
push_u16(&mut body, *high_frequency);
|
||||
(1, 0x11, body)
|
||||
}
|
||||
FeedbackEvent::Hdr { enabled } => (1, 0x12, vec![u8::from(*enabled)]),
|
||||
FeedbackEvent::Disconnected => (1, 0x13, Vec::new()),
|
||||
};
|
||||
let mut output = Vec::with_capacity(FEEDBACK_HEADER + body.len());
|
||||
output.extend_from_slice(b"VGF1");
|
||||
output.extend_from_slice(&[direction, kind]);
|
||||
output.extend_from_slice(
|
||||
&u16::try_from(body.len())
|
||||
.map_err(|_| CoreError::Length)?
|
||||
.to_be_bytes(),
|
||||
);
|
||||
output.extend_from_slice(&body);
|
||||
Ok(output)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
#![deny(unsafe_code)]
|
||||
#![forbid(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
//! Provider-free wire codecs and bounded session primitives for `VerseVDI` clients.
|
||||
//!
|
||||
//! ```
|
||||
//! use versevdi_core::media::MediaFragment;
|
||||
//!
|
||||
//! let fragment = MediaFragment::new_video(1, 2, 0, 1, vec![1, 2, 3])?;
|
||||
//! let encoded = fragment.encode()?;
|
||||
//! assert_eq!(MediaFragment::decode(&encoded)?, fragment);
|
||||
//! # Ok::<(), versevdi_core::error::CoreError>(())
|
||||
//! ```
|
||||
|
||||
pub mod error;
|
||||
pub mod input;
|
||||
pub mod media;
|
||||
pub mod session;
|
||||
pub mod transport;
|
||||
pub mod wire;
|
||||
|
||||
mod tls;
|
||||
|
||||
#[allow(unsafe_code)]
|
||||
mod abi;
|
||||
@@ -0,0 +1,309 @@
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{CoreError, Result};
|
||||
|
||||
pub const DATAGRAM_HEADER_BYTES: usize = 23;
|
||||
pub const MAX_DATAGRAM_BYTES: usize = 1_200;
|
||||
pub const MAX_FRAGMENT_PAYLOAD_BYTES: usize = 1_177;
|
||||
pub const MAX_FRAGMENT_COUNT: u16 = 891;
|
||||
pub const MAX_COMPLETE_UNIT_BYTES: usize = 1_048_576;
|
||||
const MAX_INCOMPLETE_UNITS: usize = 4;
|
||||
const EXPIRY_MILLISECONDS: u64 = 250;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MediaChannel {
|
||||
Video,
|
||||
Audio,
|
||||
}
|
||||
|
||||
impl MediaChannel {
|
||||
const fn wire(self) -> u8 {
|
||||
match self {
|
||||
Self::Video => 10,
|
||||
Self::Audio => 11,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_wire(value: u8) -> Result<Self> {
|
||||
match value {
|
||||
10 => Ok(Self::Video),
|
||||
11 => Ok(Self::Audio),
|
||||
_ => Err(CoreError::UnknownChannel),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MediaFragment {
|
||||
pub channel: MediaChannel,
|
||||
pub sequence: u32,
|
||||
pub timestamp_ms: u64,
|
||||
pub fragment_index: u16,
|
||||
pub fragment_count: u16,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
impl MediaFragment {
|
||||
/// Creates a validated video fragment.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a stable protocol error when fragment or payload bounds are invalid.
|
||||
pub fn new_video(
|
||||
sequence: u32,
|
||||
timestamp_ms: u64,
|
||||
fragment_index: u16,
|
||||
fragment_count: u16,
|
||||
payload: Vec<u8>,
|
||||
) -> Result<Self> {
|
||||
let fragment = Self {
|
||||
channel: MediaChannel::Video,
|
||||
sequence,
|
||||
timestamp_ms,
|
||||
fragment_index,
|
||||
fragment_count,
|
||||
payload,
|
||||
};
|
||||
fragment.validate()?;
|
||||
Ok(fragment)
|
||||
}
|
||||
|
||||
/// Creates a validated audio fragment.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a stable protocol error when fragment or payload bounds are invalid.
|
||||
pub fn new_audio(
|
||||
sequence: u32,
|
||||
timestamp_ms: u64,
|
||||
fragment_index: u16,
|
||||
fragment_count: u16,
|
||||
payload: Vec<u8>,
|
||||
) -> Result<Self> {
|
||||
let fragment = Self {
|
||||
channel: MediaChannel::Audio,
|
||||
sequence,
|
||||
timestamp_ms,
|
||||
fragment_index,
|
||||
fragment_count,
|
||||
payload,
|
||||
};
|
||||
fragment.validate()?;
|
||||
Ok(fragment)
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<()> {
|
||||
if self.fragment_count == 0 || self.fragment_index >= self.fragment_count {
|
||||
return Err(CoreError::Fragment);
|
||||
}
|
||||
if self.fragment_count > MAX_FRAGMENT_COUNT {
|
||||
return Err(CoreError::FragmentLimit);
|
||||
}
|
||||
if self.payload.len() > MAX_FRAGMENT_PAYLOAD_BYTES {
|
||||
return Err(CoreError::LengthMismatch);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decodes one datagram-v2 fragment.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a stable protocol error for malformed or out-of-bound bytes.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self> {
|
||||
if bytes.len() < DATAGRAM_HEADER_BYTES {
|
||||
return Err(CoreError::Truncated);
|
||||
}
|
||||
if &bytes[..2] != b"VD" {
|
||||
return Err(CoreError::Magic);
|
||||
}
|
||||
if bytes[2] != 2 {
|
||||
return Err(CoreError::UnsupportedVersion);
|
||||
}
|
||||
let channel = MediaChannel::from_wire(bytes[3])?;
|
||||
if bytes[4] != 0 {
|
||||
return Err(CoreError::Field);
|
||||
}
|
||||
let sequence = u32::from_be_bytes([bytes[5], bytes[6], bytes[7], bytes[8]]);
|
||||
let timestamp_ms = u64::from_be_bytes([
|
||||
bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15], bytes[16],
|
||||
]);
|
||||
let fragment_index = u16::from_be_bytes([bytes[17], bytes[18]]);
|
||||
let fragment_count = u16::from_be_bytes([bytes[19], bytes[20]]);
|
||||
let payload_length = usize::from(u16::from_be_bytes([bytes[21], bytes[22]]));
|
||||
if bytes.len() != DATAGRAM_HEADER_BYTES + payload_length || bytes.len() > MAX_DATAGRAM_BYTES
|
||||
{
|
||||
return Err(CoreError::LengthMismatch);
|
||||
}
|
||||
let fragment = Self {
|
||||
channel,
|
||||
sequence,
|
||||
timestamp_ms,
|
||||
fragment_index,
|
||||
fragment_count,
|
||||
payload: bytes[DATAGRAM_HEADER_BYTES..].to_vec(),
|
||||
};
|
||||
fragment.validate()?;
|
||||
Ok(fragment)
|
||||
}
|
||||
|
||||
/// Encodes one datagram-v2 fragment.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a stable protocol error when fragment or payload bounds are invalid.
|
||||
pub fn encode(&self) -> Result<Vec<u8>> {
|
||||
self.validate()?;
|
||||
let mut output = Vec::with_capacity(DATAGRAM_HEADER_BYTES + self.payload.len());
|
||||
output.extend_from_slice(b"VD");
|
||||
output.extend_from_slice(&[2, self.channel.wire(), 0]);
|
||||
output.extend_from_slice(&self.sequence.to_be_bytes());
|
||||
output.extend_from_slice(&self.timestamp_ms.to_be_bytes());
|
||||
output.extend_from_slice(&self.fragment_index.to_be_bytes());
|
||||
output.extend_from_slice(&self.fragment_count.to_be_bytes());
|
||||
output.extend_from_slice(
|
||||
&u16::try_from(self.payload.len())
|
||||
.map_err(|_| CoreError::LengthMismatch)?
|
||||
.to_be_bytes(),
|
||||
);
|
||||
output.extend_from_slice(&self.payload);
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct EncodedUnit {
|
||||
pub channel: MediaChannel,
|
||||
pub sequence: u32,
|
||||
pub timestamp_ms: u64,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IncompleteUnit {
|
||||
channel: MediaChannel,
|
||||
sequence: u32,
|
||||
timestamp_ms: u64,
|
||||
fragment_count: u16,
|
||||
started_at_ms: u64,
|
||||
total_bytes: usize,
|
||||
fragments: Vec<Option<Vec<u8>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Reassembler {
|
||||
incomplete: VecDeque<IncompleteUnit>,
|
||||
evicted_units: u64,
|
||||
expired_units: u64,
|
||||
}
|
||||
|
||||
impl Reassembler {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Adds a fragment and returns a complete encoded unit when all fragments arrive.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a stable protocol error for conflicting fragments or size-bound violations.
|
||||
pub fn push(&mut self, fragment: MediaFragment, now_ms: u64) -> Result<Option<EncodedUnit>> {
|
||||
fragment.validate()?;
|
||||
self.expire(now_ms);
|
||||
|
||||
let position = self.incomplete.iter().position(|unit| {
|
||||
unit.channel == fragment.channel && unit.sequence == fragment.sequence
|
||||
});
|
||||
let position = if let Some(position) = position {
|
||||
position
|
||||
} else {
|
||||
if self.incomplete.len() == MAX_INCOMPLETE_UNITS {
|
||||
self.incomplete.pop_front();
|
||||
self.evicted_units += 1;
|
||||
}
|
||||
self.incomplete.push_back(IncompleteUnit {
|
||||
channel: fragment.channel,
|
||||
sequence: fragment.sequence,
|
||||
timestamp_ms: fragment.timestamp_ms,
|
||||
fragment_count: fragment.fragment_count,
|
||||
started_at_ms: now_ms,
|
||||
total_bytes: 0,
|
||||
fragments: vec![None; usize::from(fragment.fragment_count)],
|
||||
});
|
||||
self.incomplete.len() - 1
|
||||
};
|
||||
|
||||
let unit = &mut self.incomplete[position];
|
||||
if unit.timestamp_ms != fragment.timestamp_ms
|
||||
|| unit.fragment_count != fragment.fragment_count
|
||||
{
|
||||
self.incomplete.remove(position);
|
||||
return Err(CoreError::ConflictingDuplicate);
|
||||
}
|
||||
let index = usize::from(fragment.fragment_index);
|
||||
if let Some(existing) = &unit.fragments[index] {
|
||||
if existing == &fragment.payload {
|
||||
return Ok(None);
|
||||
}
|
||||
self.incomplete.remove(position);
|
||||
return Err(CoreError::ConflictingDuplicate);
|
||||
}
|
||||
let total_bytes = unit
|
||||
.total_bytes
|
||||
.checked_add(fragment.payload.len())
|
||||
.ok_or(CoreError::FragmentLimit)?;
|
||||
if total_bytes > MAX_COMPLETE_UNIT_BYTES {
|
||||
self.incomplete.remove(position);
|
||||
return Err(CoreError::FragmentLimit);
|
||||
}
|
||||
unit.total_bytes = total_bytes;
|
||||
unit.fragments[index] = Some(fragment.payload);
|
||||
if unit.fragments.iter().any(Option::is_none) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let complete = self
|
||||
.incomplete
|
||||
.remove(position)
|
||||
.ok_or(CoreError::InvalidArgument)?;
|
||||
let mut payload = Vec::with_capacity(complete.total_bytes);
|
||||
for bytes in complete.fragments {
|
||||
payload.extend(bytes.ok_or(CoreError::InvalidArgument)?);
|
||||
}
|
||||
Ok(Some(EncodedUnit {
|
||||
channel: complete.channel,
|
||||
sequence: complete.sequence,
|
||||
timestamp_ms: complete.timestamp_ms,
|
||||
payload,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn expire(&mut self, now_ms: u64) {
|
||||
let before = self.incomplete.len();
|
||||
self.incomplete
|
||||
.retain(|unit| now_ms.saturating_sub(unit.started_at_ms) < EXPIRY_MILLISECONDS);
|
||||
self.expired_units += u64::try_from(before - self.incomplete.len()).unwrap_or(u64::MAX);
|
||||
}
|
||||
|
||||
pub(crate) fn next_expiry_ms(&self) -> Option<u64> {
|
||||
self.incomplete
|
||||
.front()
|
||||
.map(|unit| unit.started_at_ms.saturating_add(EXPIRY_MILLISECONDS))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn incomplete_units(&self) -> usize {
|
||||
self.incomplete.len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn evicted_units(&self) -> u64 {
|
||||
self.evicted_units
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn expired_units(&self) -> u64 {
|
||||
self.expired_units
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{CoreError, Result};
|
||||
use crate::input::decode_input;
|
||||
use crate::media::EncodedUnit;
|
||||
|
||||
pub const INPUT_QUEUE_CAPACITY: usize = 64;
|
||||
pub const CONTROL_QUEUE_CAPACITY: usize = 64;
|
||||
const MEDIA_QUEUE_CAPACITY: usize = 4;
|
||||
const MAX_CONTROL_BYTES: usize = 128 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct SessionStats {
|
||||
pub dropped_media_units: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SessionCore {
|
||||
input: VecDeque<Vec<u8>>,
|
||||
control: VecDeque<Vec<u8>>,
|
||||
media: VecDeque<EncodedUnit>,
|
||||
cancelled: bool,
|
||||
stats: SessionStats,
|
||||
}
|
||||
|
||||
impl SessionCore {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Validates and enqueues one VGI1 envelope without blocking.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a stable VGI1 parse error, `queue_full`, or `cancelled`.
|
||||
pub fn enqueue_input(&mut self, bytes: Vec<u8>, features: &[&str]) -> Result<()> {
|
||||
if self.cancelled {
|
||||
return Err(CoreError::Cancelled);
|
||||
}
|
||||
decode_input(&bytes, features)?;
|
||||
if self.input.len() == INPUT_QUEUE_CAPACITY {
|
||||
return Err(CoreError::QueueFull);
|
||||
}
|
||||
self.input.push_back(bytes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the oldest queued VGI1 envelope.
|
||||
#[must_use]
|
||||
pub fn dequeue_input(&mut self) -> Option<Vec<u8>> {
|
||||
self.input.pop_front()
|
||||
}
|
||||
|
||||
/// Enqueues one bounded reliable control body without blocking.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `invalid_argument`, `queue_full`, or `cancelled`.
|
||||
pub fn enqueue_control(&mut self, bytes: Vec<u8>) -> Result<()> {
|
||||
if self.cancelled {
|
||||
return Err(CoreError::Cancelled);
|
||||
}
|
||||
if bytes.len() > MAX_CONTROL_BYTES {
|
||||
return Err(CoreError::InvalidArgument);
|
||||
}
|
||||
if self.control.len() == CONTROL_QUEUE_CAPACITY {
|
||||
return Err(CoreError::QueueFull);
|
||||
}
|
||||
self.control.push_back(bytes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the oldest queued reliable control body.
|
||||
#[must_use]
|
||||
pub fn dequeue_control(&mut self) -> Option<Vec<u8>> {
|
||||
self.control.pop_front()
|
||||
}
|
||||
|
||||
/// Enqueues one bounded complete encoded media unit.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `invalid_argument` for an oversized unit or `cancelled` after cancellation.
|
||||
pub fn enqueue_media(&mut self, unit: EncodedUnit) -> Result<()> {
|
||||
if unit.payload.len() > crate::media::MAX_COMPLETE_UNIT_BYTES {
|
||||
return Err(CoreError::InvalidArgument);
|
||||
}
|
||||
if self.cancelled {
|
||||
self.stats.dropped_media_units += 1;
|
||||
return Err(CoreError::Cancelled);
|
||||
}
|
||||
if self.media.len() == MEDIA_QUEUE_CAPACITY {
|
||||
let position = self
|
||||
.media
|
||||
.iter()
|
||||
.position(|queued| queued.channel == unit.channel);
|
||||
if let Some(position) = position {
|
||||
self.media.remove(position);
|
||||
} else {
|
||||
self.stats.dropped_media_units += 1;
|
||||
return Ok(());
|
||||
}
|
||||
self.stats.dropped_media_units += 1;
|
||||
}
|
||||
self.media.push_back(unit);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn pop_media(&mut self) -> Option<EncodedUnit> {
|
||||
self.media.pop_front()
|
||||
}
|
||||
|
||||
pub fn cancel(&mut self) {
|
||||
self.cancelled = true;
|
||||
self.input.clear();
|
||||
self.control.clear();
|
||||
self.media.clear();
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn is_cancelled(&self) -> bool {
|
||||
self.cancelled
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn stats(&self) -> SessionStats {
|
||||
self.stats
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Default)]
|
||||
struct InProcessSession {
|
||||
incoming: VecDeque<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl InProcessSession {
|
||||
fn push_incoming(&mut self, bytes: Vec<u8>) {
|
||||
self.incoming.push_back(bytes);
|
||||
}
|
||||
|
||||
fn pop_incoming(&mut self) -> Option<Vec<u8>> {
|
||||
self.incoming.pop_front()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{InProcessSession, SessionCore, CONTROL_QUEUE_CAPACITY, INPUT_QUEUE_CAPACITY};
|
||||
use crate::error::CoreError;
|
||||
use crate::media::{EncodedUnit, MediaChannel};
|
||||
|
||||
#[test]
|
||||
fn queues_are_bounded_and_cancellation_is_idempotent() {
|
||||
let mut session = SessionCore::new();
|
||||
assert_eq!(
|
||||
session.enqueue_input(vec![0; 24], &[]),
|
||||
Err(CoreError::Magic)
|
||||
);
|
||||
let input = b"VGI1\x01\x04\x01\x00\x00\x1e".to_vec();
|
||||
for _ in 0..INPUT_QUEUE_CAPACITY {
|
||||
session
|
||||
.enqueue_input(input.clone(), &[])
|
||||
.expect("within bound");
|
||||
}
|
||||
assert_eq!(session.enqueue_input(input, &[]), Err(CoreError::QueueFull));
|
||||
|
||||
for value in 0..CONTROL_QUEUE_CAPACITY {
|
||||
session
|
||||
.enqueue_control(vec![u8::try_from(value).expect("capacity fits u8")])
|
||||
.expect("within bound");
|
||||
}
|
||||
assert_eq!(session.enqueue_control(vec![0]), Err(CoreError::QueueFull));
|
||||
let mut oversized_control = SessionCore::new();
|
||||
assert_eq!(
|
||||
oversized_control.enqueue_control(vec![0; 128 * 1024 + 1]),
|
||||
Err(CoreError::InvalidArgument)
|
||||
);
|
||||
|
||||
session
|
||||
.enqueue_media(EncodedUnit {
|
||||
channel: MediaChannel::Audio,
|
||||
sequence: 1,
|
||||
timestamp_ms: 1,
|
||||
payload: vec![1],
|
||||
})
|
||||
.expect("bounded media");
|
||||
|
||||
session.cancel();
|
||||
session.cancel();
|
||||
assert!(session.is_cancelled());
|
||||
assert_eq!(
|
||||
session.enqueue_input(vec![0], &[]),
|
||||
Err(CoreError::Cancelled)
|
||||
);
|
||||
assert!(session.pop_media().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_queue_evicts_oldest_same_channel_at_four_units() {
|
||||
let mut session = SessionCore::new();
|
||||
assert_eq!(
|
||||
session.enqueue_media(EncodedUnit {
|
||||
channel: MediaChannel::Video,
|
||||
sequence: 99,
|
||||
timestamp_ms: 0,
|
||||
payload: vec![0; 1_048_577],
|
||||
}),
|
||||
Err(CoreError::InvalidArgument)
|
||||
);
|
||||
for sequence in 0..5 {
|
||||
session
|
||||
.enqueue_media(EncodedUnit {
|
||||
channel: MediaChannel::Video,
|
||||
sequence,
|
||||
timestamp_ms: u64::from(sequence),
|
||||
payload: vec![u8::try_from(sequence).expect("test sequence fits u8")],
|
||||
})
|
||||
.expect("bounded media");
|
||||
}
|
||||
assert_eq!(session.stats().dropped_media_units, 1);
|
||||
assert_eq!(session.pop_media().expect("media").sequence, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_in_process_session_preserves_byte_order() {
|
||||
let mut transport = InProcessSession::default();
|
||||
transport.push_incoming(vec![1, 2]);
|
||||
transport.push_incoming(vec![3]);
|
||||
assert_eq!(transport.pop_incoming(), Some(vec![1, 2]));
|
||||
assert_eq!(transport.pop_incoming(), Some(vec![3]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_queue_rejects_malformed_vgi1_at_the_boundary() {
|
||||
let mut session = SessionCore::new();
|
||||
assert_eq!(
|
||||
session.enqueue_input(vec![0], &[]),
|
||||
Err(CoreError::Truncated)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_queue_honors_negotiated_vgi1_features() {
|
||||
let mut session = SessionCore::new();
|
||||
let absolute = b"VGI1\x06\x08\x00\x01\x00\x01\x00\x02\x00\x02".to_vec();
|
||||
assert_eq!(
|
||||
session.enqueue_input(absolute.clone(), &[]),
|
||||
Err(CoreError::UnsupportedFeature)
|
||||
);
|
||||
session
|
||||
.enqueue_input(absolute.clone(), &["input.absolute.v1"])
|
||||
.expect("negotiated absolute input");
|
||||
assert_eq!(session.dequeue_input(), Some(absolute));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_and_control_queues_drain_in_order() {
|
||||
let mut session = SessionCore::new();
|
||||
let first = b"VGI1\x01\x04\x01\x00\x00\x1e".to_vec();
|
||||
let second = b"VGI1\x01\x04\x00\x00\x00\x1e".to_vec();
|
||||
session
|
||||
.enqueue_input(first.clone(), &[])
|
||||
.expect("valid input");
|
||||
session
|
||||
.enqueue_input(second.clone(), &[])
|
||||
.expect("valid input");
|
||||
session.enqueue_control(vec![1]).expect("valid control");
|
||||
session.enqueue_control(vec![2]).expect("valid control");
|
||||
|
||||
assert_eq!(session.dequeue_input(), Some(first));
|
||||
assert_eq!(session.dequeue_input(), Some(second));
|
||||
assert_eq!(session.dequeue_input(), None);
|
||||
assert_eq!(session.dequeue_control(), Some(vec![1]));
|
||||
assert_eq!(session.dequeue_control(), Some(vec![2]));
|
||||
assert_eq!(session.dequeue_control(), None);
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
use std::fmt;
|
||||
use std::io::Cursor;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rustls::client::ResolvesClientCert;
|
||||
use rustls::pki_types::CertificateDer;
|
||||
use rustls::sign::{CertifiedKey, Signer, SigningKey};
|
||||
use rustls::{ClientConfig, RootCertStore, SignatureAlgorithm, SignatureScheme};
|
||||
|
||||
use crate::error::{CoreError, Result};
|
||||
use crate::wire::NativeTunnelCredential;
|
||||
|
||||
pub(crate) type SignCallback = dyn Fn(&[u8]) -> Result<[u8; 64]> + Send + Sync;
|
||||
|
||||
pub(crate) struct CallbackSigningKey {
|
||||
callback: Arc<Mutex<Option<Arc<SignCallback>>>>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for CallbackSigningKey {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("CallbackSigningKey")
|
||||
}
|
||||
}
|
||||
|
||||
impl CallbackSigningKey {
|
||||
pub(crate) fn new(callback: Arc<SignCallback>) -> Self {
|
||||
Self {
|
||||
callback: Arc::new(Mutex::new(Some(callback))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SigningKey for CallbackSigningKey {
|
||||
fn choose_scheme(&self, offered: &[SignatureScheme]) -> Option<Box<dyn Signer>> {
|
||||
offered
|
||||
.contains(&SignatureScheme::ED25519)
|
||||
.then(|| Box::new(CallbackSigner(Arc::clone(&self.callback))) as Box<dyn Signer>)
|
||||
}
|
||||
|
||||
fn algorithm(&self) -> SignatureAlgorithm {
|
||||
SignatureAlgorithm::ED25519
|
||||
}
|
||||
}
|
||||
|
||||
struct CallbackSigner(Arc<Mutex<Option<Arc<SignCallback>>>>);
|
||||
|
||||
impl fmt::Debug for CallbackSigner {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("CallbackSigner")
|
||||
}
|
||||
}
|
||||
|
||||
impl Signer for CallbackSigner {
|
||||
fn sign(&self, message: &[u8]) -> std::result::Result<Vec<u8>, rustls::Error> {
|
||||
let callback = self
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| rustls::Error::General("client signing state failed".to_owned()))?
|
||||
.take()
|
||||
.ok_or_else(|| rustls::Error::General("client signer already used".to_owned()))?;
|
||||
callback(message)
|
||||
.map(|signature| signature.to_vec())
|
||||
.map_err(|_| rustls::Error::General("client signing failed".to_owned()))
|
||||
}
|
||||
|
||||
fn scheme(&self) -> SignatureScheme {
|
||||
SignatureScheme::ED25519
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ClientIdentity(Arc<CertifiedKey>);
|
||||
|
||||
impl ResolvesClientCert for ClientIdentity {
|
||||
fn resolve(
|
||||
&self,
|
||||
_root_hint_subjects: &[&[u8]],
|
||||
sigschemes: &[SignatureScheme],
|
||||
) -> Option<Arc<CertifiedKey>> {
|
||||
sigschemes
|
||||
.contains(&SignatureScheme::ED25519)
|
||||
.then(|| Arc::clone(&self.0))
|
||||
}
|
||||
|
||||
fn has_certs(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn client_config(
|
||||
credential: &NativeTunnelCredential,
|
||||
callback: Arc<SignCallback>,
|
||||
) -> Result<ClientConfig> {
|
||||
let certificate_chain = rustls_pemfile::certs(&mut Cursor::new(
|
||||
credential.certificate_chain_pem().as_bytes(),
|
||||
))
|
||||
.collect::<std::result::Result<Vec<CertificateDer<'static>>, _>>()
|
||||
.map_err(|_| CoreError::Tls)?;
|
||||
if certificate_chain.is_empty() {
|
||||
return Err(CoreError::Tls);
|
||||
}
|
||||
let mut roots = RootCertStore::empty();
|
||||
let trust_bundle =
|
||||
rustls_pemfile::certs(&mut Cursor::new(credential.trust_bundle_pem().as_bytes()))
|
||||
.collect::<std::result::Result<Vec<CertificateDer<'static>>, _>>()
|
||||
.map_err(|_| CoreError::Tls)?;
|
||||
if trust_bundle.is_empty() || roots.add_parsable_certificates(trust_bundle).1 != 0 {
|
||||
return Err(CoreError::Tls);
|
||||
}
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let mut config = ClientConfig::builder_with_provider(provider)
|
||||
.with_protocol_versions(&[&rustls::version::TLS13])
|
||||
.map_err(|_| CoreError::Tls)?
|
||||
.with_root_certificates(roots)
|
||||
.with_client_cert_resolver(Arc::new(ClientIdentity(Arc::new(CertifiedKey::new(
|
||||
certificate_chain,
|
||||
Arc::new(CallbackSigningKey::new(callback)),
|
||||
)))));
|
||||
config.alpn_protocols = vec![b"versevdi-gateway-v1".to_vec()];
|
||||
config.enable_early_data = false;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use super::*;
|
||||
|
||||
struct DropMarker(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for DropMarker {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_callback_is_consumed_and_released_after_one_signature() {
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let marker = DropMarker(Arc::clone(&dropped));
|
||||
let callback: Arc<SignCallback> = Arc::new(move |_| {
|
||||
let _ = ▮
|
||||
Ok([7; 64])
|
||||
});
|
||||
let key = CallbackSigningKey::new(callback);
|
||||
let signer = key
|
||||
.choose_scheme(&[SignatureScheme::ED25519])
|
||||
.expect("ED25519 signer");
|
||||
let second_signer = key
|
||||
.choose_scheme(&[SignatureScheme::ED25519])
|
||||
.expect("second ED25519 signer");
|
||||
drop(key);
|
||||
|
||||
assert_eq!(
|
||||
signer.sign(b"handshake").expect("first signature"),
|
||||
vec![7; 64]
|
||||
);
|
||||
assert!(dropped.load(Ordering::SeqCst), "callback remained retained");
|
||||
assert!(
|
||||
second_signer.sign(b"second request").is_err(),
|
||||
"signer was reusable"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,803 @@
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::error::{CoreError, Result};
|
||||
|
||||
const MAX_MANIFEST_JSON_BYTES: usize = 128 * 1024;
|
||||
const MAX_CREDENTIAL_JSON_BYTES: usize = 196 * 1024;
|
||||
const MAX_ADMISSION_JSON_BYTES: usize = 16 * 1024;
|
||||
|
||||
fn decode_strict<T: DeserializeOwned>(bytes: &[u8], maximum: usize) -> Result<T> {
|
||||
if bytes.is_empty() || bytes.len() > maximum {
|
||||
return Err(CoreError::InvalidArgument);
|
||||
}
|
||||
serde_json::from_slice(bytes).map_err(|_| CoreError::InvalidArgument)
|
||||
}
|
||||
|
||||
fn bounded(value: &str, minimum: usize, maximum: usize) -> bool {
|
||||
(minimum..=maximum).contains(&value.len())
|
||||
}
|
||||
|
||||
const fn base64url_value(value: u8) -> Option<u8> {
|
||||
match value {
|
||||
b'A'..=b'Z' => Some(value - b'A'),
|
||||
b'a'..=b'z' => Some(value - b'a' + 26),
|
||||
b'0'..=b'9' => Some(value - b'0' + 52),
|
||||
b'-' => Some(62),
|
||||
b'_' => Some(63),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
const fn base64_value(value: u8) -> Option<u8> {
|
||||
match value {
|
||||
b'A'..=b'Z' => Some(value - b'A'),
|
||||
b'a'..=b'z' => Some(value - b'a' + 26),
|
||||
b'0'..=b'9' => Some(value - b'0' + 52),
|
||||
b'+' => Some(62),
|
||||
b'/' => Some(63),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_base64(value: &str) -> bool {
|
||||
let bytes = value.as_bytes();
|
||||
if bytes.is_empty() || !bytes.len().is_multiple_of(4) {
|
||||
return false;
|
||||
}
|
||||
let data_length = bytes
|
||||
.iter()
|
||||
.position(|byte| *byte == b'=')
|
||||
.unwrap_or(bytes.len());
|
||||
let padding = bytes.len() - data_length;
|
||||
if data_length == 0
|
||||
|| padding > 2
|
||||
|| !bytes[..data_length]
|
||||
.iter()
|
||||
.all(|byte| base64_value(*byte).is_some())
|
||||
|| !bytes[data_length..].iter().all(|byte| *byte == b'=')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
match padding {
|
||||
0 => true,
|
||||
1 => base64_value(bytes[data_length - 1]).is_some_and(|value| value.trailing_zeros() >= 2),
|
||||
2 => base64_value(bytes[data_length - 1]).is_some_and(|value| value.trailing_zeros() >= 4),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn certificate_only_pem(value: &str) -> bool {
|
||||
let mut lines = value.lines().peekable();
|
||||
let mut blocks = 0_u32;
|
||||
loop {
|
||||
while lines.next_if(|line| line.trim().is_empty()).is_some() {}
|
||||
let Some(begin) = lines.next() else {
|
||||
return blocks > 0;
|
||||
};
|
||||
if begin != "-----BEGIN CERTIFICATE-----" {
|
||||
return false;
|
||||
}
|
||||
blocks += 1;
|
||||
let mut body = String::new();
|
||||
let mut complete = false;
|
||||
for line in lines.by_ref() {
|
||||
if line == "-----END CERTIFICATE-----" {
|
||||
complete = true;
|
||||
break;
|
||||
}
|
||||
if line.is_empty() || line.trim() != line {
|
||||
return false;
|
||||
}
|
||||
body.push_str(line);
|
||||
}
|
||||
if !complete || !valid_base64(&body) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_base64url_decoded_len(value: &str) -> Option<usize> {
|
||||
let bytes = value.as_bytes();
|
||||
if bytes.is_empty() || bytes.iter().any(|byte| base64url_value(*byte).is_none()) {
|
||||
return None;
|
||||
}
|
||||
let remainder_bytes = match bytes.len() % 4 {
|
||||
0 => 0,
|
||||
2 if base64url_value(*bytes.last()?)?.trailing_zeros() >= 4 => 1,
|
||||
3 if base64url_value(*bytes.last()?)?.trailing_zeros() >= 2 => 2,
|
||||
_ => return None,
|
||||
};
|
||||
bytes
|
||||
.len()
|
||||
.checked_div(4)?
|
||||
.checked_mul(3)?
|
||||
.checked_add(remainder_bytes)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
struct Timestamp {
|
||||
year: u16,
|
||||
month: u8,
|
||||
day: u8,
|
||||
hour: u8,
|
||||
minute: u8,
|
||||
second: u8,
|
||||
nanosecond: u32,
|
||||
}
|
||||
|
||||
pub(crate) fn now_utc() -> Result<String> {
|
||||
system_time_utc(SystemTime::now())
|
||||
}
|
||||
|
||||
fn system_time_utc(now: SystemTime) -> Result<String> {
|
||||
let seconds = now
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|_| CoreError::InvalidArgument)?
|
||||
.as_secs();
|
||||
let days = seconds / 86_400;
|
||||
let day_seconds = seconds % 86_400;
|
||||
let shifted = days
|
||||
.checked_add(719_468)
|
||||
.ok_or(CoreError::InvalidArgument)?;
|
||||
let era = shifted / 146_097;
|
||||
let day_of_era = shifted % 146_097;
|
||||
let year_of_era =
|
||||
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
|
||||
let mut year = year_of_era + era * 400;
|
||||
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
|
||||
let month_prime = (5 * day_of_year + 2) / 153;
|
||||
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
|
||||
let month = if month_prime < 10 {
|
||||
month_prime + 3
|
||||
} else {
|
||||
month_prime - 9
|
||||
};
|
||||
if month <= 2 {
|
||||
year += 1;
|
||||
}
|
||||
if year > 9_999 {
|
||||
return Err(CoreError::InvalidArgument);
|
||||
}
|
||||
let hour = day_seconds / 3_600;
|
||||
let minute = (day_seconds % 3_600) / 60;
|
||||
let second = day_seconds % 60;
|
||||
Ok(format!(
|
||||
"{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z"
|
||||
))
|
||||
}
|
||||
|
||||
fn timestamp(value: &str, exact_seconds: bool) -> Option<Timestamp> {
|
||||
let bytes = value.as_bytes();
|
||||
if bytes.len() < 20
|
||||
|| bytes.len() > 30
|
||||
|| bytes[4] != b'-'
|
||||
|| bytes[7] != b'-'
|
||||
|| bytes[10] != b'T'
|
||||
|| bytes[13] != b':'
|
||||
|| bytes[16] != b':'
|
||||
|| *bytes.last()? != b'Z'
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let digits = |start: usize, end: usize| {
|
||||
bytes
|
||||
.get(start..end)?
|
||||
.iter()
|
||||
.try_fold(0_u32, |number, byte| {
|
||||
byte.is_ascii_digit()
|
||||
.then_some(number * 10 + u32::from(*byte - b'0'))
|
||||
})
|
||||
};
|
||||
let year = u16::try_from(digits(0, 4)?).ok()?;
|
||||
let month = u8::try_from(digits(5, 7)?).ok()?;
|
||||
let day = u8::try_from(digits(8, 10)?).ok()?;
|
||||
let hour = u8::try_from(digits(11, 13)?).ok()?;
|
||||
let minute = u8::try_from(digits(14, 16)?).ok()?;
|
||||
let second = u8::try_from(digits(17, 19)?).ok()?;
|
||||
if hour > 23 || minute > 59 || second > 59 {
|
||||
return None;
|
||||
}
|
||||
let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
|
||||
let maximum_day = match month {
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
2 if leap => 29,
|
||||
2 => 28,
|
||||
_ => return None,
|
||||
};
|
||||
if day == 0 || day > maximum_day {
|
||||
return None;
|
||||
}
|
||||
let nanosecond = if bytes.len() == 20 {
|
||||
0
|
||||
} else {
|
||||
if exact_seconds || bytes[19] != b'.' {
|
||||
return None;
|
||||
}
|
||||
let fraction = bytes.get(20..bytes.len() - 1)?;
|
||||
if fraction.is_empty()
|
||||
|| fraction.len() > 9
|
||||
|| !fraction.iter().all(u8::is_ascii_digit)
|
||||
|| *fraction.last()? == b'0'
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut value = fraction
|
||||
.iter()
|
||||
.fold(0_u32, |number, byte| number * 10 + u32::from(*byte - b'0'));
|
||||
for _ in fraction.len()..9 {
|
||||
value *= 10;
|
||||
}
|
||||
value
|
||||
};
|
||||
Some(Timestamp {
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
second,
|
||||
nanosecond,
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_dns_name(value: &str) -> bool {
|
||||
bounded(value, 1, 253)
|
||||
&& value.parse::<std::net::IpAddr>().is_err()
|
||||
&& !uuid_shaped(value)
|
||||
&& value.split('.').all(|label| {
|
||||
bounded(label, 1, 63)
|
||||
&& !label.starts_with('-')
|
||||
&& !label.ends_with('-')
|
||||
&& label
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
})
|
||||
}
|
||||
|
||||
fn uuid_shaped(value: &str) -> bool {
|
||||
value.len() == 36
|
||||
&& value.bytes().enumerate().all(|(index, byte)| match index {
|
||||
8 | 13 | 18 | 23 => byte == b'-',
|
||||
_ => byte.is_ascii_hexdigit(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CapabilityProfile {
|
||||
transport: String,
|
||||
framing: String,
|
||||
media: String,
|
||||
audio: String,
|
||||
source_rate_control: String,
|
||||
client_decode: Vec<String>,
|
||||
}
|
||||
|
||||
impl CapabilityProfile {
|
||||
/// Creates and validates an RC5 capability profile.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `invalid_argument` when a field violates RC5 bounds or registry values.
|
||||
pub fn new(
|
||||
transport: &str,
|
||||
framing: &str,
|
||||
media: &str,
|
||||
audio: &str,
|
||||
source_rate_control: &str,
|
||||
client_decode: Vec<String>,
|
||||
) -> Result<Self> {
|
||||
let profile = Self {
|
||||
transport: transport.to_owned(),
|
||||
framing: framing.to_owned(),
|
||||
media: media.to_owned(),
|
||||
audio: audio.to_owned(),
|
||||
source_rate_control: source_rate_control.to_owned(),
|
||||
client_decode,
|
||||
};
|
||||
profile.validate()?;
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<()> {
|
||||
if !bounded(&self.transport, 1, 64)
|
||||
|| !matches!(self.framing.as_str(), "datagram-v1" | "datagram-v2")
|
||||
|| !bounded(&self.media, 1, 64)
|
||||
|| !bounded(&self.audio, 1, 64)
|
||||
|| !bounded(&self.source_rate_control, 1, 64)
|
||||
|| !(1..=2).contains(&self.client_decode.len())
|
||||
|| self
|
||||
.client_decode
|
||||
.iter()
|
||||
.any(|value| !matches!(value.as_str(), "h264-opus" | "hevc-opus"))
|
||||
|| self.client_decode.len()
|
||||
!= self
|
||||
.client_decode
|
||||
.iter()
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.len()
|
||||
{
|
||||
return Err(CoreError::InvalidArgument);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_subset_of(&self, offered: &Self) -> bool {
|
||||
self.transport == offered.transport
|
||||
&& self.framing == offered.framing
|
||||
&& self.media == offered.media
|
||||
&& self.audio == offered.audio
|
||||
&& self.source_rate_control == offered.source_rate_control
|
||||
&& self
|
||||
.client_decode
|
||||
.iter()
|
||||
.all(|codec| offered.client_decode.contains(codec))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ManifestGateway {
|
||||
id: String,
|
||||
addresses: Vec<String>,
|
||||
public_identity: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ManifestTunnel {
|
||||
versions: Vec<String>,
|
||||
features: Vec<String>,
|
||||
}
|
||||
|
||||
#[allow(clippy::struct_field_names)]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ManifestBounds {
|
||||
minimum_kbps: u64,
|
||||
target_kbps: u64,
|
||||
maximum_kbps: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct DisplayMode {
|
||||
resolution_width: u16,
|
||||
resolution_height: u16,
|
||||
fps: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ManifestProfile {
|
||||
id: String,
|
||||
bounds: ManifestBounds,
|
||||
display_mode: Option<DisplayMode>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct GrantReference {
|
||||
opaque_value: String,
|
||||
expires_at: String,
|
||||
audience: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConnectionManifest {
|
||||
version: String,
|
||||
purpose: String,
|
||||
session_id: String,
|
||||
reconnect_sequence: u64,
|
||||
gateway: ManifestGateway,
|
||||
tunnel: ManifestTunnel,
|
||||
profile: ManifestProfile,
|
||||
grant: GrantReference,
|
||||
correlation_id: String,
|
||||
}
|
||||
|
||||
impl ConnectionManifest {
|
||||
/// Strictly decodes and validates an RC5 connection manifest.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `invalid_argument` for malformed, duplicate, trailing, unknown, or invalid data.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self> {
|
||||
let manifest: Self = decode_strict(bytes, MAX_MANIFEST_JSON_BYTES)?;
|
||||
manifest.validate()?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<()> {
|
||||
let bounds = &self.profile.bounds;
|
||||
let valid_display = self.profile.display_mode.as_ref().is_none_or(|mode| {
|
||||
(320..=16_384).contains(&mode.resolution_width)
|
||||
&& (200..=8_640).contains(&mode.resolution_height)
|
||||
&& (1..=240).contains(&mode.fps)
|
||||
});
|
||||
if self.version != "1"
|
||||
|| !matches!(self.purpose.as_str(), "launch" | "reconnect")
|
||||
|| !bounded(&self.session_id, 1, 128)
|
||||
|| !bounded(&self.gateway.id, 1, 128)
|
||||
|| !(1..=4).contains(&self.gateway.addresses.len())
|
||||
|| self
|
||||
.gateway
|
||||
.addresses
|
||||
.iter()
|
||||
.any(|address| !bounded(address, 1, 256))
|
||||
|| !valid_dns_name(&self.gateway.public_identity)
|
||||
|| self.gateway.public_identity == self.gateway.id
|
||||
|| !(1..=4).contains(&self.tunnel.versions.len())
|
||||
|| self
|
||||
.tunnel
|
||||
.versions
|
||||
.iter()
|
||||
.any(|version| !bounded(version, 1, 64))
|
||||
|| self.tunnel.features.len() > 32
|
||||
|| self
|
||||
.tunnel
|
||||
.features
|
||||
.iter()
|
||||
.any(|feature| !bounded(feature, 1, 64))
|
||||
|| !bounded(&self.profile.id, 1, 128)
|
||||
|| !(1..=100_000_000).contains(&bounds.minimum_kbps)
|
||||
|| !(1..=100_000_000).contains(&bounds.target_kbps)
|
||||
|| !(1..=100_000_000).contains(&bounds.maximum_kbps)
|
||||
|| bounds.minimum_kbps > bounds.target_kbps
|
||||
|| bounds.target_kbps > bounds.maximum_kbps
|
||||
|| !valid_display
|
||||
|| !bounded(&self.grant.opaque_value, 43, 256)
|
||||
|| timestamp(&self.grant.expires_at, false).is_none()
|
||||
|| !bounded(&self.grant.audience, 1, 128)
|
||||
|| !bounded(&self.correlation_id, 1, 128)
|
||||
{
|
||||
return Err(CoreError::InvalidArgument);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks expiry and the supported tunnel binding at a supplied UTC instant.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `invalid_argument` for unsupported input or `expired` for an expired grant.
|
||||
pub fn validate_at(&self, now_utc: &str) -> Result<()> {
|
||||
let now = timestamp(now_utc, false).ok_or(CoreError::InvalidArgument)?;
|
||||
if timestamp(&self.grant.expires_at, false).ok_or(CoreError::InvalidArgument)? <= now {
|
||||
return Err(CoreError::Expired);
|
||||
}
|
||||
if !self
|
||||
.tunnel
|
||||
.versions
|
||||
.iter()
|
||||
.any(|value| value == "verse-gateway-v1/1")
|
||||
|| !self
|
||||
.tunnel
|
||||
.features
|
||||
.iter()
|
||||
.any(|value| value == "control.v1")
|
||||
{
|
||||
return Err(CoreError::InvalidArgument);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn addresses(&self) -> &[String] {
|
||||
&self.gateway.addresses
|
||||
}
|
||||
|
||||
pub(crate) fn public_identity(&self) -> &str {
|
||||
&self.gateway.public_identity
|
||||
}
|
||||
|
||||
pub(crate) fn features(&self) -> &[String] {
|
||||
&self.tunnel.features
|
||||
}
|
||||
|
||||
pub(crate) fn admission(
|
||||
&self,
|
||||
client_nonce: String,
|
||||
device_signature: String,
|
||||
capabilities: CapabilityProfile,
|
||||
) -> Result<TunnelAdmissionRequest> {
|
||||
let request = TunnelAdmissionRequest {
|
||||
version: "1".to_owned(),
|
||||
session_id: self.session_id.clone(),
|
||||
gateway_id: self.gateway.id.clone(),
|
||||
audience: self.grant.audience.clone(),
|
||||
grant: self.grant.opaque_value.clone(),
|
||||
reconnect_sequence: self.reconnect_sequence,
|
||||
client_nonce,
|
||||
device_signature,
|
||||
capabilities,
|
||||
};
|
||||
request.validate()?;
|
||||
Ok(request)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NativeTunnelCredential {
|
||||
client_device_id: String,
|
||||
device_key_id: String,
|
||||
certificate_chain_pem: String,
|
||||
trust_bundle_pem: String,
|
||||
expires_at: String,
|
||||
}
|
||||
|
||||
impl NativeTunnelCredential {
|
||||
/// Strictly decodes and validates an RC5 native tunnel credential.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `invalid_argument` for malformed, unknown, or out-of-bound data.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self> {
|
||||
let credential: Self = decode_strict(bytes, MAX_CREDENTIAL_JSON_BYTES)?;
|
||||
if !bounded(&credential.client_device_id, 1, 128)
|
||||
|| !bounded(&credential.device_key_id, 1, 128)
|
||||
|| !bounded(&credential.certificate_chain_pem, 1, 65_536)
|
||||
|| !bounded(&credential.trust_bundle_pem, 1, 65_536)
|
||||
|| !certificate_only_pem(&credential.certificate_chain_pem)
|
||||
|| !certificate_only_pem(&credential.trust_bundle_pem)
|
||||
|| timestamp(&credential.expires_at, false).is_none()
|
||||
{
|
||||
return Err(CoreError::InvalidArgument);
|
||||
}
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
/// Checks credential expiry at a supplied UTC instant.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `invalid_argument` for an invalid instant or `expired` after expiry.
|
||||
pub fn validate_at(&self, now_utc: &str) -> Result<()> {
|
||||
let now = timestamp(now_utc, false).ok_or(CoreError::InvalidArgument)?;
|
||||
if timestamp(&self.expires_at, false).ok_or(CoreError::InvalidArgument)? <= now {
|
||||
return Err(CoreError::Expired);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn certificate_chain_pem(&self) -> &str {
|
||||
&self.certificate_chain_pem
|
||||
}
|
||||
|
||||
pub(crate) fn trust_bundle_pem(&self) -> &str {
|
||||
&self.trust_bundle_pem
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TunnelAdmissionRequest {
|
||||
version: String,
|
||||
session_id: String,
|
||||
gateway_id: String,
|
||||
audience: String,
|
||||
grant: String,
|
||||
reconnect_sequence: u64,
|
||||
client_nonce: String,
|
||||
device_signature: String,
|
||||
capabilities: CapabilityProfile,
|
||||
}
|
||||
|
||||
impl TunnelAdmissionRequest {
|
||||
/// Strictly decodes and validates an RC5 tunnel admission request.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `invalid_argument` for malformed, unknown, or out-of-bound data.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self> {
|
||||
let request: Self = decode_strict(bytes, MAX_ADMISSION_JSON_BYTES)?;
|
||||
request.validate()?;
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<()> {
|
||||
if self.version != "1"
|
||||
|| !bounded(&self.session_id, 1, 128)
|
||||
|| !bounded(&self.gateway_id, 1, 128)
|
||||
|| !bounded(&self.audience, 1, 256)
|
||||
|| !bounded(&self.grant, 43, 256)
|
||||
|| !bounded(&self.client_nonce, 16, 128)
|
||||
|| self.device_signature.len() != 86
|
||||
|| !matches!(raw_base64url_decoded_len(&self.client_nonce), Some(12..=96))
|
||||
|| raw_base64url_decoded_len(&self.device_signature) != Some(64)
|
||||
{
|
||||
return Err(CoreError::InvalidArgument);
|
||||
}
|
||||
self.capabilities.validate()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn admission_transcript(&self) -> Vec<u8> {
|
||||
let reconnect_sequence = self.reconnect_sequence.to_string();
|
||||
let decode_count = self.capabilities.client_decode.len().to_string();
|
||||
let mut fields = vec![
|
||||
self.session_id.as_str(),
|
||||
self.gateway_id.as_str(),
|
||||
self.audience.as_str(),
|
||||
self.grant.as_str(),
|
||||
reconnect_sequence.as_str(),
|
||||
self.client_nonce.as_str(),
|
||||
self.capabilities.transport.as_str(),
|
||||
self.capabilities.framing.as_str(),
|
||||
self.capabilities.media.as_str(),
|
||||
self.capabilities.audio.as_str(),
|
||||
self.capabilities.source_rate_control.as_str(),
|
||||
decode_count.as_str(),
|
||||
];
|
||||
fields.extend(self.capabilities.client_decode.iter().map(String::as_str));
|
||||
let mut transcript = String::from("versevdi/tunnel-admission/v1");
|
||||
for field in fields {
|
||||
transcript.push_str(&field.len().to_string());
|
||||
transcript.push(':');
|
||||
transcript.push_str(field);
|
||||
}
|
||||
transcript.into_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ClientSessionAuthority {
|
||||
version: String,
|
||||
session_id: String,
|
||||
gateway_id: String,
|
||||
audience: String,
|
||||
reconnect_sequence: u64,
|
||||
expires_at: String,
|
||||
capabilities: CapabilityProfile,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct StableError {
|
||||
version: String,
|
||||
code: String,
|
||||
message: String,
|
||||
retryable: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct DecodedStableError {
|
||||
pub(crate) error: CoreError,
|
||||
pub(crate) code: String,
|
||||
pub(crate) retryable: bool,
|
||||
}
|
||||
|
||||
impl ClientSessionAuthority {
|
||||
/// Strictly decodes and validates a provider-free RC5 client authority.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `invalid_argument` for malformed, unknown, provider-shaped, or invalid data.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self> {
|
||||
let authority: Self = decode_strict(bytes, MAX_ADMISSION_JSON_BYTES)?;
|
||||
if authority.version != "1"
|
||||
|| !bounded(&authority.session_id, 1, 128)
|
||||
|| !bounded(&authority.gateway_id, 1, 128)
|
||||
|| !bounded(&authority.audience, 1, 256)
|
||||
|| timestamp(&authority.expires_at, true).is_none()
|
||||
{
|
||||
return Err(CoreError::InvalidArgument);
|
||||
}
|
||||
authority.capabilities.validate()?;
|
||||
Ok(authority)
|
||||
}
|
||||
|
||||
/// Checks session, gateway, audience, reconnect, expiry, and capability bindings.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `invalid_argument` for an invalid clock value or `authority_rejected` on mismatch.
|
||||
pub fn validate_binding(
|
||||
&self,
|
||||
manifest: &ConnectionManifest,
|
||||
offered: &CapabilityProfile,
|
||||
now_utc: &str,
|
||||
) -> Result<()> {
|
||||
let now = timestamp(now_utc, false).ok_or(CoreError::InvalidArgument)?;
|
||||
let expires = timestamp(&self.expires_at, true).ok_or(CoreError::AuthorityRejected)?;
|
||||
let grant_expires =
|
||||
timestamp(&manifest.grant.expires_at, false).ok_or(CoreError::AuthorityRejected)?;
|
||||
if self.session_id != manifest.session_id
|
||||
|| self.gateway_id != manifest.gateway.id
|
||||
|| self.audience != manifest.grant.audience
|
||||
|| self.reconnect_sequence != manifest.reconnect_sequence
|
||||
|| expires <= now
|
||||
|| expires > grant_expires
|
||||
|| !self.capabilities.is_subset_of(offered)
|
||||
{
|
||||
return Err(CoreError::AuthorityRejected);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn session_id(&self) -> &str {
|
||||
&self.session_id
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode_stable_error(bytes: &[u8]) -> Result<DecodedStableError> {
|
||||
let stable: StableError = decode_strict(bytes, MAX_ADMISSION_JSON_BYTES)?;
|
||||
if stable.version != "1" || !bounded(&stable.code, 1, 128) || !bounded(&stable.message, 1, 512)
|
||||
{
|
||||
return Err(CoreError::Protocol);
|
||||
}
|
||||
let error = match stable.code.as_str() {
|
||||
"expired_grant" => CoreError::Expired,
|
||||
"admission_rejected"
|
||||
| "gateway_draining"
|
||||
| "invalid_authority"
|
||||
| "no_capability_overlap"
|
||||
| "wrong_gateway"
|
||||
| "provider_work_unavailable"
|
||||
| "clipboard_audit_unavailable" => CoreError::AuthorityRejected,
|
||||
"provider_identity_rejected"
|
||||
| "provider_malformed"
|
||||
| "provider_timeout"
|
||||
| "provider_unavailable"
|
||||
| "provider_state_unavailable" => CoreError::Transport,
|
||||
"invalid_hello" => CoreError::Protocol,
|
||||
_ => return Err(CoreError::Protocol),
|
||||
};
|
||||
Ok(DecodedStableError {
|
||||
error,
|
||||
code: stable.code,
|
||||
retryable: stable.retryable,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stable_error_tests {
|
||||
use super::{decode_stable_error, system_time_utc};
|
||||
use crate::error::CoreError;
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
#[test]
|
||||
fn system_clock_conversion_is_exact_at_epoch_and_leap_day() {
|
||||
assert_eq!(
|
||||
system_time_utc(UNIX_EPOCH).as_deref(),
|
||||
Ok("1970-01-01T00:00:00Z")
|
||||
);
|
||||
assert_eq!(
|
||||
system_time_utc(UNIX_EPOCH + Duration::from_secs(1_709_251_199)).as_deref(),
|
||||
Ok("2024-02-29T23:59:59Z")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_error_uses_exact_rc5_bounds_and_preserves_retryability() {
|
||||
let message = "m".repeat(512);
|
||||
let bytes = serde_json::to_vec(&serde_json::json!({
|
||||
"version": "1",
|
||||
"code": "gateway_draining",
|
||||
"message": message,
|
||||
"retryable": true,
|
||||
}))
|
||||
.expect("encode stable error");
|
||||
let decoded = decode_stable_error(&bytes).expect("RC5 stable error");
|
||||
assert_eq!(decoded.error, CoreError::AuthorityRejected);
|
||||
assert!(decoded.retryable);
|
||||
|
||||
for invalid in [
|
||||
serde_json::json!({"version":"1","code":"gateway_draining","message":"","retryable":true}),
|
||||
serde_json::json!({"version":"1","code":"c".repeat(129),"message":"m","retryable":true}),
|
||||
serde_json::json!({"version":"1","code":"gateway_draining","message":"m".repeat(513),"retryable":true}),
|
||||
serde_json::json!({"version":"1","code":"unknown","message":"m","retryable":true}),
|
||||
] {
|
||||
assert_eq!(
|
||||
decode_stable_error(&serde_json::to_vec(&invalid).expect("encode invalid")).err(),
|
||||
Some(CoreError::Protocol)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,907 @@
|
||||
#![allow(
|
||||
unsafe_code,
|
||||
clippy::borrow_as_ptr,
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::items_after_statements,
|
||||
clashing_extern_declarations
|
||||
)]
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::c_void;
|
||||
use std::mem::{offset_of, size_of};
|
||||
use std::ptr;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rustls::sign::SigningKey;
|
||||
use rustls::SignatureScheme;
|
||||
|
||||
#[path = "gateway_oracle.rs"]
|
||||
mod gateway_oracle;
|
||||
|
||||
thread_local! {
|
||||
static CORE_CONTEXTS: RefCell<HashMap<usize, usize>> = RefCell::new(HashMap::new());
|
||||
static ORACLES: RefCell<HashMap<usize, gateway_oracle::Oracle>> = RefCell::new(HashMap::new());
|
||||
}
|
||||
|
||||
const ABI_V1: u32 = 1;
|
||||
const OK: u32 = 0;
|
||||
const INVALID_ARGUMENT: u32 = 1;
|
||||
const INVALID_STATE: u32 = 2;
|
||||
const UNSUPPORTED_ABI: u32 = 3;
|
||||
const AUTHORITY_REJECTED: u32 = 4;
|
||||
const TLS: u32 = 5;
|
||||
const QUEUE_FULL: u32 = 9;
|
||||
const CANCELLED: u32 = 10;
|
||||
const REENTRANT: u32 = 11;
|
||||
const BUSY: u32 = 12;
|
||||
const INTERNAL: u32 = 13;
|
||||
|
||||
const STATE_CONNECTING: u32 = 1;
|
||||
const STATE_CONNECTED: u32 = 2;
|
||||
const STATE_CANCELLED: u32 = 3;
|
||||
const INPUT_KEYBOARD: u32 = 1;
|
||||
|
||||
#[repr(C)]
|
||||
struct Core {
|
||||
_private: [u8; 0],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct BytesView {
|
||||
data: *const u8,
|
||||
length: usize,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct StateEvent {
|
||||
struct_size: u32,
|
||||
abi_version: u32,
|
||||
state: u32,
|
||||
reason: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct ErrorEvent {
|
||||
struct_size: u32,
|
||||
abi_version: u32,
|
||||
code: u32,
|
||||
retryable: u32,
|
||||
phase: u32,
|
||||
reserved: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct StatsEvent {
|
||||
struct_size: u32,
|
||||
abi_version: u32,
|
||||
dropped_callbacks: u64,
|
||||
dropped_media_units: u64,
|
||||
dropped_input_events: u64,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct MediaEvent {
|
||||
struct_size: u32,
|
||||
abi_version: u32,
|
||||
channel: u32,
|
||||
sequence: u32,
|
||||
timestamp_ms: u64,
|
||||
encoded_unit: BytesView,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct ControlEvent {
|
||||
struct_size: u32,
|
||||
abi_version: u32,
|
||||
kind: u32,
|
||||
reserved: u32,
|
||||
payload: BytesView,
|
||||
}
|
||||
|
||||
type SignFn = unsafe extern "C" fn(*mut c_void, BytesView, *mut u8) -> u32;
|
||||
type StateFn = unsafe extern "C" fn(*mut c_void, *const StateEvent);
|
||||
type ErrorFn = unsafe extern "C" fn(*mut c_void, *const ErrorEvent);
|
||||
type StatsFn = unsafe extern "C" fn(*mut c_void, *const StatsEvent);
|
||||
type MediaFn = unsafe extern "C" fn(*mut c_void, *const MediaEvent);
|
||||
type ControlFn = unsafe extern "C" fn(*mut c_void, *const ControlEvent);
|
||||
|
||||
#[repr(C)]
|
||||
struct Config {
|
||||
struct_size: u32,
|
||||
abi_version: u32,
|
||||
context: *mut c_void,
|
||||
sign_admission: Option<SignFn>,
|
||||
sign_tls_ed25519: Option<SignFn>,
|
||||
on_state: Option<StateFn>,
|
||||
on_error: Option<ErrorFn>,
|
||||
on_stats: Option<StatsFn>,
|
||||
on_media: Option<MediaFn>,
|
||||
on_control: Option<ControlFn>,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct ConnectRequest {
|
||||
struct_size: u32,
|
||||
abi_version: u32,
|
||||
manifest_json: BytesView,
|
||||
tunnel_credential_json: BytesView,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct InputEvent {
|
||||
struct_size: u32,
|
||||
abi_version: u32,
|
||||
kind: u32,
|
||||
flags: u32,
|
||||
values: [i32; 12],
|
||||
}
|
||||
|
||||
unsafe extern "C" {
|
||||
fn verse_core_abi_version() -> u32;
|
||||
fn verse_core_create_v1(config: *const Config, out_core: *mut *mut Core) -> u32;
|
||||
fn verse_core_connect_v1(core: *mut Core, request: *const ConnectRequest) -> u32;
|
||||
fn verse_core_send_input_v1(core: *mut Core, event: *const InputEvent) -> u32;
|
||||
fn verse_core_request_idr_v1(core: *mut Core) -> u32;
|
||||
fn verse_core_cancel_v1(core: *mut Core) -> u32;
|
||||
fn verse_core_destroy_v1(core: *mut Core, timeout_ms: u32) -> u32;
|
||||
}
|
||||
|
||||
const MANIFEST: &[u8] = br#"{
|
||||
"version":"1","purpose":"launch","session_id":"session","reconnect_sequence":0,
|
||||
"gateway":{"id":"gateway","addresses":["gateway.test:443"],"public_identity":"gateway.test"},
|
||||
"tunnel":{"versions":["verse-gateway-v1/1"],"features":["control.v1","input.absolute.v1","input.scroll.v1"]},
|
||||
"profile":{"id":"standard","bounds":{"minimum_kbps":1000,"target_kbps":5000,"maximum_kbps":10000},"display_mode":{"resolution_width":1920,"resolution_height":1080,"fps":60}},
|
||||
"grant":{"opaque_value":"ggggggggggggggggggggggggggggggggggggggggggg","expires_at":"2099-01-01T00:00:00Z","audience":"audience"},
|
||||
"correlation_id":"correlation"
|
||||
}"#;
|
||||
const CREDENTIAL: &[u8] = br#"{"client_device_id":"device","device_key_id":"key","certificate_chain_pem":"-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----","trust_bundle_pem":"-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----","expires_at":"2099-01-01T00:00:00Z"}"#;
|
||||
|
||||
struct Context {
|
||||
core: AtomicUsize,
|
||||
admission_calls: AtomicUsize,
|
||||
tls_calls: AtomicUsize,
|
||||
admission_status: AtomicU32,
|
||||
tls_status: AtomicU32,
|
||||
admission_input: Mutex<Vec<u8>>,
|
||||
tls_input: Mutex<Vec<u8>>,
|
||||
states: Mutex<Vec<u32>>,
|
||||
wake: Condvar,
|
||||
reentry_cancel: AtomicU32,
|
||||
reentry_send: AtomicU32,
|
||||
reentry_destroy: AtomicU32,
|
||||
block_callbacks: AtomicBool,
|
||||
release_callbacks: AtomicBool,
|
||||
callback_active: AtomicUsize,
|
||||
callback_max: AtomicUsize,
|
||||
cancel_on_connecting: AtomicBool,
|
||||
reentry_target: AtomicUsize,
|
||||
reentry_results: Mutex<Vec<u32>>,
|
||||
admission_key: Mutex<Option<Arc<dyn SigningKey>>>,
|
||||
tls_key: Mutex<Option<Arc<dyn SigningKey>>>,
|
||||
}
|
||||
|
||||
impl Default for Context {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
core: AtomicUsize::new(0),
|
||||
admission_calls: AtomicUsize::new(0),
|
||||
tls_calls: AtomicUsize::new(0),
|
||||
admission_status: AtomicU32::new(OK),
|
||||
tls_status: AtomicU32::new(OK),
|
||||
admission_input: Mutex::new(Vec::new()),
|
||||
tls_input: Mutex::new(Vec::new()),
|
||||
states: Mutex::new(Vec::new()),
|
||||
wake: Condvar::new(),
|
||||
reentry_cancel: AtomicU32::new(u32::MAX),
|
||||
reentry_send: AtomicU32::new(u32::MAX),
|
||||
reentry_destroy: AtomicU32::new(u32::MAX),
|
||||
block_callbacks: AtomicBool::new(false),
|
||||
release_callbacks: AtomicBool::new(false),
|
||||
callback_active: AtomicUsize::new(0),
|
||||
callback_max: AtomicUsize::new(0),
|
||||
cancel_on_connecting: AtomicBool::new(false),
|
||||
reentry_target: AtomicUsize::new(0),
|
||||
reentry_results: Mutex::new(Vec::new()),
|
||||
admission_key: Mutex::new(None),
|
||||
tls_key: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn context<'a>(raw: *mut c_void) -> &'a Context {
|
||||
// Test invariant: every callback receives the live Box<Context> supplied at create.
|
||||
unsafe { &*raw.cast::<Context>() }
|
||||
}
|
||||
|
||||
unsafe extern "C" fn sign_admission(raw: *mut c_void, input: BytesView, output: *mut u8) -> u32 {
|
||||
// Test invariant: ABI promises input is readable for input.length during this callback.
|
||||
let bytes = unsafe { std::slice::from_raw_parts(input.data, input.length) };
|
||||
let ctx = unsafe { context(raw) };
|
||||
ctx.admission_calls.fetch_add(1, Ordering::SeqCst);
|
||||
*ctx.admission_input.lock().expect("admission lock") = bytes.to_vec();
|
||||
let status = ctx.admission_status.load(Ordering::SeqCst);
|
||||
if status != OK {
|
||||
return status;
|
||||
}
|
||||
let key = ctx.admission_key.lock().expect("admission key");
|
||||
let Some(key) = key.as_ref() else {
|
||||
return INTERNAL;
|
||||
};
|
||||
let signer = key
|
||||
.choose_scheme(&[SignatureScheme::ED25519])
|
||||
.expect("Ed25519 admission signer");
|
||||
let signature = signer.sign(bytes).expect("admission signature");
|
||||
assert_eq!(signature.len(), 64);
|
||||
// Test invariant: ABI promises a writable 64-byte Rust-owned signature buffer.
|
||||
unsafe { ptr::copy_nonoverlapping(signature.as_ptr(), output, signature.len()) };
|
||||
OK
|
||||
}
|
||||
|
||||
unsafe extern "C" fn sign_tls(raw: *mut c_void, input: BytesView, output: *mut u8) -> u32 {
|
||||
// Test invariant: ABI promises input is readable for input.length during this callback.
|
||||
let bytes = unsafe { std::slice::from_raw_parts(input.data, input.length) };
|
||||
let ctx = unsafe { context(raw) };
|
||||
ctx.tls_calls.fetch_add(1, Ordering::SeqCst);
|
||||
*ctx.tls_input.lock().expect("tls lock") = bytes.to_vec();
|
||||
let status = ctx.tls_status.load(Ordering::SeqCst);
|
||||
if status != OK {
|
||||
return status;
|
||||
}
|
||||
let key = ctx.tls_key.lock().expect("TLS key");
|
||||
let Some(key) = key.as_ref() else {
|
||||
return INTERNAL;
|
||||
};
|
||||
let signer = key
|
||||
.choose_scheme(&[SignatureScheme::ED25519])
|
||||
.expect("Ed25519 TLS signer");
|
||||
let signature = signer.sign(bytes).expect("TLS signature");
|
||||
assert_eq!(signature.len(), 64);
|
||||
// Test invariant: ABI promises a writable 64-byte Rust-owned signature buffer.
|
||||
unsafe { ptr::copy_nonoverlapping(signature.as_ptr(), output, signature.len()) };
|
||||
OK
|
||||
}
|
||||
|
||||
unsafe extern "C" fn sign_admission_probes_global_reentry(
|
||||
raw: *mut c_void,
|
||||
input: BytesView,
|
||||
output: *mut u8,
|
||||
) -> u32 {
|
||||
let ctx = unsafe { context(raw) };
|
||||
let target = ctx.reentry_target.load(Ordering::SeqCst) as *mut Core;
|
||||
let results = [
|
||||
unsafe { verse_core_abi_version() },
|
||||
unsafe { verse_core_create_v1(ptr::null(), ptr::null_mut()) },
|
||||
unsafe { verse_core_connect_v1(target, ptr::null()) },
|
||||
unsafe { verse_core_send_input_v1(target, ptr::null()) },
|
||||
unsafe { verse_core_request_idr_v1(target) },
|
||||
unsafe { verse_core_cancel_v1(target) },
|
||||
unsafe { verse_core_destroy_v1(target, 0) },
|
||||
];
|
||||
ctx.reentry_results
|
||||
.lock()
|
||||
.expect("signer reentry results")
|
||||
.extend(results);
|
||||
unsafe { sign_admission(raw, input, output) }
|
||||
}
|
||||
|
||||
unsafe extern "C" fn on_state_probes_global_reentry(raw: *mut c_void, event: *const StateEvent) {
|
||||
let ctx = unsafe { context(raw) };
|
||||
// Test invariant: ABI promises a readable state record for the callback duration.
|
||||
let state = unsafe { (*event).state };
|
||||
ctx.states.lock().expect("states lock").push(state);
|
||||
ctx.wake.notify_all();
|
||||
if state != STATE_CONNECTED {
|
||||
return;
|
||||
}
|
||||
let origin = ctx.core.load(Ordering::SeqCst) as *mut Core;
|
||||
let other = ctx.reentry_target.load(Ordering::SeqCst) as *mut Core;
|
||||
let results = [
|
||||
unsafe { verse_core_abi_version() },
|
||||
unsafe { verse_core_create_v1(ptr::null(), ptr::null_mut()) },
|
||||
unsafe { verse_core_connect_v1(origin, ptr::null()) },
|
||||
unsafe { verse_core_send_input_v1(origin, ptr::null()) },
|
||||
unsafe { verse_core_request_idr_v1(origin) },
|
||||
unsafe { verse_core_cancel_v1(other) },
|
||||
unsafe { verse_core_destroy_v1(origin, 0) },
|
||||
unsafe { verse_core_cancel_v1(origin) },
|
||||
];
|
||||
ctx.reentry_results
|
||||
.lock()
|
||||
.expect("event reentry results")
|
||||
.extend(results);
|
||||
}
|
||||
|
||||
unsafe extern "C" fn on_state(raw: *mut c_void, event: *const StateEvent) {
|
||||
let ctx = unsafe { context(raw) };
|
||||
let active = ctx.callback_active.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
ctx.callback_max.fetch_max(active, Ordering::SeqCst);
|
||||
// Test invariant: ABI promises a readable state record for the callback duration.
|
||||
let state = unsafe { (*event).state };
|
||||
ctx.states.lock().expect("states lock").push(state);
|
||||
ctx.wake.notify_all();
|
||||
|
||||
if state == STATE_CONNECTING && ctx.cancel_on_connecting.load(Ordering::SeqCst) {
|
||||
let core = ctx.core.load(Ordering::SeqCst) as *mut Core;
|
||||
ctx.reentry_cancel
|
||||
.store(unsafe { verse_core_cancel_v1(core) }, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
if ctx.block_callbacks.load(Ordering::SeqCst) && !ctx.release_callbacks.load(Ordering::SeqCst) {
|
||||
let mut states = ctx.states.lock().expect("states lock");
|
||||
while !ctx.release_callbacks.load(Ordering::SeqCst) {
|
||||
states = ctx.wake.wait(states).expect("callback wait");
|
||||
}
|
||||
}
|
||||
|
||||
if state == STATE_CONNECTED && ctx.reentry_cancel.load(Ordering::SeqCst) == u32::MAX {
|
||||
let core = ctx.core.load(Ordering::SeqCst) as *mut Core;
|
||||
// Test invariant: the stored handle is live until this callback and its destroy complete.
|
||||
ctx.reentry_cancel
|
||||
.store(unsafe { verse_core_cancel_v1(core) }, Ordering::SeqCst);
|
||||
let event = keyboard_event();
|
||||
// Test invariant: event and handle remain valid for the synchronous call.
|
||||
ctx.reentry_send.store(
|
||||
unsafe { verse_core_send_input_v1(core, &event) },
|
||||
Ordering::SeqCst,
|
||||
);
|
||||
// Test invariant: the callback intentionally probes the documented reentry rejection.
|
||||
ctx.reentry_destroy
|
||||
.store(unsafe { verse_core_destroy_v1(core, 1) }, Ordering::SeqCst);
|
||||
}
|
||||
ctx.callback_active.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
unsafe extern "C" fn sign_admission_reenters(
|
||||
raw: *mut c_void,
|
||||
input: BytesView,
|
||||
output: *mut u8,
|
||||
) -> u32 {
|
||||
let ctx = unsafe { context(raw) };
|
||||
ctx.reentry_cancel.store(
|
||||
unsafe { verse_core_cancel_v1(ctx.core.load(Ordering::SeqCst) as *mut Core) },
|
||||
Ordering::SeqCst,
|
||||
);
|
||||
unsafe { sign_admission(raw, input, output) }
|
||||
}
|
||||
|
||||
unsafe extern "C" fn sign_admission_waits_for_cancel(
|
||||
raw: *mut c_void,
|
||||
input: BytesView,
|
||||
output: *mut u8,
|
||||
) -> u32 {
|
||||
let ctx = unsafe { context(raw) };
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while ctx.reentry_cancel.load(Ordering::SeqCst) == u32::MAX {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"connecting callback did not cancel"
|
||||
);
|
||||
thread::yield_now();
|
||||
}
|
||||
unsafe { sign_admission(raw, input, output) }
|
||||
}
|
||||
|
||||
fn config(ctx: &mut Context) -> Config {
|
||||
Config {
|
||||
struct_size: size_of::<Config>() as u32,
|
||||
abi_version: ABI_V1,
|
||||
context: ptr::from_mut(ctx).cast(),
|
||||
sign_admission: Some(sign_admission),
|
||||
sign_tls_ed25519: Some(sign_tls),
|
||||
on_state: Some(on_state),
|
||||
on_error: None,
|
||||
on_stats: None,
|
||||
on_media: None,
|
||||
on_control: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn request(manifest: &[u8], credential: &[u8]) -> ConnectRequest {
|
||||
ConnectRequest {
|
||||
struct_size: size_of::<ConnectRequest>() as u32,
|
||||
abi_version: ABI_V1,
|
||||
manifest_json: BytesView {
|
||||
data: manifest.as_ptr(),
|
||||
length: manifest.len(),
|
||||
},
|
||||
tunnel_credential_json: BytesView {
|
||||
data: credential.as_ptr(),
|
||||
length: credential.len(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn keyboard_event() -> InputEvent {
|
||||
let mut values = [0; 12];
|
||||
values[0] = 1;
|
||||
values[2] = 30;
|
||||
InputEvent {
|
||||
struct_size: size_of::<InputEvent>() as u32,
|
||||
abi_version: ABI_V1,
|
||||
kind: INPUT_KEYBOARD,
|
||||
flags: 0,
|
||||
values,
|
||||
}
|
||||
}
|
||||
|
||||
fn create(ctx: &mut Context) -> *mut Core {
|
||||
let mut core = ptr::null_mut();
|
||||
let config = config(ctx);
|
||||
// Test invariant: config/out pointers remain valid for the synchronous create call.
|
||||
assert_eq!(unsafe { verse_core_create_v1(&config, &mut core) }, OK);
|
||||
assert!(!core.is_null());
|
||||
register(core, ctx);
|
||||
core
|
||||
}
|
||||
|
||||
fn register(core: *mut Core, ctx: &mut Context) {
|
||||
ctx.core.store(core as usize, Ordering::SeqCst);
|
||||
CORE_CONTEXTS.with(|contexts| {
|
||||
contexts
|
||||
.borrow_mut()
|
||||
.insert(core as usize, ptr::from_mut(ctx) as usize);
|
||||
});
|
||||
}
|
||||
|
||||
fn connect(core: *mut Core, manifest: &[u8], credential: &[u8]) -> u32 {
|
||||
connect_mode(core, "", manifest, credential)
|
||||
}
|
||||
|
||||
fn connect_mode(core: *mut Core, mode: &str, manifest: &[u8], credential: &[u8]) -> u32 {
|
||||
connect_with_oracle(core, mode, |oracle| {
|
||||
let manifest = if manifest == MANIFEST {
|
||||
oracle.ready.manifest.as_bytes()
|
||||
} else {
|
||||
manifest
|
||||
};
|
||||
let credential = if credential == CREDENTIAL {
|
||||
oracle.ready.credential.as_bytes()
|
||||
} else {
|
||||
credential
|
||||
};
|
||||
let request = request(manifest, credential);
|
||||
// Test invariant: request and backing byte slices remain valid for the synchronous call.
|
||||
unsafe { verse_core_connect_v1(core, &request) }
|
||||
})
|
||||
}
|
||||
|
||||
fn connect_with_oracle(
|
||||
core: *mut Core,
|
||||
mode: &str,
|
||||
action: impl FnOnce(&gateway_oracle::Oracle) -> u32,
|
||||
) -> u32 {
|
||||
let oracle = gateway_oracle::Oracle::start(mode);
|
||||
CORE_CONTEXTS.with(|contexts| {
|
||||
let raw = *contexts
|
||||
.borrow()
|
||||
.get(&(core as usize))
|
||||
.expect("registered ABI context");
|
||||
// Test invariant: context outlives the core and is removed only after destroy succeeds.
|
||||
let ctx = unsafe { &*(raw as *const Context) };
|
||||
*ctx.admission_key.lock().expect("admission key") =
|
||||
Some(gateway_oracle::test_key(&oracle.ready.admission_key));
|
||||
*ctx.tls_key.lock().expect("TLS key") =
|
||||
Some(gateway_oracle::test_key(&oracle.ready.client_key));
|
||||
});
|
||||
let status = action(&oracle);
|
||||
if status == OK {
|
||||
ORACLES.with(|oracles| {
|
||||
oracles.borrow_mut().insert(core as usize, oracle);
|
||||
});
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
fn wait_for(ctx: &Context, predicate: impl Fn(&[u32]) -> bool) {
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
let mut states = ctx.states.lock().expect("states lock");
|
||||
while !predicate(&states) {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
assert!(
|
||||
!remaining.is_zero(),
|
||||
"callback deadline exceeded: {states:?}"
|
||||
);
|
||||
(states, _) = ctx
|
||||
.wake
|
||||
.wait_timeout(states, remaining)
|
||||
.expect("callback wait");
|
||||
}
|
||||
}
|
||||
|
||||
fn destroy(core: *mut Core) -> u32 {
|
||||
// Test invariant: caller retains the handle until destroy reports success.
|
||||
let status = unsafe { verse_core_destroy_v1(core, 2_000) };
|
||||
if status == OK {
|
||||
ORACLES.with(|oracles| {
|
||||
oracles.borrow_mut().remove(&(core as usize));
|
||||
});
|
||||
CORE_CONTEXTS.with(|contexts| {
|
||||
contexts.borrow_mut().remove(&(core as usize));
|
||||
});
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arm64_c_layout_is_exact_and_version_is_fixed() {
|
||||
assert_eq!(versevdi_core::session::INPUT_QUEUE_CAPACITY, 64);
|
||||
assert_eq!(size_of::<BytesView>(), 16);
|
||||
assert_eq!(size_of::<Config>(), 72);
|
||||
assert_eq!(offset_of!(Config, sign_admission), 16);
|
||||
assert_eq!(size_of::<ConnectRequest>(), 40);
|
||||
assert_eq!(offset_of!(ConnectRequest, manifest_json), 8);
|
||||
assert_eq!(size_of::<InputEvent>(), 64);
|
||||
assert_eq!(size_of::<StateEvent>(), 16);
|
||||
assert_eq!(size_of::<ErrorEvent>(), 24);
|
||||
assert_eq!(size_of::<StatsEvent>(), 32);
|
||||
assert_eq!(size_of::<MediaEvent>(), 40);
|
||||
assert_eq!(size_of::<ControlEvent>(), 32);
|
||||
// Test invariant: no pointer arguments are involved.
|
||||
assert_eq!(unsafe { verse_core_abi_version() }, ABI_V1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_validates_prefix_callbacks_output_and_trailing_bytes() {
|
||||
let mut ctx = Context::default();
|
||||
let mut core = ptr::dangling_mut::<Core>();
|
||||
let mut cfg = config(&mut ctx);
|
||||
|
||||
cfg.struct_size = 7;
|
||||
// Test invariant: config/out are readable/writable for this call.
|
||||
assert_eq!(
|
||||
unsafe { verse_core_create_v1(&cfg, &mut core) },
|
||||
INVALID_ARGUMENT
|
||||
);
|
||||
assert!(core.is_null());
|
||||
|
||||
cfg = config(&mut ctx);
|
||||
cfg.abi_version = 2;
|
||||
assert_eq!(
|
||||
unsafe { verse_core_create_v1(&cfg, &mut core) },
|
||||
UNSUPPORTED_ABI
|
||||
);
|
||||
assert!(core.is_null());
|
||||
|
||||
cfg = config(&mut ctx);
|
||||
cfg.sign_tls_ed25519 = None;
|
||||
assert_eq!(
|
||||
unsafe { verse_core_create_v1(&cfg, &mut core) },
|
||||
INVALID_ARGUMENT
|
||||
);
|
||||
assert!(core.is_null());
|
||||
|
||||
#[repr(C)]
|
||||
struct Extended {
|
||||
base: Config,
|
||||
ignored: [u8; 32],
|
||||
}
|
||||
let extended = Extended {
|
||||
base: config(&mut ctx),
|
||||
ignored: [0xEE; 32],
|
||||
};
|
||||
let mut extended = extended;
|
||||
extended.base.struct_size = size_of::<Extended>() as u32;
|
||||
assert_eq!(
|
||||
unsafe { verse_core_create_v1(&extended.base, &mut core) },
|
||||
OK
|
||||
);
|
||||
assert_eq!(destroy(core), OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_copies_inputs_and_calls_purpose_specific_signers_once() {
|
||||
let mut ctx = Context::default();
|
||||
let core = create(&mut ctx);
|
||||
let mut manifest = MANIFEST.to_vec();
|
||||
let mut credential = CREDENTIAL.to_vec();
|
||||
assert_eq!(connect(core, &manifest, &credential), OK);
|
||||
manifest.fill(b'x');
|
||||
credential.fill(b'y');
|
||||
|
||||
assert_eq!(ctx.admission_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(ctx.tls_calls.load(Ordering::SeqCst), 1);
|
||||
let admission = ctx.admission_input.lock().expect("admission");
|
||||
let tls = ctx.tls_input.lock().expect("tls");
|
||||
assert!(admission.starts_with(b"versevdi/tunnel-admission/v1"));
|
||||
assert!(!tls.is_empty());
|
||||
assert_ne!(&*admission, &*tls);
|
||||
assert_eq!(destroy(core), OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_and_slices_reject_short_unsupported_null_and_oversized_inputs() {
|
||||
let mut ctx = Context::default();
|
||||
ctx.reentry_cancel.store(OK, Ordering::SeqCst);
|
||||
let core = create(&mut ctx);
|
||||
let mut req = request(MANIFEST, CREDENTIAL);
|
||||
|
||||
req.struct_size = 7;
|
||||
assert_eq!(
|
||||
unsafe { verse_core_connect_v1(core, &req) },
|
||||
INVALID_ARGUMENT
|
||||
);
|
||||
req.struct_size = size_of::<ConnectRequest>() as u32;
|
||||
req.abi_version = 2;
|
||||
assert_eq!(
|
||||
unsafe { verse_core_connect_v1(core, &req) },
|
||||
UNSUPPORTED_ABI
|
||||
);
|
||||
req.abi_version = ABI_V1;
|
||||
req.manifest_json = BytesView {
|
||||
data: ptr::null(),
|
||||
length: 1,
|
||||
};
|
||||
assert_eq!(
|
||||
unsafe { verse_core_connect_v1(core, &req) },
|
||||
INVALID_ARGUMENT
|
||||
);
|
||||
req.manifest_json = BytesView {
|
||||
data: ptr::null(),
|
||||
length: 0,
|
||||
};
|
||||
assert_eq!(
|
||||
unsafe { verse_core_connect_v1(core, &req) },
|
||||
INVALID_ARGUMENT
|
||||
);
|
||||
req.manifest_json.length = 1_048_577;
|
||||
assert_eq!(
|
||||
unsafe { verse_core_connect_v1(core, &req) },
|
||||
INVALID_ARGUMENT
|
||||
);
|
||||
|
||||
let mut input = keyboard_event();
|
||||
input.struct_size = 7;
|
||||
assert_eq!(
|
||||
unsafe { verse_core_send_input_v1(core, &input) },
|
||||
INVALID_ARGUMENT
|
||||
);
|
||||
input.struct_size = size_of::<InputEvent>() as u32;
|
||||
input.abi_version = 2;
|
||||
assert_eq!(
|
||||
unsafe { verse_core_send_input_v1(core, &input) },
|
||||
UNSUPPORTED_ABI
|
||||
);
|
||||
|
||||
#[repr(C)]
|
||||
struct ExtendedRequest {
|
||||
base: ConnectRequest,
|
||||
ignored: [u8; 24],
|
||||
}
|
||||
assert_eq!(
|
||||
connect_with_oracle(core, "", |oracle| {
|
||||
let mut trailing_request = ExtendedRequest {
|
||||
base: request(
|
||||
oracle.ready.manifest.as_bytes(),
|
||||
oracle.ready.credential.as_bytes(),
|
||||
),
|
||||
ignored: [0xEE; 24],
|
||||
};
|
||||
trailing_request.base.struct_size = size_of::<ExtendedRequest>() as u32;
|
||||
unsafe { verse_core_connect_v1(core, &trailing_request.base) }
|
||||
}),
|
||||
OK
|
||||
);
|
||||
|
||||
#[repr(C)]
|
||||
struct ExtendedInput {
|
||||
base: InputEvent,
|
||||
ignored: [u8; 24],
|
||||
}
|
||||
let mut trailing_input = ExtendedInput {
|
||||
base: keyboard_event(),
|
||||
ignored: [0xEE; 24],
|
||||
};
|
||||
trailing_input.base.struct_size = size_of::<ExtendedInput>() as u32;
|
||||
assert_eq!(
|
||||
unsafe { verse_core_send_input_v1(core, &trailing_input.base) },
|
||||
OK
|
||||
);
|
||||
assert_eq!(destroy(core), OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_order_is_serial_and_only_cancel_is_reentrant() {
|
||||
let mut ctx = Context::default();
|
||||
let core = create(&mut ctx);
|
||||
assert_eq!(connect(core, MANIFEST, CREDENTIAL), OK);
|
||||
wait_for(&ctx, |states| states.contains(&STATE_CANCELLED));
|
||||
|
||||
assert_eq!(
|
||||
ctx.states.lock().expect("states").as_slice(),
|
||||
[STATE_CONNECTING, STATE_CONNECTED, STATE_CANCELLED]
|
||||
);
|
||||
assert_eq!(ctx.reentry_cancel.load(Ordering::SeqCst), OK);
|
||||
assert_eq!(ctx.reentry_send.load(Ordering::SeqCst), REENTRANT);
|
||||
assert_eq!(ctx.reentry_destroy.load(Ordering::SeqCst), REENTRANT);
|
||||
assert_eq!(ctx.callback_max.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(destroy(core), OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signer_callbacks_cannot_reenter_even_cancel() {
|
||||
let mut ctx = Context::default();
|
||||
let mut cfg = config(&mut ctx);
|
||||
cfg.sign_admission = Some(sign_admission_reenters);
|
||||
let mut core = ptr::null_mut();
|
||||
assert_eq!(unsafe { verse_core_create_v1(&cfg, &mut core) }, OK);
|
||||
register(core, &mut ctx);
|
||||
assert_eq!(connect(core, MANIFEST, CREDENTIAL), OK);
|
||||
assert_eq!(ctx.reentry_cancel.load(Ordering::SeqCst), REENTRANT);
|
||||
assert_eq!(destroy(core), OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signer_callback_rejects_every_stateful_api_across_handles() {
|
||||
let mut other_ctx = Context::default();
|
||||
other_ctx.reentry_cancel.store(OK, Ordering::SeqCst);
|
||||
let other = create(&mut other_ctx);
|
||||
|
||||
let mut ctx = Context::default();
|
||||
ctx.reentry_cancel.store(OK, Ordering::SeqCst);
|
||||
ctx.reentry_target.store(other as usize, Ordering::SeqCst);
|
||||
let mut cfg = config(&mut ctx);
|
||||
cfg.sign_admission = Some(sign_admission_probes_global_reentry);
|
||||
let mut core = ptr::null_mut();
|
||||
assert_eq!(unsafe { verse_core_create_v1(&cfg, &mut core) }, OK);
|
||||
register(core, &mut ctx);
|
||||
assert_eq!(connect(core, MANIFEST, CREDENTIAL), OK);
|
||||
assert_eq!(
|
||||
ctx.reentry_results
|
||||
.lock()
|
||||
.expect("signer results")
|
||||
.as_slice(),
|
||||
[ABI_V1, REENTRANT, REENTRANT, REENTRANT, REENTRANT, REENTRANT, REENTRANT]
|
||||
);
|
||||
|
||||
assert_eq!(destroy(core), OK);
|
||||
assert_eq!(connect(other, MANIFEST, CREDENTIAL), OK);
|
||||
assert_eq!(destroy(other), OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_callback_allows_only_originating_handle_cancel() {
|
||||
let mut other_ctx = Context::default();
|
||||
other_ctx.reentry_cancel.store(OK, Ordering::SeqCst);
|
||||
let other = create(&mut other_ctx);
|
||||
|
||||
let mut ctx = Context::default();
|
||||
ctx.reentry_target.store(other as usize, Ordering::SeqCst);
|
||||
let mut cfg = config(&mut ctx);
|
||||
cfg.on_state = Some(on_state_probes_global_reentry);
|
||||
let mut core = ptr::null_mut();
|
||||
assert_eq!(unsafe { verse_core_create_v1(&cfg, &mut core) }, OK);
|
||||
register(core, &mut ctx);
|
||||
assert_eq!(connect(core, MANIFEST, CREDENTIAL), OK);
|
||||
wait_for(&ctx, |states| states.contains(&STATE_CANCELLED));
|
||||
assert_eq!(
|
||||
ctx.reentry_results
|
||||
.lock()
|
||||
.expect("event results")
|
||||
.as_slice(),
|
||||
[ABI_V1, REENTRANT, REENTRANT, REENTRANT, REENTRANT, REENTRANT, REENTRANT, OK]
|
||||
);
|
||||
|
||||
assert_eq!(destroy(core), OK);
|
||||
assert_eq!(connect(other, MANIFEST, CREDENTIAL), OK);
|
||||
assert_eq!(destroy(other), OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signer_statuses_are_purpose_specific_and_unknown_values_are_internal() {
|
||||
for (admission, expected) in [
|
||||
(OK, OK),
|
||||
(AUTHORITY_REJECTED, AUTHORITY_REJECTED),
|
||||
(CANCELLED, CANCELLED),
|
||||
(INTERNAL, INTERNAL),
|
||||
(TLS, INTERNAL),
|
||||
(u32::MAX, INTERNAL),
|
||||
] {
|
||||
let mut ctx = Context::default();
|
||||
ctx.reentry_cancel.store(OK, Ordering::SeqCst);
|
||||
ctx.admission_status.store(admission, Ordering::SeqCst);
|
||||
let core = create(&mut ctx);
|
||||
assert_eq!(connect(core, MANIFEST, CREDENTIAL), expected);
|
||||
assert_eq!(destroy(core), OK);
|
||||
}
|
||||
|
||||
for (tls, expected) in [
|
||||
(OK, OK),
|
||||
(TLS, TLS),
|
||||
(CANCELLED, CANCELLED),
|
||||
(INTERNAL, INTERNAL),
|
||||
(AUTHORITY_REJECTED, INTERNAL),
|
||||
(u32::MAX, INTERNAL),
|
||||
] {
|
||||
let mut ctx = Context::default();
|
||||
ctx.reentry_cancel.store(OK, Ordering::SeqCst);
|
||||
ctx.tls_status.store(tls, Ordering::SeqCst);
|
||||
let core = create(&mut ctx);
|
||||
assert_eq!(connect(core, MANIFEST, CREDENTIAL), expected);
|
||||
assert_eq!(destroy(core), OK);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_during_connect_preserves_state_order_and_stops_before_tls_signing() {
|
||||
let mut ctx = Context::default();
|
||||
ctx.cancel_on_connecting.store(true, Ordering::SeqCst);
|
||||
let mut cfg = config(&mut ctx);
|
||||
cfg.sign_admission = Some(sign_admission_waits_for_cancel);
|
||||
let mut core = ptr::null_mut();
|
||||
assert_eq!(unsafe { verse_core_create_v1(&cfg, &mut core) }, OK);
|
||||
register(core, &mut ctx);
|
||||
|
||||
assert_eq!(connect(core, MANIFEST, CREDENTIAL), CANCELLED);
|
||||
wait_for(&ctx, |states| states.contains(&STATE_CANCELLED));
|
||||
assert_eq!(
|
||||
ctx.states.lock().expect("states").as_slice(),
|
||||
[STATE_CONNECTING, STATE_CANCELLED]
|
||||
);
|
||||
assert_eq!(ctx.tls_calls.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(destroy(core), OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_input_is_nonblocking_bounded_and_cancel_is_idempotent() {
|
||||
let mut ctx = Context::default();
|
||||
ctx.reentry_cancel.store(OK, Ordering::SeqCst);
|
||||
let core = create(&mut ctx);
|
||||
assert_eq!(connect_mode(core, "slow-input", MANIFEST, CREDENTIAL), OK);
|
||||
let event = keyboard_event();
|
||||
let started = Instant::now();
|
||||
let mut sent = 0;
|
||||
let saturated = loop {
|
||||
match unsafe { verse_core_send_input_v1(core, &event) } {
|
||||
OK => sent += 1,
|
||||
QUEUE_FULL => break true,
|
||||
status => panic!("unexpected input status {status}"),
|
||||
}
|
||||
if sent == 10_000 || started.elapsed() > Duration::from_secs(1) {
|
||||
break false;
|
||||
}
|
||||
};
|
||||
assert!(saturated, "real slow consumer did not expose bounded queue");
|
||||
assert!(started.elapsed() < Duration::from_secs(1));
|
||||
assert_eq!(unsafe { verse_core_cancel_v1(core) }, OK);
|
||||
assert_eq!(unsafe { verse_core_cancel_v1(core) }, OK);
|
||||
assert_eq!(unsafe { verse_core_send_input_v1(core, &event) }, CANCELLED);
|
||||
assert_eq!(destroy(core), OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destroy_timeout_keeps_ownership_suppresses_late_callbacks_and_allows_retry() {
|
||||
let mut ctx = Context::default();
|
||||
ctx.reentry_cancel.store(OK, Ordering::SeqCst);
|
||||
ctx.block_callbacks.store(true, Ordering::SeqCst);
|
||||
let core = create(&mut ctx);
|
||||
assert_eq!(connect(core, MANIFEST, CREDENTIAL), OK);
|
||||
wait_for(&ctx, |states| !states.is_empty());
|
||||
|
||||
assert_eq!(unsafe { verse_core_destroy_v1(core, 1) }, BUSY);
|
||||
let count_at_timeout = ctx.states.lock().expect("states").len();
|
||||
ctx.release_callbacks.store(true, Ordering::SeqCst);
|
||||
ctx.wake.notify_all();
|
||||
assert_eq!(destroy(core), OK);
|
||||
assert_eq!(ctx.states.lock().expect("states").len(), count_at_timeout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preconnect_and_postcancel_state_checks_are_stable() {
|
||||
let mut ctx = Context::default();
|
||||
ctx.reentry_cancel.store(OK, Ordering::SeqCst);
|
||||
let core = create(&mut ctx);
|
||||
let event = keyboard_event();
|
||||
assert_eq!(
|
||||
unsafe { verse_core_send_input_v1(core, &event) },
|
||||
INVALID_STATE
|
||||
);
|
||||
assert_eq!(unsafe { verse_core_request_idr_v1(core) }, INVALID_STATE);
|
||||
assert_eq!(unsafe { verse_core_cancel_v1(core) }, OK);
|
||||
assert_eq!(connect(core, MANIFEST, CREDENTIAL), CANCELLED);
|
||||
assert_eq!(destroy(core), OK);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
#include "versevdi_core.h"
|
||||
|
||||
#include <assert.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static const char MANIFEST[] =
|
||||
"{\"version\":\"1\",\"purpose\":\"launch\",\"session_id\":\"session\","
|
||||
"\"reconnect_sequence\":0,\"gateway\":{\"id\":\"gateway\",\"addresses\":["
|
||||
"\"127.0.0.1:9\"],\"public_identity\":\"gateway.test\"},\"tunnel\":{"
|
||||
"\"versions\":[\"verse-gateway-v1/1\"],\"features\":[\"control.v1\"]},"
|
||||
"\"profile\":{\"id\":\"standard\",\"bounds\":{\"minimum_kbps\":1000,"
|
||||
"\"target_kbps\":5000,\"maximum_kbps\":10000},\"display_mode\":null},"
|
||||
"\"grant\":{\"opaque_value\":\"ggggggggggggggggggggggggggggggggggggggggggg\","
|
||||
"\"expires_at\":\"2099-01-01T00:00:00Z\",\"audience\":\"audience\"},"
|
||||
"\"correlation_id\":\"correlation\"}";
|
||||
static const char CREDENTIAL[] =
|
||||
"{\"client_device_id\":\"device\",\"device_key_id\":\"key\","
|
||||
"\"certificate_chain_pem\":\"-----BEGIN CERTIFICATE-----\\nAQID\\n-----END CERTIFICATE-----\","
|
||||
"\"trust_bundle_pem\":\"-----BEGIN CERTIFICATE-----\\nAQID\\n-----END CERTIFICATE-----\","
|
||||
"\"expires_at\":\"2099-01-01T00:00:00Z\"}";
|
||||
|
||||
typedef struct smoke_context {
|
||||
atomic_uint admission_calls;
|
||||
atomic_uint tls_calls;
|
||||
} smoke_context_t;
|
||||
|
||||
static verse_status_t reject_admission(
|
||||
void *raw,
|
||||
verse_bytes_view_t input,
|
||||
uint8_t signature[64]) {
|
||||
smoke_context_t *context = raw;
|
||||
(void)input;
|
||||
(void)signature;
|
||||
atomic_fetch_add(&context->admission_calls, 1U);
|
||||
return VERSE_STATUS_INTERNAL;
|
||||
}
|
||||
|
||||
static verse_status_t reject_tls(
|
||||
void *raw,
|
||||
verse_bytes_view_t input,
|
||||
uint8_t signature[64]) {
|
||||
smoke_context_t *context = raw;
|
||||
(void)input;
|
||||
(void)signature;
|
||||
atomic_fetch_add(&context->tls_calls, 1U);
|
||||
return VERSE_STATUS_INTERNAL;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
smoke_context_t context = {0};
|
||||
verse_core_config_v1_t config = {
|
||||
.struct_size = sizeof(config),
|
||||
.abi_version = VERSE_CORE_ABI_VERSION_1,
|
||||
.context = &context,
|
||||
.sign_admission = reject_admission,
|
||||
.sign_tls_ed25519 = reject_tls,
|
||||
};
|
||||
verse_core_t *core = NULL;
|
||||
|
||||
assert(verse_core_abi_version() == VERSE_CORE_ABI_VERSION_1);
|
||||
assert(verse_core_create_v1(NULL, &core) == VERSE_STATUS_INVALID_ARGUMENT);
|
||||
assert(verse_core_create_v1(&config, NULL) == VERSE_STATUS_INVALID_ARGUMENT);
|
||||
config.struct_size = 8U;
|
||||
assert(verse_core_create_v1(&config, &core) == VERSE_STATUS_INVALID_ARGUMENT);
|
||||
config.struct_size = sizeof(config);
|
||||
config.abi_version = VERSE_CORE_ABI_VERSION_1 + 1U;
|
||||
assert(verse_core_create_v1(&config, &core) == VERSE_STATUS_UNSUPPORTED_ABI);
|
||||
config.abi_version = VERSE_CORE_ABI_VERSION_1;
|
||||
assert(verse_core_create_v1(&config, &core) == VERSE_STATUS_OK);
|
||||
assert(core != NULL);
|
||||
|
||||
verse_input_event_v1_t input = {
|
||||
.struct_size = sizeof(input),
|
||||
.abi_version = VERSE_CORE_ABI_VERSION_1,
|
||||
.kind = VERSE_INPUT_KEYBOARD,
|
||||
.values = {1, 0, 30},
|
||||
};
|
||||
assert(verse_core_send_input_v1(core, &input) == VERSE_STATUS_INVALID_STATE);
|
||||
assert(verse_core_request_idr_v1(core) == VERSE_STATUS_INVALID_STATE);
|
||||
|
||||
char manifest[sizeof(MANIFEST)];
|
||||
char credential[sizeof(CREDENTIAL)];
|
||||
memcpy(manifest, MANIFEST, sizeof(manifest));
|
||||
memcpy(credential, CREDENTIAL, sizeof(credential));
|
||||
verse_connect_request_v1_t request = {
|
||||
.struct_size = sizeof(request),
|
||||
.abi_version = VERSE_CORE_ABI_VERSION_1,
|
||||
.manifest_json = {(const uint8_t *)manifest, sizeof(manifest) - 1U},
|
||||
.tunnel_credential_json = {(const uint8_t *)credential, sizeof(credential) - 1U},
|
||||
};
|
||||
assert(verse_core_connect_v1(NULL, &request) == VERSE_STATUS_INVALID_ARGUMENT);
|
||||
request.struct_size = 8U;
|
||||
assert(verse_core_connect_v1(core, &request) == VERSE_STATUS_INVALID_ARGUMENT);
|
||||
request.struct_size = sizeof(request);
|
||||
request.abi_version = VERSE_CORE_ABI_VERSION_1 + 1U;
|
||||
assert(verse_core_connect_v1(core, &request) == VERSE_STATUS_UNSUPPORTED_ABI);
|
||||
request.abi_version = VERSE_CORE_ABI_VERSION_1;
|
||||
request.manifest_json.data = NULL;
|
||||
assert(verse_core_connect_v1(core, &request) == VERSE_STATUS_INVALID_ARGUMENT);
|
||||
request.manifest_json.data = (const uint8_t *)manifest;
|
||||
|
||||
assert(verse_core_connect_v1(core, &request) == VERSE_STATUS_TLS);
|
||||
memset(manifest, 0, sizeof(manifest));
|
||||
memset(credential, 0, sizeof(credential));
|
||||
assert(verse_core_send_input_v1(core, &input) == VERSE_STATUS_CANCELLED);
|
||||
assert(verse_core_request_idr_v1(core) == VERSE_STATUS_CANCELLED);
|
||||
assert(verse_core_cancel_v1(core) == VERSE_STATUS_OK);
|
||||
assert(verse_core_cancel_v1(core) == VERSE_STATUS_OK);
|
||||
assert(atomic_load(&context.admission_calls) == 0U);
|
||||
assert(atomic_load(&context.tls_calls) == 0U);
|
||||
assert(verse_core_destroy_v1(core, 2000U) == VERSE_STATUS_OK);
|
||||
assert(atomic_load(&context.admission_calls) == 0U);
|
||||
assert(atomic_load(&context.tls_calls) == 0U);
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
# Protocol fixture provenance
|
||||
|
||||
- Repository: `git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol`
|
||||
- Commit: `4693102b3ccbb81aeb1144c1a3b0884ee682bfa3`
|
||||
- Tag: `v1.0.0-phase3d-macos-rc.5`
|
||||
- Schema SHA-256: `b2353c12269304289b4e872f27cc370ae61b958dea90d9fb7b6ab8afd7d37248`
|
||||
- Fixture corpus SHA-256: `6d2ce3a855b2fa45733a5f7b5b4c2e68448cceed5dfbca535ec81fe8cf230b30`
|
||||
|
||||
Copied byte-for-byte from `fixtures/conformance/tunnel-v1.tsv`,
|
||||
`fixtures/conformance/datagram-v2.tsv`,
|
||||
`fixtures/conformance/gateway-input-feedback-v1.tsv`, and
|
||||
`fixtures/manifest.json`. RC4 is superseded and is not an authority for these
|
||||
fixtures.
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
id version kind input expected
|
||||
v2-valid-video-single 2 datagram hex=5644020a00000000010000000000000002000000010003010203 valid
|
||||
v2-valid-video-last-fragment 2 datagram hex=5644020a00000000010000000000000002037a037b0000 valid
|
||||
v2-invalid-short 2 datagram hex=564402 invalid:truncated
|
||||
v2-invalid-version 2 datagram hex=5644030a00000000010000000000000002000000010000 invalid:unsupported_version
|
||||
v2-invalid-channel 2 datagram hex=5644020d00000000010000000000000002000000010000 invalid:unknown_channel
|
||||
v2-invalid-fragment-zero 2 datagram hex=5644020a00000000010000000000000002000000000000 invalid:fragment
|
||||
v2-invalid-fragment-index 2 datagram hex=5644020a00000000010000000000000002000100010000 invalid:fragment
|
||||
v2-invalid-fragment-count-limit 2 datagram hex=5644020a000000000100000000000000020000037c0000 invalid:fragment_limit
|
||||
v2-invalid-length 2 datagram hex=5644020a00000000010000000000000002000000010001 invalid:length_mismatch
|
||||
|
@@ -0,0 +1,34 @@
|
||||
id version kind input expected
|
||||
valid-keyboard-press 1 gateway_input hex=5647493101040102001e valid
|
||||
valid-keyboard-release 1 gateway_input hex=5647493101040000001e valid
|
||||
valid-mouse-button 1 gateway_input hex=564749310203010100 valid
|
||||
valid-mouse-release 1 gateway_input hex=564749310203000100 valid
|
||||
valid-relative-mouse 1 gateway_input hex=564749310304fffe0003 valid
|
||||
valid-utf8-scalar 1 gateway_input hex=564749310403e29883 valid
|
||||
valid-controller 1 gateway_input hex=5647493105110200030004ffff00010002000300040005 valid
|
||||
valid-controller-release 1 gateway_input hex=5647493105110200000000000000000000000000000000 valid
|
||||
valid-absolute-mouse 1 gateway_input hex=56474931060804d202370a0005a0 valid
|
||||
valid-scroll 1 gateway_input hex=564749310704ff880078 valid
|
||||
valid-idr 1 gateway_feedback hex=5647463100010000 valid
|
||||
valid-fec 1 gateway_feedback hex=56474631000200150000002a000500030002000a000200080002140001 valid
|
||||
valid-terminal-receipt 1 gateway_feedback hex=5647463100030000 valid
|
||||
valid-termination 1 gateway_feedback hex=564746310110000400000001 valid
|
||||
valid-rumble 1 gateway_feedback hex=56474631011100050112345678 valid
|
||||
valid-hdr 1 gateway_feedback hex=564746310112000101 valid
|
||||
invalid-input-magic 1 gateway_input hex=494e503101040102001e invalid:magic
|
||||
invalid-input-kind 1 gateway_input hex=564749317f00 invalid:kind
|
||||
invalid-input-reserved 1 gateway_input hex=564749310203010101 invalid:reserved
|
||||
invalid-input-utf8 1 gateway_input hex=564749310402c328 invalid:utf8
|
||||
invalid-input-length 1 gateway_input hex=564749310104010200 invalid:length
|
||||
invalid-absolute-zero-viewport 1 gateway_input hex=56474931060800000000000005a0 invalid:field
|
||||
invalid-absolute-x-out-of-range 1 gateway_input hex=5647493106080a0000000a0005a0 invalid:field
|
||||
invalid-absolute-y-out-of-range 1 gateway_input hex=564749310608000005a00a0005a0 invalid:field
|
||||
invalid-absolute-length 1 gateway_input hex=56474931060700000000010001 invalid:length
|
||||
invalid-scroll-length 1 gateway_input hex=5647493107020000 invalid:length
|
||||
invalid-feedback-direction 1 gateway_feedback hex=5647463101020000 invalid:direction
|
||||
invalid-terminal-receipt-direction 1 gateway_feedback hex=5647463101030000 invalid:direction
|
||||
invalid-terminal-receipt-body 1 gateway_feedback hex=5647463100030001ff invalid:length
|
||||
invalid-terminal-receipt-truncated 1 gateway_feedback hex=56474631000300 invalid:truncated
|
||||
invalid-terminal-receipt-length 1 gateway_feedback hex=5647463100030001 invalid:length
|
||||
invalid-feedback-type 1 gateway_feedback hex=5647463100040000 invalid:type
|
||||
invalid-feedback-length 1 gateway_feedback hex=5647463101100003000000 invalid:length
|
||||
|
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"algorithm": "sha256(path\\0bytes\\0 sorted by path)",
|
||||
"files": [
|
||||
"fixtures/conformance/control-v1.tsv",
|
||||
"fixtures/conformance/datagram-v1.tsv",
|
||||
"fixtures/conformance/datagram-v2.tsv",
|
||||
"fixtures/conformance/device-proof-v1.tsv",
|
||||
"fixtures/conformance/events-v1.tsv",
|
||||
"fixtures/conformance/gateway-clipboard-audit-v1.tsv",
|
||||
"fixtures/conformance/gateway-clipboard-v1.tsv",
|
||||
"fixtures/conformance/gateway-input-feedback-v1.tsv",
|
||||
"fixtures/conformance/tunnel-v1.tsv"
|
||||
],
|
||||
"corpus_sha256": "6d2ce3a855b2fa45733a5f7b5b4c2e68448cceed5dfbca535ec81fe8cf230b30"
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
id version kind input expected
|
||||
tunnel-current 2 tunnel offered=2;feature=control.v2 valid
|
||||
tunnel-n-minus-1 1 tunnel offered=1;feature=control.v1 valid
|
||||
tunnel-n-minus-2 0 tunnel offered=0;feature=control.v1 valid
|
||||
tunnel-display-request 2 tunnel offered=2;feature=display.request.v1 valid
|
||||
tunnel-absolute-input 2 tunnel offered=2;feature=input.absolute.v1 valid
|
||||
tunnel-scroll-input 2 tunnel offered=2;feature=input.scroll.v1 valid
|
||||
tunnel-unsupported 2 tunnel offered=3;feature=control.v2 invalid:unsupported_version
|
||||
tunnel-no-control 2 tunnel offered=2;feature=media.video invalid:unsupported_feature
|
||||
|
File diff suppressed because it is too large
Load Diff
Executable
+234
@@ -0,0 +1,234 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)
|
||||
BUILDER="$ROOT/core/scripts/build-xcframework.sh"
|
||||
EXPECTED_EXPORTS='_verse_core_abi_version
|
||||
_verse_core_cancel_v1
|
||||
_verse_core_connect_v1
|
||||
_verse_core_create_v1
|
||||
_verse_core_destroy_v1
|
||||
_verse_core_request_idr_v1
|
||||
_verse_core_send_input_v1'
|
||||
|
||||
test "$(uname -s)" = Darwin
|
||||
test "$(uname -m)" = arm64
|
||||
test -x "$BUILDER"
|
||||
|
||||
WORK=$(mktemp -d "${TMPDIR:-/tmp}/versevdi-core-package.XXXXXX")
|
||||
trap 'rm -rf "$WORK"' EXIT HUP INT TERM
|
||||
|
||||
expect_path_rejected() {
|
||||
error=$1
|
||||
shift
|
||||
if "$BUILDER" "$@" >"$WORK/path.stdout" 2>"$WORK/path.stderr"; then
|
||||
echo "unsafe packaging path was accepted" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -F "$error" "$WORK/path.stderr" >/dev/null
|
||||
}
|
||||
|
||||
mkdir "$WORK/parents" "$WORK/physical-parent"
|
||||
mkdir "$WORK/existing-output"
|
||||
expect_path_rejected "must not exist" \
|
||||
--output "$WORK/existing-output" --target-dir "$WORK/parents/existing-target"
|
||||
ln -s "$WORK/physical-parent" "$WORK/parent-alias"
|
||||
ln -s "$WORK/physical-parent" "$WORK/second-parent-alias"
|
||||
ln -s "$WORK/missing" "$WORK/dangling-output"
|
||||
expect_path_rejected "must not exist" \
|
||||
--output "$WORK/dangling-output" --target-dir "$WORK/parents/dangling-target"
|
||||
expect_path_rejected "must be separate" \
|
||||
--output "$WORK/parents/same" --target-dir "$WORK/parents/same"
|
||||
expect_path_rejected "must be separate" \
|
||||
--output "$WORK/parent-alias/aliased-same" \
|
||||
--target-dir "$WORK/second-parent-alias/aliased-same"
|
||||
ln -s "$ROOT" "$WORK/repository-alias"
|
||||
expect_path_rejected "outside the repository" \
|
||||
--output "$WORK/repository-alias/core/forbidden-output" \
|
||||
--target-dir "$WORK/parents/repository-alias-target"
|
||||
|
||||
(umask 022 && "$BUILDER" --output "$WORK/one" --target-dir "$WORK/target-one")
|
||||
mkdir "$WORK/hostile-bin"
|
||||
mkdir -p "$WORK/hostile-cargo-home" "$WORK/hostile-home/.cargo"
|
||||
ln -s /usr/bin/false "$WORK/hostile-bin/cargo"
|
||||
ln -s /usr/bin/false "$WORK/hostile-bin/rustup"
|
||||
ln -s /usr/bin/false "$WORK/hostile-bin/xcodebuild"
|
||||
cat >"$WORK/hostile-cargo-home/config.toml" <<'EOF'
|
||||
[build]
|
||||
rustc-wrapper = "/usr/bin/false"
|
||||
EOF
|
||||
cat >"$WORK/hostile-home/.cargo/config.toml" <<'EOF'
|
||||
[build]
|
||||
rustc = "/usr/bin/false"
|
||||
EOF
|
||||
(
|
||||
umask 077
|
||||
env \
|
||||
AR=/usr/bin/false \
|
||||
CARGO_BUILD_RUSTC=/usr/bin/false \
|
||||
CARGO_BUILD_RUSTC_WRAPPER=/usr/bin/false \
|
||||
CARGO_ENCODED_RUSTFLAGS=--cfghostile \
|
||||
CARGO_HOME="$WORK/hostile-cargo-home" \
|
||||
CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER=/usr/bin/false \
|
||||
CC=/usr/bin/false \
|
||||
DEVELOPER_DIR="$WORK/hostile-xcode" \
|
||||
HOME="$WORK/hostile-home" \
|
||||
PATH="$WORK/hostile-bin:/usr/bin:/bin" \
|
||||
RUSTC=/usr/bin/false \
|
||||
RUSTC_WRAPPER=/usr/bin/false \
|
||||
RUSTFLAGS=--cfg=hostile \
|
||||
RUSTUP_HOME="$WORK/hostile-rustup-home" \
|
||||
RUSTUP_TOOLCHAIN=bogus \
|
||||
"$BUILDER" \
|
||||
--output "$WORK/parent-alias/two" \
|
||||
--target-dir "$WORK/parents/target-two"
|
||||
)
|
||||
|
||||
framework_one="$WORK/one/VerseVDICore.xcframework"
|
||||
framework_two="$WORK/physical-parent/two/VerseVDICore.xcframework"
|
||||
library_one=$(find "$framework_one" -type f -name libversevdi_core.a -print)
|
||||
library_two=$(find "$framework_two" -type f -name libversevdi_core.a -print)
|
||||
test "$(printf '%s\n' "$library_one" | grep -c .)" -eq 1
|
||||
test "$(printf '%s\n' "$library_two" | grep -c .)" -eq 1
|
||||
|
||||
platform=$(/usr/libexec/PlistBuddy -c 'Print :AvailableLibraries:0:SupportedPlatform' "$framework_one/Info.plist")
|
||||
architecture=$(/usr/libexec/PlistBuddy -c 'Print :AvailableLibraries:0:SupportedArchitectures:0' "$framework_one/Info.plist")
|
||||
available_libraries=$(/usr/libexec/PlistBuddy -c 'Print :AvailableLibraries' "$framework_one/Info.plist")
|
||||
test "$platform" = macos
|
||||
test "$architecture" = arm64
|
||||
test "$(printf '%s\n' "$available_libraries" | grep -c 'Dict {')" -eq 1
|
||||
library_archs=$(xcrun lipo -archs "$library_one")
|
||||
library_identity=$(file "$library_one")
|
||||
test "$library_archs" = arm64
|
||||
printf '%s\n' "$library_identity" | grep -F 'current ar archive' >/dev/null
|
||||
|
||||
consumer="$WORK/consumer"
|
||||
link_consumer() {
|
||||
output=$1
|
||||
library=$2
|
||||
xcrun clang \
|
||||
-arch arm64 \
|
||||
-mmacosx-version-min=14.0 \
|
||||
-std=c11 \
|
||||
-Wall -Wextra -Werror -Wpedantic \
|
||||
-fmodules \
|
||||
-fmodules-cache-path="$WORK/module-cache" \
|
||||
-I"$(dirname "$library_one")/Headers" \
|
||||
"$ROOT/core/tests/ffi/abi_smoke.c" \
|
||||
-Wl,-force_load,"$library" \
|
||||
-framework Security \
|
||||
-framework SystemConfiguration \
|
||||
-framework CoreFoundation \
|
||||
-lresolv \
|
||||
-o "$output"
|
||||
}
|
||||
|
||||
link_consumer "$consumer" "$library_one"
|
||||
consumer_archs=$(xcrun lipo -archs "$consumer")
|
||||
consumer_identity=$(file "$consumer")
|
||||
test "$consumer_archs" = arm64
|
||||
printf '%s\n' "$consumer_identity" | grep -F 'Mach-O 64-bit executable arm64' >/dev/null
|
||||
|
||||
symbols=$(xcrun nm -gjU "$consumer")
|
||||
actual_exports=$(printf '%s\n' "$symbols" | grep '^_verse_core_' | LC_ALL=C sort -u)
|
||||
test "$actual_exports" = "$EXPECTED_EXPORTS"
|
||||
|
||||
cat >"$WORK/unexpected.c" <<'EOF'
|
||||
void verse_core_unexpected_v1(void) {}
|
||||
EOF
|
||||
xcrun clang -arch arm64 -mmacosx-version-min=14.0 -c "$WORK/unexpected.c" -o "$WORK/unexpected.o"
|
||||
cp "$library_one" "$WORK/libunexpected.a"
|
||||
ZERO_AR_DATE=1 xcrun ar -r "$WORK/libunexpected.a" "$WORK/unexpected.o"
|
||||
link_consumer "$WORK/unexpected-consumer" "$WORK/libunexpected.a"
|
||||
unexpected_symbols=$(xcrun nm -gjU "$WORK/unexpected-consumer")
|
||||
if test "$(printf '%s\n' "$unexpected_symbols" | grep '^_verse_core_' | LC_ALL=C sort -u)" = "$EXPECTED_EXPORTS"; then
|
||||
echo "force-loaded export inspection missed an unreferenced ABI symbol" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
dependencies=$(xcrun otool -L "$consumer")
|
||||
unexpected_dependencies=$(printf '%s\n' "$dependencies" | tail -n +2 | awk '{print $1}' | grep -Ev '^(/usr/lib/(libSystem\.B|libresolv\.9)\.dylib|/System/Library/Frameworks/(CoreFoundation|Security|SystemConfiguration)\.framework/Versions/A/[^/]+)$' || true)
|
||||
test -z "$unexpected_dependencies"
|
||||
|
||||
"$consumer"
|
||||
i=0
|
||||
pids=
|
||||
while test "$i" -lt 32; do
|
||||
"$consumer" &
|
||||
pids="$pids $!"
|
||||
i=$((i + 1))
|
||||
done
|
||||
for pid in $pids; do
|
||||
wait "$pid"
|
||||
done
|
||||
|
||||
canonical_tree() {
|
||||
(
|
||||
cd "$1"
|
||||
find . -print >"$WORK/tree.entries"
|
||||
LC_ALL=C sort "$WORK/tree.entries" >"$WORK/tree.sorted"
|
||||
while IFS= read -r path; do
|
||||
metadata=$(stat -f '%HT|%Sp' "$path")
|
||||
case "$metadata" in
|
||||
'Regular File|'*) digest=$(sha256_file "$path") ;;
|
||||
'Symbolic Link|'*) digest=$(readlink "$path") ;;
|
||||
*) digest=- ;;
|
||||
esac
|
||||
printf '%s|%s|%s\n' "$metadata" "$digest" "$path"
|
||||
done <"$WORK/tree.sorted"
|
||||
)
|
||||
}
|
||||
|
||||
sha256_file() {
|
||||
checksum=$(shasum -a 256 "$1")
|
||||
set -- $checksum
|
||||
printf '%s\n' "$1"
|
||||
}
|
||||
|
||||
canonical_tree "$framework_one" >"$WORK/one.tree"
|
||||
canonical_tree "$framework_two" >"$WORK/two.tree"
|
||||
cmp "$WORK/one.tree" "$WORK/two.tree"
|
||||
canonical_tree "$WORK/one" >"$WORK/one.output-tree"
|
||||
canonical_tree "$WORK/physical-parent/two" >"$WORK/two.output-tree"
|
||||
cmp "$WORK/one.output-tree" "$WORK/two.output-tree"
|
||||
for directory in \
|
||||
"$WORK/one" \
|
||||
"$WORK/target-one" \
|
||||
"$framework_one" \
|
||||
"$WORK/physical-parent/two" \
|
||||
"$WORK/parents/target-two" \
|
||||
"$framework_two"; do
|
||||
test "$(stat -f '%Sp' "$directory")" = drwxr-xr-x
|
||||
done
|
||||
test "$(sha256_file "$library_one")" = "$(sha256_file "$library_two")"
|
||||
|
||||
source_epoch=$(git -C "$ROOT" show -s --format=%ct HEAD)
|
||||
find "$WORK/one" "$WORK/physical-parent/two" -exec stat -f '%m' {} \; >"$WORK/mtimes"
|
||||
while IFS= read -r epoch; do
|
||||
test "$epoch" = "$source_epoch"
|
||||
done <"$WORK/mtimes"
|
||||
|
||||
expected_rustc='rustc 1.97.1 (8bab26f4f 2026-07-14)'
|
||||
expected_cargo='cargo 1.97.1 (c980f4866 2026-06-30)'
|
||||
expected_xcode='Xcode 26.6
|
||||
Build version 17F113'
|
||||
grep -Fx "rustc=$expected_rustc" "$WORK/one/build-environment.txt" >/dev/null
|
||||
grep -Fx "cargo=$expected_cargo" "$WORK/one/build-environment.txt" >/dev/null
|
||||
test "$(sed -n 's/^xcode=//p; /^Build version /p' "$WORK/one/build-environment.txt")" = "$expected_xcode"
|
||||
cmp "$WORK/one/build-environment.txt" "$WORK/physical-parent/two/build-environment.txt"
|
||||
|
||||
strings "$library_one" >"$WORK/library.strings"
|
||||
if grep -F "$ROOT" "$WORK/library.strings" >/dev/null; then
|
||||
echo "repository path leaked into static archive" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -F "$WORK/target-one" "$WORK/library.strings" >/dev/null; then
|
||||
echo "target directory path leaked into static archive" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
archive_members=$(xcrun ar -tv "$library_one")
|
||||
printf '%s\n' "$archive_members" | awk '$7 != "1970" { exit 1 }'
|
||||
|
||||
printf 'framework_sha256=%s\n' "$(sha256_file "$WORK/one.tree")"
|
||||
printf 'library_sha256=%s\n' "$(sha256_file "$library_one")"
|
||||
@@ -0,0 +1,651 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use versevdi_core::input::{
|
||||
decode_feedback, decode_input, encode_feedback, encode_input, FecStatus, FeedbackEvent,
|
||||
};
|
||||
use versevdi_core::media::{MediaFragment, Reassembler};
|
||||
use versevdi_core::wire::{
|
||||
CapabilityProfile, ClientSessionAuthority, ConnectionManifest, NativeTunnelCredential,
|
||||
TunnelAdmissionRequest,
|
||||
};
|
||||
|
||||
fn fixture(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_protocol_rc5_fixtures_have_immutable_hashes() {
|
||||
let cases = [
|
||||
(
|
||||
"tunnel-v1.tsv",
|
||||
"31a884800031c17844b9a8789702cf0d3639838ccfeb819c2bc3f0a5462ac5df",
|
||||
),
|
||||
(
|
||||
"datagram-v2.tsv",
|
||||
"65b9f6f018af624033562a78aae0b685df331c5e857d5f3d5d415aefa9d5b97b",
|
||||
),
|
||||
(
|
||||
"gateway-input-feedback-v1.tsv",
|
||||
"91b4dc1eb756476637ea7deebcb297256cb56d8e9c3d635088511af6b8914bda",
|
||||
),
|
||||
(
|
||||
"manifest.json",
|
||||
"76c33b33864d85f7d4a3761798eab52ffadbbe5fc313cf4e6e03369cfdc8df9a",
|
||||
),
|
||||
];
|
||||
|
||||
for (name, expected) in cases {
|
||||
let output = Command::new("shasum")
|
||||
.args(["-a", "256"])
|
||||
.arg(fixture(name))
|
||||
.output()
|
||||
.expect("shasum must be installed for fixture verification");
|
||||
assert!(output.status.success(), "shasum failed for {name}");
|
||||
let actual = String::from_utf8(output.stdout).expect("shasum output is UTF-8");
|
||||
assert_eq!(&actual[..64], expected, "fixture drifted: {name}");
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_manifest() -> &'static [u8] {
|
||||
br#"{
|
||||
"version":"1","purpose":"launch","session_id":"session","reconnect_sequence":0,
|
||||
"gateway":{"id":"gateway","addresses":["gateway.test:443"],"public_identity":"gateway.test"},
|
||||
"tunnel":{"versions":["verse-gateway-v1/1"],"features":["control.v1","input.absolute.v1","input.scroll.v1"]},
|
||||
"profile":{"id":"standard","bounds":{"minimum_kbps":1000,"target_kbps":5000,"maximum_kbps":10000},"display_mode":{"resolution_width":1920,"resolution_height":1080,"fps":60}},
|
||||
"grant":{"opaque_value":"ggggggggggggggggggggggggggggggggggggggggggg","expires_at":"2099-01-01T00:00:00Z","audience":"audience"},
|
||||
"correlation_id":"correlation"
|
||||
}"#
|
||||
}
|
||||
|
||||
fn capabilities() -> CapabilityProfile {
|
||||
CapabilityProfile::new(
|
||||
"quic-tls13",
|
||||
"datagram-v2",
|
||||
"encoded",
|
||||
"encoded",
|
||||
"server",
|
||||
vec!["h264-opus".to_owned(), "hevc-opus".to_owned()],
|
||||
)
|
||||
.expect("literal capability profile is valid")
|
||||
}
|
||||
|
||||
const VALID_CERTIFICATE_PEM: &str = "-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----";
|
||||
|
||||
fn credential_json(certificate_chain_pem: &str, trust_bundle_pem: &str) -> Vec<u8> {
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"client_device_id": "device",
|
||||
"device_key_id": "key",
|
||||
"certificate_chain_pem": certificate_chain_pem,
|
||||
"trust_bundle_pem": trust_bundle_pem,
|
||||
"expires_at": "2099-01-01T00:00:00Z",
|
||||
}))
|
||||
.expect("literal credential is JSON-encodable")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_rc5_dtos_reject_duplicate_trailing_unknown_and_provider_fields() {
|
||||
assert!(ConnectionManifest::decode(valid_manifest()).is_ok());
|
||||
assert!(
|
||||
ConnectionManifest::decode(br#"{"version":"1","version":"1","purpose":"launch"}"#).is_err()
|
||||
);
|
||||
|
||||
let trailing = [valid_manifest(), b" {}"].concat();
|
||||
assert!(ConnectionManifest::decode(&trailing).is_err());
|
||||
|
||||
let unknown = String::from_utf8(valid_manifest().to_vec())
|
||||
.expect("fixture is UTF-8")
|
||||
.replacen(
|
||||
"\"correlation_id\"",
|
||||
"\"unknown\":true,\"correlation_id\"",
|
||||
1,
|
||||
);
|
||||
assert!(ConnectionManifest::decode(unknown.as_bytes()).is_err());
|
||||
|
||||
let provider = String::from_utf8(valid_manifest().to_vec())
|
||||
.expect("fixture is UTF-8")
|
||||
.replacen(
|
||||
"\"addresses\"",
|
||||
"\"providerIdentity\":\"hidden\",\"addresses\"",
|
||||
1,
|
||||
);
|
||||
assert!(ConnectionManifest::decode(provider.as_bytes()).is_err());
|
||||
|
||||
let authority = br#"{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","reconnect_sequence":0,"expires_at":"2098-01-01T00:00:00Z","capabilities":{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}"#;
|
||||
let authority_trailing = [authority.as_slice(), b" {}"].concat();
|
||||
assert!(ClientSessionAuthority::decode(&authority_trailing).is_err());
|
||||
let authority_duplicate = String::from_utf8(authority.to_vec())
|
||||
.expect("fixture is UTF-8")
|
||||
.replacen(
|
||||
"\"session_id\":",
|
||||
"\"session_id\":\"duplicate\",\"session_id\":",
|
||||
1,
|
||||
);
|
||||
assert!(ClientSessionAuthority::decode(authority_duplicate.as_bytes()).is_err());
|
||||
|
||||
let admission_provider = format!(
|
||||
r#"{{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","grant":"{}","reconnect_sequence":0,"client_nonce":"{}","device_signature":"{}","provider_identity":"forbidden","capabilities":{{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}}}"#,
|
||||
"g".repeat(43),
|
||||
"n".repeat(16),
|
||||
"A".repeat(86),
|
||||
);
|
||||
assert!(TunnelAdmissionRequest::decode(admission_provider.as_bytes()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_public_identity_requires_dns_sni_not_ip_or_uuid() {
|
||||
for invalid_identity in ["127.0.0.1", "::1", "550e8400-e29b-41d4-a716-446655440000"] {
|
||||
let manifest = String::from_utf8(valid_manifest().to_vec())
|
||||
.expect("fixture is UTF-8")
|
||||
.replace("gateway.test\"}", &format!("{invalid_identity}\"}}"));
|
||||
assert!(
|
||||
ConnectionManifest::decode(manifest.as_bytes()).is_err(),
|
||||
"non-DNS SNI accepted: {invalid_identity}"
|
||||
);
|
||||
}
|
||||
|
||||
let identity_equals_dns_shaped_gateway_id = String::from_utf8(valid_manifest().to_vec())
|
||||
.expect("fixture is UTF-8")
|
||||
.replace("\"id\":\"gateway\"", "\"id\":\"gateway.test\"");
|
||||
assert!(
|
||||
ConnectionManifest::decode(identity_equals_dns_shaped_gateway_id.as_bytes()).is_err(),
|
||||
"public SNI identity matched the logical gateway id"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rc5_manifest_credential_and_authority_enforce_bounds_and_bindings() {
|
||||
let manifest = ConnectionManifest::decode(valid_manifest()).expect("valid manifest");
|
||||
manifest
|
||||
.validate_at("2026-08-12T00:00:00Z")
|
||||
.expect("unexpired manifest");
|
||||
|
||||
let credential = NativeTunnelCredential::decode(&credential_json(
|
||||
VALID_CERTIFICATE_PEM,
|
||||
VALID_CERTIFICATE_PEM,
|
||||
))
|
||||
.expect("valid credential");
|
||||
credential
|
||||
.validate_at("2026-08-12T00:00:00Z")
|
||||
.expect("unexpired credential");
|
||||
assert!(NativeTunnelCredential::decode(
|
||||
br#"{"client_device_id":"device","device_key_id":"key","certificate_chain_pem":"-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----","trust_bundle_pem":"-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----","client_private_key_pem":"forbidden","expires_at":"2099-01-01T00:00:00Z"}"#,
|
||||
)
|
||||
.is_err());
|
||||
|
||||
let authority = ClientSessionAuthority::decode(
|
||||
br#"{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","reconnect_sequence":0,"expires_at":"2098-01-01T00:00:00Z","capabilities":{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}"#,
|
||||
)
|
||||
.expect("valid client-safe authority");
|
||||
authority
|
||||
.validate_binding(&manifest, &capabilities(), "2026-08-12T00:00:00Z")
|
||||
.expect("authority is bound and is a capability subset");
|
||||
|
||||
let provider_authority = br#"{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","reconnect_sequence":0,"expires_at":"2098-01-01T00:00:00Z","capabilities":{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]},"provider_profile":"apollo"}"#;
|
||||
assert!(ClientSessionAuthority::decode(provider_authority).is_err());
|
||||
let provider_route = br#"{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","reconnect_sequence":0,"expires_at":"2098-01-01T00:00:00Z","capabilities":{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]},"stream_host":"provider.invalid"}"#;
|
||||
assert!(ClientSessionAuthority::decode(provider_route).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_authority_directly_rejects_every_manifest_binding_mismatch() {
|
||||
let manifest = ConnectionManifest::decode(valid_manifest()).expect("valid manifest");
|
||||
let offered = CapabilityProfile::new(
|
||||
"quic-tls13",
|
||||
"datagram-v2",
|
||||
"encoded",
|
||||
"encoded",
|
||||
"server",
|
||||
vec!["h264-opus".to_owned()],
|
||||
)
|
||||
.expect("offered capabilities");
|
||||
let valid = serde_json::json!({
|
||||
"version": "1",
|
||||
"session_id": "session",
|
||||
"gateway_id": "gateway",
|
||||
"audience": "audience",
|
||||
"reconnect_sequence": 0,
|
||||
"expires_at": "2098-01-01T00:00:00Z",
|
||||
"capabilities": {
|
||||
"transport": "quic-tls13",
|
||||
"framing": "datagram-v2",
|
||||
"media": "encoded",
|
||||
"audio": "encoded",
|
||||
"source_rate_control": "server",
|
||||
"client_decode": ["h264-opus"],
|
||||
},
|
||||
});
|
||||
for case in [
|
||||
"session",
|
||||
"gateway",
|
||||
"audience",
|
||||
"reconnect",
|
||||
"expired",
|
||||
"beyond-grant",
|
||||
"capability",
|
||||
] {
|
||||
let mut value = valid.clone();
|
||||
match case {
|
||||
"session" => value["session_id"] = "other".into(),
|
||||
"gateway" => value["gateway_id"] = "other".into(),
|
||||
"audience" => value["audience"] = "other".into(),
|
||||
"reconnect" => value["reconnect_sequence"] = 1.into(),
|
||||
"expired" => value["expires_at"] = "2026-08-12T00:00:00Z".into(),
|
||||
"beyond-grant" => value["expires_at"] = "2100-01-01T00:00:00Z".into(),
|
||||
"capability" => {
|
||||
value["capabilities"]["client_decode"] =
|
||||
serde_json::json!(["h264-opus", "hevc-opus"]);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let authority =
|
||||
ClientSessionAuthority::decode(&serde_json::to_vec(&value).expect("encode authority"))
|
||||
.expect("structurally valid authority");
|
||||
assert_eq!(
|
||||
authority.validate_binding(&manifest, &offered, "2026-08-12T00:00:00Z"),
|
||||
Err(versevdi_core::error::CoreError::AuthorityRejected),
|
||||
"binding mismatch accepted: {case}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_tunnel_credential_rejects_private_key_pem_in_certificate_fields() {
|
||||
for field in ["certificate_chain_pem", "trust_bundle_pem"] {
|
||||
let private_key = "-----BEGIN PRIVATE KEY-----\nAQID\n-----END PRIVATE KEY-----";
|
||||
let credential = if field == "certificate_chain_pem" {
|
||||
credential_json(private_key, VALID_CERTIFICATE_PEM)
|
||||
} else {
|
||||
credential_json(VALID_CERTIFICATE_PEM, private_key)
|
||||
};
|
||||
assert!(
|
||||
NativeTunnelCredential::decode(&credential).is_err(),
|
||||
"private key armor accepted in {field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_tunnel_credential_accepts_one_or_more_certificate_blocks() {
|
||||
let two_certificates = format!("{VALID_CERTIFICATE_PEM}\n\n{VALID_CERTIFICATE_PEM}\n");
|
||||
assert!(NativeTunnelCredential::decode(&credential_json(
|
||||
&two_certificates,
|
||||
VALID_CERTIFICATE_PEM,
|
||||
))
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
fn assert_credential_pem_rejected(invalid_values: &[&str]) {
|
||||
for invalid in invalid_values {
|
||||
assert!(
|
||||
NativeTunnelCredential::decode(&credential_json(invalid, VALID_CERTIFICATE_PEM))
|
||||
.is_err(),
|
||||
"invalid certificate chain accepted"
|
||||
);
|
||||
assert!(
|
||||
NativeTunnelCredential::decode(&credential_json(VALID_CERTIFICATE_PEM, invalid))
|
||||
.is_err(),
|
||||
"invalid trust bundle accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_tunnel_credential_rejects_bare_certificate_text() {
|
||||
assert_credential_pem_rejected(&["certificate"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_tunnel_credential_rejects_non_certificate_pem_labels() {
|
||||
assert_credential_pem_rejected(&["-----BEGIN PUBLIC KEY-----\nAQID\n-----END PUBLIC KEY-----"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_tunnel_credential_rejects_malformed_or_incomplete_certificate_armor() {
|
||||
assert_credential_pem_rejected(&[
|
||||
"-----BEGIN CERTIFICATE-----\nAQID",
|
||||
"-----BEGIN CERTIFICATE-----\n!!!!\n-----END CERTIFICATE-----",
|
||||
"-----BEGIN CERTIFICATE-----\nAQI\n-----END CERTIFICATE-----",
|
||||
"-----BEGIN CERTIFICATE-----\nAQJ=\n-----END CERTIFICATE-----",
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_tunnel_credential_rejects_junk_between_or_after_certificate_blocks() {
|
||||
let between = format!("{VALID_CERTIFICATE_PEM}\njunk\n{VALID_CERTIFICATE_PEM}");
|
||||
let after = format!("{VALID_CERTIFICATE_PEM}\njunk");
|
||||
assert_credential_pem_rejected(&[&between, &after]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_tunnel_credential_rejects_empty_certificate_blocks() {
|
||||
assert_credential_pem_rejected(&["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_mismatch_and_capability_escalation_are_rejected() {
|
||||
let manifest = ConnectionManifest::decode(valid_manifest()).expect("valid manifest");
|
||||
assert!(manifest.validate_at("2100-01-01T00:00:00Z").is_err());
|
||||
let credential = NativeTunnelCredential::decode(&credential_json(
|
||||
VALID_CERTIFICATE_PEM,
|
||||
VALID_CERTIFICATE_PEM,
|
||||
))
|
||||
.expect("valid credential");
|
||||
assert!(credential.validate_at("2099-01-01T00:00:00Z").is_err());
|
||||
|
||||
let mismatched = ClientSessionAuthority::decode(
|
||||
br#"{"version":"1","session_id":"other","gateway_id":"gateway","audience":"audience","reconnect_sequence":0,"expires_at":"2098-01-01T00:00:00Z","capabilities":{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}"#,
|
||||
)
|
||||
.expect("shape is valid");
|
||||
assert!(mismatched
|
||||
.validate_binding(&manifest, &capabilities(), "2026-08-12T00:00:00Z")
|
||||
.is_err());
|
||||
|
||||
let escalated = ClientSessionAuthority::decode(
|
||||
br#"{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","reconnect_sequence":0,"expires_at":"2098-01-01T00:00:00Z","capabilities":{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus","hevc-opus"]}}"#,
|
||||
)
|
||||
.expect("shape is valid");
|
||||
let h264_only = CapabilityProfile::new(
|
||||
"quic-tls13",
|
||||
"datagram-v2",
|
||||
"encoded",
|
||||
"encoded",
|
||||
"server",
|
||||
vec!["h264-opus".to_owned()],
|
||||
)
|
||||
.expect("literal capability profile");
|
||||
assert!(escalated
|
||||
.validate_binding(&manifest, &h264_only, "2026-08-12T00:00:00Z")
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_transcript_matches_rc5_literal() {
|
||||
let request = TunnelAdmissionRequest::decode(
|
||||
format!(
|
||||
r#"{{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","grant":"{}","reconnect_sequence":0,"client_nonce":"{}","device_signature":"{}","capabilities":{{"transport":"quic-tls13","framing":"datagram-v1","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}}}"#,
|
||||
"g".repeat(43),
|
||||
"n".repeat(16),
|
||||
"A".repeat(86),
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("valid admission request");
|
||||
|
||||
assert_eq!(
|
||||
request.admission_transcript(),
|
||||
format!(
|
||||
"versevdi/tunnel-admission/v17:session7:gateway8:audience43:{}1:016:{}10:quic-tls1311:datagram-v17:encoded7:encoded6:server1:19:h264-opus",
|
||||
"g".repeat(43),
|
||||
"n".repeat(16),
|
||||
)
|
||||
.into_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_rejects_non_raw_base64url_nonce_and_signature() {
|
||||
for (nonce, signature) in [
|
||||
("!".repeat(16), "A".repeat(86)),
|
||||
("A".repeat(17), "A".repeat(86)),
|
||||
("A".repeat(16), "!".repeat(86)),
|
||||
("A".repeat(16), format!("{}B", "A".repeat(85))),
|
||||
] {
|
||||
let request = format!(
|
||||
r#"{{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","grant":"{}","reconnect_sequence":0,"client_nonce":"{}","device_signature":"{}","capabilities":{{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}}}"#,
|
||||
"g".repeat(43),
|
||||
nonce,
|
||||
signature,
|
||||
);
|
||||
assert!(TunnelAdmissionRequest::decode(request.as_bytes()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_hex(value: &str) -> Vec<u8> {
|
||||
value
|
||||
.as_bytes()
|
||||
.chunks_exact(2)
|
||||
.map(|pair| {
|
||||
let text = std::str::from_utf8(pair).expect("fixture hex is ASCII");
|
||||
u8::from_str_radix(text, 16).expect("fixture hex is valid")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fixture_rows(name: &str) -> impl Iterator<Item = Vec<&'static str>> {
|
||||
let data = match name {
|
||||
"datagram-v2.tsv" => include_str!("fixtures/datagram-v2.tsv"),
|
||||
"gateway-input-feedback-v1.tsv" => {
|
||||
include_str!("fixtures/gateway-input-feedback-v1.tsv")
|
||||
}
|
||||
_ => unreachable!("known fixture"),
|
||||
};
|
||||
data.lines()
|
||||
.skip(1)
|
||||
.map(|line| line.split('\t').collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datagram_v2_codec_matches_all_rc5_conformance_rows() {
|
||||
for row in fixture_rows("datagram-v2.tsv") {
|
||||
let bytes = decode_hex(row[3].strip_prefix("hex=").expect("hex fixture"));
|
||||
match row[4] {
|
||||
"valid" => {
|
||||
let fragment = MediaFragment::decode(&bytes).expect(row[0]);
|
||||
assert_eq!(fragment.encode().expect(row[0]), bytes, "{}", row[0]);
|
||||
}
|
||||
error => assert_eq!(
|
||||
MediaFragment::decode(&bytes).expect_err(row[0]).code(),
|
||||
error.strip_prefix("invalid:").expect("invalid fixture"),
|
||||
"{}",
|
||||
row[0]
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vgi1_and_vgf1_codecs_match_all_rc5_conformance_rows() {
|
||||
let features = ["input.absolute.v1", "input.scroll.v1"];
|
||||
for row in fixture_rows("gateway-input-feedback-v1.tsv") {
|
||||
let bytes = decode_hex(row[3].strip_prefix("hex=").expect("hex fixture"));
|
||||
let result = match row[2] {
|
||||
"gateway_input" => {
|
||||
decode_input(&bytes, &features).and_then(|event| encode_input(&event, &features))
|
||||
}
|
||||
"gateway_feedback" => decode_feedback(&bytes).and_then(|event| encode_feedback(&event)),
|
||||
_ => unreachable!("known fixture kind"),
|
||||
};
|
||||
match row[4] {
|
||||
"valid" => assert_eq!(result.expect(row[0]), bytes, "{}", row[0]),
|
||||
error => assert_eq!(
|
||||
result.expect_err(row[0]).code(),
|
||||
error.strip_prefix("invalid:").expect("invalid fixture"),
|
||||
"{}",
|
||||
row[0]
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datagram_v2_reassembly_is_bounded_reordered_and_duplicate_safe() {
|
||||
let first = MediaFragment::new_video(7, 42, 0, 2, b"hello".to_vec()).expect("fragment");
|
||||
let second = MediaFragment::new_video(7, 42, 1, 2, b" world".to_vec()).expect("fragment");
|
||||
let mut reassembler = Reassembler::new();
|
||||
|
||||
assert!(reassembler
|
||||
.push(second.clone(), 10)
|
||||
.expect("second")
|
||||
.is_none());
|
||||
assert!(reassembler.push(second, 11).expect("duplicate").is_none());
|
||||
let unit = reassembler
|
||||
.push(first, 12)
|
||||
.expect("first")
|
||||
.expect("complete unit");
|
||||
assert_eq!(unit.payload, b"hello world");
|
||||
assert_eq!(reassembler.incomplete_units(), 0);
|
||||
|
||||
for sequence in 0..5 {
|
||||
let fragment = MediaFragment::new_video(sequence, 1, 0, 2, vec![0]).expect("fragment");
|
||||
assert!(reassembler.push(fragment, 20 + u64::from(sequence)).is_ok());
|
||||
}
|
||||
assert_eq!(reassembler.incomplete_units(), 4);
|
||||
assert_eq!(reassembler.evicted_units(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datagram_v2_boundary_loop_covers_payload_fragment_count_and_audio() {
|
||||
for payload_length in [0, 1, 1_177] {
|
||||
let fragment = MediaFragment::new_audio(
|
||||
u32::try_from(payload_length).expect("small"),
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
vec![0; payload_length],
|
||||
)
|
||||
.expect("boundary is valid");
|
||||
assert_eq!(
|
||||
MediaFragment::decode(&fragment.encode().expect("encode")).expect("decode"),
|
||||
fragment
|
||||
);
|
||||
}
|
||||
assert!(MediaFragment::new_audio(1, 1, 0, 1, vec![0; 1_178]).is_err());
|
||||
assert!(MediaFragment::new_audio(1, 1, 0, 0, Vec::new()).is_err());
|
||||
assert!(MediaFragment::new_audio(1, 1, 0, 892, Vec::new()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reassembly_discards_conflicts_and_expires_after_250_ms() {
|
||||
let mut reassembler = Reassembler::new();
|
||||
let first = MediaFragment::new_video(1, 1, 0, 2, vec![1]).expect("fragment");
|
||||
reassembler.push(first, 0).expect("first");
|
||||
let conflict = MediaFragment::new_video(1, 1, 0, 2, vec![2]).expect("fragment");
|
||||
assert!(reassembler.push(conflict, 1).is_err());
|
||||
assert_eq!(reassembler.incomplete_units(), 0);
|
||||
|
||||
let expiring = MediaFragment::new_video(2, 1, 0, 2, vec![1]).expect("fragment");
|
||||
reassembler.push(expiring, 10).expect("first");
|
||||
let next = MediaFragment::new_video(3, 1, 0, 2, vec![1]).expect("fragment");
|
||||
reassembler.push(next, 261).expect("expiry sweep");
|
||||
assert_eq!(reassembler.expired_units(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reassembly_expires_at_exactly_250_ms() {
|
||||
let mut reassembler = Reassembler::new();
|
||||
let expiring = MediaFragment::new_video(2, 1, 0, 2, vec![1]).expect("fragment");
|
||||
reassembler.push(expiring, 10).expect("first");
|
||||
let next = MediaFragment::new_video(3, 1, 0, 2, vec![1]).expect("fragment");
|
||||
reassembler.push(next, 260).expect("expiry sweep");
|
||||
assert_eq!(reassembler.expired_units(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reassembly_rejects_a_complete_unit_above_one_mebibyte() {
|
||||
let mut reassembler = Reassembler::new();
|
||||
for index in 0..891_u16 {
|
||||
let fragment = MediaFragment::new_video(9, 1, index, 891, vec![0; 1_177])
|
||||
.expect("individual fragment is bounded");
|
||||
let result = reassembler.push(fragment, 0);
|
||||
if index < 890 {
|
||||
assert!(result.expect("within aggregate bound").is_none());
|
||||
} else {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
assert_eq!(reassembler.incomplete_units(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_features_and_feedback_booleans_fail_closed() {
|
||||
assert_eq!(
|
||||
decode_input(&decode_hex("56474931060804d202370a0005a0"), &[])
|
||||
.expect_err("absolute feature is required")
|
||||
.code(),
|
||||
"unsupported_feature"
|
||||
);
|
||||
assert_eq!(
|
||||
decode_feedback(&decode_hex("564746310112000102"))
|
||||
.expect_err("HDR is boolean")
|
||||
.code(),
|
||||
"length"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnected_gateway_feedback_is_provider_free_control() {
|
||||
assert_eq!(
|
||||
decode_feedback(&decode_hex("5647463101130000")).expect("disconnected feedback"),
|
||||
FeedbackEvent::Disconnected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fec_feedback_enforces_rc5_go_field_invariants_on_decode_and_encode() {
|
||||
for invalid in [
|
||||
"56474631000200150000002a0005000300020000000200080002140001",
|
||||
"56474631000200150000002a000500030002000a0002000b0002140001",
|
||||
"56474631000200150000002a000500030002000a000200080003140001",
|
||||
"56474631000200150000002a000500030002000a000200080002650001",
|
||||
"56474631000200150000002a000500030002000a000200080002140000",
|
||||
"56474631000200150000002a000500030002000a000200080002140101",
|
||||
] {
|
||||
assert_eq!(
|
||||
decode_feedback(&decode_hex(invalid))
|
||||
.expect_err("invalid FEC status")
|
||||
.code(),
|
||||
"field"
|
||||
);
|
||||
}
|
||||
|
||||
let valid = FecStatus {
|
||||
frame_index: 42,
|
||||
highest_received_sequence: 5,
|
||||
next_contiguous_sequence: 3,
|
||||
missing_before_highest: 2,
|
||||
total_data_packets: 10,
|
||||
total_parity_packets: 2,
|
||||
received_data_packets: 8,
|
||||
received_parity_packets: 2,
|
||||
fec_percentage: 20,
|
||||
multi_fec_block_index: 0,
|
||||
multi_fec_block_count: 1,
|
||||
};
|
||||
let invalid = [
|
||||
FecStatus {
|
||||
total_data_packets: 0,
|
||||
..valid.clone()
|
||||
},
|
||||
FecStatus {
|
||||
received_data_packets: 11,
|
||||
..valid.clone()
|
||||
},
|
||||
FecStatus {
|
||||
received_parity_packets: 3,
|
||||
..valid.clone()
|
||||
},
|
||||
FecStatus {
|
||||
fec_percentage: 101,
|
||||
..valid.clone()
|
||||
},
|
||||
FecStatus {
|
||||
multi_fec_block_count: 0,
|
||||
..valid.clone()
|
||||
},
|
||||
FecStatus {
|
||||
multi_fec_block_index: 1,
|
||||
..valid
|
||||
},
|
||||
];
|
||||
for status in invalid {
|
||||
assert_eq!(
|
||||
encode_feedback(&FeedbackEvent::Fec(status))
|
||||
.expect_err("invalid FEC status")
|
||||
.code(),
|
||||
"field"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,388 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"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) {
|
||||
packets, err := encodeApolloInputEvent(InputEvent{Device: "keyboard", Code: 30, Pressed: true, Payload: []byte{2}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const expected = "0000000a03000000001e00020000"
|
||||
if len(packets) != 1 || string(packets[0].payload) != string(mustDecodeHex(t, expected)) {
|
||||
t.Fatalf("keyboard packets = %#v, want %s", packets, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApolloAbsoluteAndScrollInputWireVectors(t *testing.T) {
|
||||
// Independently implemented from the approved Apollo adc5c5a0 input.cpp
|
||||
// consumer and its moonlight-common-c c999436 Input.h/InputStream.c pin.
|
||||
absolute, err := encodeApolloInputEvent(InputEvent{Device: "mouse-absolute", Payload: []byte{0x04, 0xd2, 0x02, 0x37, 0x0a, 0x00, 0x05, 0xa0}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const absoluteExpected = "0000000e0500000004d20237000009ff059f"
|
||||
if len(absolute) != 1 || absolute[0].channel != apolloChannelMouse || string(absolute[0].payload) != string(mustDecodeHex(t, absoluteExpected)) {
|
||||
t.Fatalf("absolute packets = %#v, want %s", absolute, absoluteExpected)
|
||||
}
|
||||
|
||||
scroll, err := encodeApolloInputEvent(InputEvent{Device: "mouse-scroll", Payload: []byte{0xff, 0x88, 0x00, 0x78}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const verticalExpected = "0000000a0a000000ff88ff880000"
|
||||
const horizontalExpected = "00000006010000550078"
|
||||
if len(scroll) != 2 || scroll[0].channel != apolloChannelMouse || scroll[1].channel != apolloChannelMouse ||
|
||||
string(scroll[0].payload) != string(mustDecodeHex(t, verticalExpected)) || string(scroll[1].payload) != string(mustDecodeHex(t, horizontalExpected)) {
|
||||
t.Fatalf("scroll packets = %#v", scroll)
|
||||
}
|
||||
|
||||
zero, err := encodeApolloInputEvent(InputEvent{Device: "mouse-scroll", Payload: make([]byte, 4)})
|
||||
if err != nil || len(zero) != 0 {
|
||||
t.Fatalf("zero scroll packets = %#v, %v", zero, err)
|
||||
}
|
||||
|
||||
for _, payload := range [][]byte{
|
||||
{0, 0, 0, 0, 0, 1, 0, 2},
|
||||
{0, 0, 0, 0, 0x80, 0, 0, 2},
|
||||
{0, 0, 0, 0, 0, 2, 0x80, 0},
|
||||
} {
|
||||
if _, err := encodeApolloInputEvent(InputEvent{Device: "mouse-absolute", Payload: payload}); !errors.Is(err, ErrInputMalformed) {
|
||||
t.Fatalf("Apollo accepted unrepresentable absolute payload %x: %v", payload, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,126 @@
|
||||
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 nil, 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 nil, 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 nil, 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 nil, 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 nil, 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
|
||||
case "mouse-absolute":
|
||||
if event.Pressed || event.Code != 0 || !validAbsolutePayload(event.Payload) {
|
||||
return nil, ErrInputMalformed
|
||||
}
|
||||
width, height := binary.BigEndian.Uint16(event.Payload[4:6]), binary.BigEndian.Uint16(event.Payload[6:8])
|
||||
if width < 2 || height < 2 || width > 0x7fff || height > 0x7fff {
|
||||
return nil, ErrInputMalformed
|
||||
}
|
||||
packet := make([]byte, 18)
|
||||
binary.BigEndian.PutUint32(packet[:4], 14)
|
||||
binary.LittleEndian.PutUint32(packet[4:8], 5)
|
||||
copy(packet[8:12], event.Payload[:4])
|
||||
binary.BigEndian.PutUint16(packet[14:16], width-1)
|
||||
binary.BigEndian.PutUint16(packet[16:18], height-1)
|
||||
return []apolloInputPacket{{channel: apolloChannelMouse, payload: packet}}, nil
|
||||
case "mouse-scroll":
|
||||
if event.Pressed || event.Code != 0 || len(event.Payload) != 4 {
|
||||
return nil, ErrInputMalformed
|
||||
}
|
||||
packets := make([]apolloInputPacket, 0, 2)
|
||||
if event.Payload[0] != 0 || event.Payload[1] != 0 {
|
||||
packet := make([]byte, 14)
|
||||
binary.BigEndian.PutUint32(packet[:4], 10)
|
||||
binary.LittleEndian.PutUint32(packet[4:8], 10)
|
||||
copy(packet[8:10], event.Payload[:2])
|
||||
copy(packet[10:12], event.Payload[:2])
|
||||
packets = append(packets, apolloInputPacket{channel: apolloChannelMouse, payload: packet})
|
||||
}
|
||||
if event.Payload[2] != 0 || event.Payload[3] != 0 {
|
||||
packet := make([]byte, 10)
|
||||
binary.BigEndian.PutUint32(packet[:4], 6)
|
||||
binary.LittleEndian.PutUint32(packet[4:8], 0x55000001)
|
||||
copy(packet[8:10], event.Payload[2:4])
|
||||
packets = append(packets, apolloInputPacket{channel: apolloChannelMouse, payload: packet})
|
||||
}
|
||||
return packets, nil
|
||||
default:
|
||||
return nil, 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,37 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol"
|
||||
)
|
||||
|
||||
var ErrNoCapabilityOverlap = errors.New("no capability overlap")
|
||||
|
||||
func DefaultFeatures() []string {
|
||||
return []string{"quic-tls13", "datagram.media", "apollo", "display.request.v1", "input.absolute.v1", "input.scroll.v1"}
|
||||
}
|
||||
|
||||
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,171 @@
|
||||
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
|
||||
gatewayInputAbsolute = 6
|
||||
gatewayInputScroll = 7
|
||||
)
|
||||
|
||||
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
|
||||
case "mouse-absolute":
|
||||
if event.Pressed || event.Code != 0 || !validAbsolutePayload(event.Payload) {
|
||||
return nil, ErrInputMalformed
|
||||
}
|
||||
encoded := make([]byte, gatewayInputHeaderSize+8)
|
||||
copy(encoded, "VGI1")
|
||||
encoded[4], encoded[5] = gatewayInputAbsolute, 8
|
||||
copy(encoded[6:], event.Payload)
|
||||
return encoded, nil
|
||||
case "mouse-scroll":
|
||||
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] = gatewayInputScroll, 4
|
||||
copy(encoded[6:], 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
|
||||
case gatewayInputAbsolute:
|
||||
if !validAbsolutePayload(body) {
|
||||
return InputEvent{}, ErrInputMalformed
|
||||
}
|
||||
return InputEvent{Device: "mouse-absolute", Payload: append([]byte(nil), body...)}, nil
|
||||
case gatewayInputScroll:
|
||||
if len(body) != 4 {
|
||||
return InputEvent{}, ErrInputMalformed
|
||||
}
|
||||
return InputEvent{Device: "mouse-scroll", Payload: append([]byte(nil), body...)}, nil
|
||||
default:
|
||||
return InputEvent{}, ErrInputMalformed
|
||||
}
|
||||
}
|
||||
|
||||
func validAbsolutePayload(payload []byte) bool {
|
||||
if len(payload) != 8 {
|
||||
return false
|
||||
}
|
||||
x, y := binary.BigEndian.Uint16(payload[:2]), binary.BigEndian.Uint16(payload[2:4])
|
||||
width, height := binary.BigEndian.Uint16(payload[4:6]), binary.BigEndian.Uint16(payload[6:8])
|
||||
return width != 0 && height != 0 && x < width && y < height
|
||||
}
|
||||
|
||||
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-phase3d-macos-rc.5
|
||||
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,44 @@
|
||||
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=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3d-macos-rc.1 h1:uCH+etSQtzqfhhRP3dWdp6aIDTU55G7r1UEOAJfWsGw=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3d-macos-rc.1/go.mod h1:7PhFIDhjtr20btWoEb2GqB+7dBpzJt43olrnHVutWoc=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3d-macos-rc.2 h1:UdkgLXQ2fWFCTGcMblZHXhvQbDx2wfTy4eHyEvav72U=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3d-macos-rc.2/go.mod h1:7PhFIDhjtr20btWoEb2GqB+7dBpzJt43olrnHVutWoc=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3d-macos-rc.3 h1:43EJHiKcbWdF0K1dvVznpWaUUWcvT5nogcGiSnOVu5s=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3d-macos-rc.3/go.mod h1:7PhFIDhjtr20btWoEb2GqB+7dBpzJt43olrnHVutWoc=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3d-macos-rc.5 h1:bJ3JeCm7dsQwWc7kMjcSgBrtATT1hi2xUSNdE73q4X4=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3d-macos-rc.5/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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user