Compare commits
200 Commits
22210fadbc
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0da9e7d175 | ||
|
|
38274a5271 | ||
|
|
576c033031 | ||
|
|
f000a9d979 | ||
|
|
97e39949a8 | ||
|
|
ddab77b337 | ||
|
|
5cc755b0b6 | ||
|
|
822364d12b | ||
|
|
217206e9a9 | ||
|
|
284cfd193a | ||
|
|
14f28e9d66 | ||
|
|
390dd11202 | ||
|
|
8075d2c671 | ||
|
|
538c8ebf34 | ||
|
|
53bee034ca | ||
|
|
2a24935b8d | ||
|
|
5833529c0c | ||
|
|
9114630c3a | ||
|
|
a5fa843f00 | ||
|
|
850531fd07 | ||
|
|
c4f8b5b47f | ||
|
|
c1612d0145 | ||
|
|
b09b90e5bb | ||
|
|
3ad8d8beba | ||
|
|
a6cf547f04 | ||
|
|
d39a0ab8d1 | ||
|
|
2bfc76b397 | ||
|
|
22a7929a7b | ||
|
|
f6ef19ebf6 | ||
|
|
980dbaa928 | ||
|
|
4892fa7b49 | ||
|
|
35d32f4670 | ||
|
|
3fe719f874 | ||
|
|
0de5557921 | ||
|
|
3e5cfdc1cf | ||
|
|
ffc185aa2d | ||
|
|
029bec831a | ||
|
|
e506d0dbbf | ||
|
|
dba83559d3 | ||
|
|
a25fe30f1a | ||
|
|
950d2298a1 | ||
|
|
c3613c2fae | ||
|
|
609e7cea69 | ||
|
|
2066631c0a | ||
|
|
3c0b2f6bae | ||
|
|
e004da1a55 | ||
|
|
042c4cd4a9 | ||
|
|
c8d12780b9 | ||
|
|
f711db9315 | ||
|
|
e81c426329 | ||
|
|
cc811dd18d | ||
|
|
8fe1f52e5d | ||
|
|
7db7be456c | ||
|
|
1137090626 | ||
|
|
553a00c32f | ||
|
|
d92caedaee | ||
|
|
2a602d5289 | ||
|
|
befe677759 | ||
|
|
7064a39bf1 | ||
|
|
0970c623eb | ||
|
|
5509c81eee | ||
|
|
f3f4d8baa6 | ||
|
|
b1bc50ccd1 | ||
|
|
1dbed54581 | ||
|
|
675de771c7 | ||
|
|
7c1d43376d | ||
|
|
0b35dc043f | ||
|
|
c98f5e6a1f | ||
|
|
1e398c7561 | ||
|
|
55d177e9ee | ||
|
|
9f7f5c0c54 | ||
|
|
6e04eb0661 | ||
|
|
af630143de | ||
|
|
10688b0dd1 | ||
|
|
5e427dcf98 | ||
|
|
07bcbf0c08 | ||
|
|
9a5909f672 | ||
|
|
fff011bb7f | ||
|
|
232ef22535 | ||
|
|
ca9eaa8f1f | ||
|
|
bc31de85dd | ||
|
|
469037cb94 | ||
|
|
9683a16f4f | ||
|
|
c81821b890 | ||
|
|
6541246fc9 | ||
|
|
a7eba2d43b | ||
|
|
19f241d7a3 | ||
|
|
552f35c690 | ||
|
|
1dd12b035a | ||
|
|
7551f8a4b2 | ||
|
|
b119c31019 | ||
|
|
3076843e9c | ||
|
|
e062065cd3 | ||
|
|
debf47d99e | ||
|
|
3e49e36806 | ||
|
|
09134e5001 | ||
|
|
8be2b06564 | ||
|
|
e7bfbe951d | ||
|
|
f8f82dce21 | ||
|
|
c6d819f85f | ||
|
|
afc22989d6 | ||
|
|
733c8a8318 | ||
|
|
007e598802 | ||
|
|
cd97114f87 | ||
|
|
5475b661ae | ||
|
|
06814ba276 | ||
|
|
e254918b1c | ||
|
|
542fde9580 | ||
|
|
e7f3bd05f0 | ||
|
|
31054450fc | ||
|
|
4fe1981997 | ||
|
|
7b3fe1b124 | ||
|
|
4ea8f7862a | ||
|
|
cf88e7c588 | ||
|
|
34e4a88059 | ||
|
|
99cafdbdbb | ||
|
|
57725f7ef2 | ||
|
|
fc3b849a08 | ||
|
|
a24465623e | ||
|
|
a25633a63b | ||
|
|
5b9ca321d2 | ||
|
|
cb04516d52 | ||
|
|
d0c249c739 | ||
|
|
bb0949553c | ||
|
|
e38e6d1689 | ||
|
|
5337281e85 | ||
|
|
6b8269c1c1 | ||
|
|
c1c837c54f | ||
|
|
89678c7949 | ||
|
|
7af4a68ef5 | ||
|
|
6efed9772e | ||
|
|
1a8984e851 | ||
|
|
d77f1ff62c | ||
|
|
5e7e4b22f2 | ||
|
|
bfe1be1dfe | ||
|
|
aa1912b962 | ||
|
|
95b9cccf07 | ||
|
|
242a4e0dc1 | ||
|
|
86d100a9ad | ||
|
|
3492f0cf1f | ||
|
|
d36deb6922 | ||
|
|
a887638557 | ||
|
|
d01a69eb72 | ||
|
|
c44725618c | ||
|
|
660a40491a | ||
|
|
823432b1c8 | ||
|
|
77502ec4fe | ||
|
|
9c0097a305 | ||
|
|
f40b8f9400 | ||
|
|
4871e8ac3d | ||
|
|
aa956fcbb4 | ||
|
|
cc4d3129cc | ||
|
|
5098643355 | ||
|
|
5c8c87fb7a | ||
|
|
a2b14ab6e7 | ||
|
|
95438cdc12 | ||
|
|
2ab93c9682 | ||
|
|
cbaa08daba | ||
|
|
9b41ffa574 | ||
|
|
ba85871227 | ||
|
|
3d17bed322 | ||
|
|
340ea48478 | ||
|
|
c0279fd6e2 | ||
|
|
0ad7c31549 | ||
|
|
ad5cf06207 | ||
|
|
3020184054 | ||
|
|
a09c131539 | ||
|
|
1529d2c94c | ||
|
|
cf8cfccab4 | ||
|
|
2af57e6686 | ||
|
|
e4c327e25e | ||
|
|
03612323c0 | ||
|
|
7b3ba8491a | ||
|
|
7fd2ef3fc8 | ||
|
|
b273b61795 | ||
|
|
e554c053ee | ||
|
|
d6809c65c4 | ||
|
|
4f1d3ebc6b | ||
|
|
88b960bac5 | ||
|
|
67b0a43b39 | ||
|
|
59de784c8a | ||
|
|
bf5241e87b | ||
|
|
46698b8b5e | ||
|
|
99bc6fd9f6 | ||
|
|
a390130205 | ||
|
|
3323bc81c0 | ||
|
|
32fb4b09b1 | ||
|
|
985ff8a178 | ||
|
|
7b4adf5072 | ||
|
|
dc5d073374 | ||
|
|
f9964a517d | ||
|
|
d22dcd24f7 | ||
|
|
97d57326fd | ||
|
|
50406caab0 | ||
|
|
9466bf4b6e | ||
|
|
787f806e02 | ||
|
|
199e29d15b | ||
|
|
edbfc62f7f | ||
|
|
d782ec8488 | ||
|
|
021a514b2d |
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"worktree": {
|
||||
"baseRef": "head"
|
||||
}
|
||||
}
|
||||
12
.gitattributes
vendored
Normal file
12
.gitattributes
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# public/projects.ts embeds literal NUL bytes on purpose: the namespace-group
|
||||
# sentinels ACTIVE_GROUP_KEY = '\0active' and OTHER_GROUP_KEY = '\0other' are
|
||||
# prefixed with NUL so they can never collide with a real `First.Second` key.
|
||||
#
|
||||
# git classifies any file containing NUL as binary, so `git diff`/`git show`
|
||||
# print "Binary files differ" and review of this file is impossible. The `diff`
|
||||
# attribute tells git to diff it as text anyway. It does NOT set `text`, so
|
||||
# nothing is line-ending-normalised and the bytes on disk are untouched.
|
||||
#
|
||||
# Same trap in the shell: `grep` prints only "Binary file ... matches" and looks
|
||||
# like a no-match. Use `grep -a` on this file.
|
||||
public/projects.ts diff
|
||||
184
.github/workflows/android.yml
vendored
Normal file
184
.github/workflows/android.yml
vendored
Normal file
@@ -0,0 +1,184 @@
|
||||
# Android CI (F2). Until this file existed the Android client had ZERO CI — 10 modules,
|
||||
# 691 JVM tests and the whole `WEBTERM_TOKEN` secret-handling wave (Tink AEAD under an
|
||||
# AndroidKeystore master key, the POST /auth probe, cookie stamping on every route and on
|
||||
# the WS upgrade) were gated by nothing at all. Modelled on ios.yml: same `on:` shape
|
||||
# (push + pull_request, path-filtered), same "one job per verification layer" structure,
|
||||
# and — like ios.yml — deliberately NO `concurrency:` group, so the two workflows behave
|
||||
# the same way on rapid pushes.
|
||||
#
|
||||
# ── HONESTY NOTE (read before trusting a green badge) ────────────────────────────────────
|
||||
# This repository's only remote is a self-hosted Gitea instance, so GitHub Actions has
|
||||
# NEVER run here and this file has never been executed by the platform. What IS verified:
|
||||
# every command below was run locally on macOS against the same Gradle build
|
||||
# (`./gradlew test :app:assembleDebug koverVerify koverLog` + the androidTest compile),
|
||||
# and the YAML parses. What is NOT verified: the runner-side pieces — action resolution,
|
||||
# the preinstalled Android SDK layout on `ubuntu-latest`, and the emulator leg.
|
||||
#
|
||||
# ── Why no `src/**` path trigger (ios.yml HAS one) ───────────────────────────────────────
|
||||
# ios.yml watches `src/**` because it owns `integration-tests`, a Swift layer that boots the
|
||||
# real Node server and would catch client↔server protocol drift. Android has no equivalent
|
||||
# leg (android/README.md, "DEFERRED — end-to-end access token": nothing here starts a server
|
||||
# with WEBTERM_TOKEN set and completes a cookie-gated WS upgrade). Triggering on `src/**`
|
||||
# would therefore burn runner minutes without checking a single contract. When an Android
|
||||
# server-contract leg is added, add `src/**` here at the same time.
|
||||
name: android
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "android/**"
|
||||
- ".github/workflows/android.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "android/**"
|
||||
- ".github/workflows/android.yml"
|
||||
# Manual trigger for the instrumented (device) leg below, which is dispatch-only.
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# Layer 1 — everything that runs without a device. This is exactly the green gate
|
||||
# android/README.md documents ("./gradlew test :app:assembleDebug koverVerify"), plus a
|
||||
# compile-only pass over the instrumented sources so they cannot silently rot.
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
defaults:
|
||||
run:
|
||||
working-directory: android
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# jvmToolchain(17) is declared by every module; Gradle resolves it from the
|
||||
# installed JDKs, so 17 must actually be present (the runner image's default may
|
||||
# be a different major).
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
cache: gradle
|
||||
|
||||
# :app/:terminal-view/:host-registry/:client-tls-android compile against SDK 36 with
|
||||
# build-tools 36.0.0 (android/README.md "Android SDK setup"). The runner image ships an
|
||||
# Android SDK, but which platforms it holds drifts image to image — so install what the
|
||||
# build actually needs instead of assuming, and FAIL LOUDLY if there is no sdkmanager.
|
||||
- name: Install the Android SDK packages this build compiles against
|
||||
run: |
|
||||
# `set -eu` WITHOUT pipefail on purpose: `yes | sdkmanager --licenses` kills `yes`
|
||||
# with SIGPIPE (141) as soon as the prompts stop, and pipefail would report that
|
||||
# normal ending as a failed step.
|
||||
set -eu
|
||||
sdkmanager_bin="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
|
||||
if [ ! -x "$sdkmanager_bin" ]; then
|
||||
sdkmanager_bin="$(ls -1 "$ANDROID_HOME"/cmdline-tools/*/bin/sdkmanager 2>/dev/null | tail -n 1 || true)"
|
||||
fi
|
||||
if [ ! -x "$sdkmanager_bin" ]; then
|
||||
echo "::error title=No sdkmanager::none found under $ANDROID_HOME/cmdline-tools — the Android SDK is not usable on this runner" >&2
|
||||
exit 1
|
||||
fi
|
||||
yes | "$sdkmanager_bin" --licenses > /dev/null
|
||||
"$sdkmanager_bin" "platforms;android-36" "build-tools;36.0.0" "platform-tools"
|
||||
|
||||
# 10 modules of JVM unit tests (JUnit 5 + coroutines-test + Turbine + MockK). Includes
|
||||
# the Android-library modules' testDebugUnitTest and :app's — no device needed.
|
||||
- name: JVM unit tests (all modules)
|
||||
run: ./gradlew test
|
||||
|
||||
# The APK build is its own step: a Kotlin/KSP/Hilt/Compose breakage that unit tests do
|
||||
# not reach (resources, manifest merge, DI graph assembly) must not hide behind them.
|
||||
- name: Debug APK builds
|
||||
run: ./gradlew :app:assembleDebug
|
||||
|
||||
# Coverage floor: >=80% line coverage on the four modules that apply the Kover plugin
|
||||
# (:wire-protocol, :session-core, :api-client, :client-tls). koverLog prints the actual
|
||||
# percentages into the run log so a near-miss is visible before it becomes a failure.
|
||||
# The Android-framework modules are NOT gated (no Kover on them) — a known gap.
|
||||
- name: Coverage gate (Kover, >= 80% on the four gated modules)
|
||||
run: ./gradlew koverVerify koverLog
|
||||
|
||||
# The instrumented sources cannot RUN here, but they must keep COMPILING. Three modules
|
||||
# carry them, 119 @Test in total: `:app` (67 — the device-blocker suite: key routing,
|
||||
# resize, alternate-screen scroll, autofill, driven through a custom HiltTestRunner),
|
||||
# `:host-registry` (31 — incl. TinkAccessTokenStoreTest, the only assertion anywhere that
|
||||
# the access token is ciphertext at rest) and `:client-tls-android` (21 — AndroidKeyStore
|
||||
# import). A silent compile break would delete all of that without anyone noticing.
|
||||
- name: Instrumented test sources still compile
|
||||
run: ./gradlew :app:assembleDebugAndroidTest :host-registry:assembleDebugAndroidTest :client-tls-android:assembleDebugAndroidTest
|
||||
|
||||
- name: Upload test + coverage reports
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-reports
|
||||
path: |
|
||||
android/*/build/reports/tests/**
|
||||
android/*/build/test-results/**
|
||||
android/*/build/reports/kover/**
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Layer 2 — instrumented (device) tests. DISPATCH-ONLY, on purpose.
|
||||
#
|
||||
# What is at stake: `TinkAccessTokenStoreTest` is the ONLY test anywhere that proves the
|
||||
# frozen at-rest properties of the access token (ios-completion §1.1) — that a fresh store
|
||||
# instance decrypts it after a cold start, and that the bytes on disk are ciphertext rather
|
||||
# than the token in the clear. Tink's AEAD + the AndroidKeystore-wrapped master key have no
|
||||
# JVM double, so this can only run on a device or emulator.
|
||||
#
|
||||
# The suite itself is NOT unproven: the android-blocker-fixes session ran `:app`'s 67
|
||||
# instrumented tests green on real hardware. What has never been proven is this leg — the
|
||||
# CI *emulator* path. Those are different claims and only the second one gates this `if:`.
|
||||
#
|
||||
# Why it is not on push: this workflow has never executed on any runner (see the honesty
|
||||
# note at the top), and an emulator leg is the single most fragile thing in Android CI —
|
||||
# it needs KVM, a system image download, and an AVD boot. Wiring an unvalidated, ~15-minute,
|
||||
# KVM-dependent job into every push would either block the repo on a leg nobody has seen
|
||||
# pass, or push people to make it non-blocking — and a leg that cannot fail is a false green
|
||||
# (the same trap ios.yml's `ios17-floor-tests` comment calls out).
|
||||
#
|
||||
# THEREFORE: run it by hand from the Actions tab (workflow_dispatch) whenever the token
|
||||
# store, the DataStore stores, the AndroidKeyStore importer, or the terminal view/input
|
||||
# path change. Once it has been seen green on a real runner, promote it by deleting the
|
||||
# `if:` below — the suite has already earned it on hardware.
|
||||
#
|
||||
# MANUAL EQUIVALENT (the path that has actually been walked — DEVICE_QA_CHECKLIST.md):
|
||||
# with a device/emulator attached,
|
||||
# cd android && ANDROID_HOME=<sdk> ./gradlew :app:connectedDebugAndroidTest \
|
||||
# :host-registry:connectedDebugAndroidTest \
|
||||
# :client-tls-android:connectedDebugAndroidTest
|
||||
# (StrongBox falls back to software keys on an emulator, so hardware-backed key claims still
|
||||
# need real hardware.)
|
||||
instrumented-tests:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
cache: gradle
|
||||
- name: Enable KVM for the emulator
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
|
||||
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
# API 29 == the app's minSdk: the deployment floor is where at-rest crypto behaviour is
|
||||
# most likely to differ (mirrors ios.yml's iOS 17 floor leg).
|
||||
- name: connectedDebugAndroidTest on an API 29 emulator
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
api-level: 29
|
||||
target: google_apis
|
||||
arch: x86_64
|
||||
working-directory: android
|
||||
script: ./gradlew :app:connectedDebugAndroidTest :host-registry:connectedDebugAndroidTest :client-tls-android:connectedDebugAndroidTest
|
||||
- name: Upload instrumented reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-instrumented-reports
|
||||
path: android/*/build/reports/androidTests/**
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
283
.github/workflows/ios.yml
vendored
Normal file
283
.github/workflows/ios.yml
vendored
Normal file
@@ -0,0 +1,283 @@
|
||||
# iOS CI (T-iOS-16; hardens the T-iOS-1 skeleton). Three layers, PLAN_IOS_CLIENT §9:
|
||||
#
|
||||
# 1. package-tests — per-package `swift test` + OWN-SOURCES coverage gate
|
||||
# (>= 80%, plan §9). NOTE: the plan §9 raw llvm-cov command reads export
|
||||
# TOTALS, which count statically-linked DEPENDENCY sources compiled into
|
||||
# the test binary (a known flaw, fix assigned to T-iOS-16). The corrected
|
||||
# per-package filter lives in ios/IntegrationTests/scripts/coverage-gate.sh
|
||||
# (jq keeps only Packages/<P>/Sources/, excluding *Placeholder*).
|
||||
# 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle) on
|
||||
# an iPhone AND an iPad simulator: ViewModels/components of the app glue
|
||||
# layer, on both the compact and the regular size class.
|
||||
# 3. integration-tests — Swift Testing against the REAL Node server. The
|
||||
# ServerHarness self-bootstraps `tsx src/server.ts` on an ephemeral
|
||||
# loopback port (127.0.0.1:<free-port> is always in the derived Origin
|
||||
# whitelist, src/config.ts:187-226 — no ALLOWED_ORIGINS needed). This layer
|
||||
# is the standing anti-drift gate between the Swift-replicated protocol
|
||||
# and the server implementation — hence the src/** path triggers below.
|
||||
name: ios
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "ios/**"
|
||||
- ".github/workflows/ios.yml"
|
||||
# integration layer guards client<->server protocol drift:
|
||||
- "src/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
pull_request:
|
||||
paths:
|
||||
- "ios/**"
|
||||
- ".github/workflows/ios.yml"
|
||||
- "src/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
|
||||
jobs:
|
||||
# Layer 1: pure-SwiftPM package tests + the 80% own-sources coverage gate.
|
||||
# The gate covers the 4 packages of plan §9 PLUS ClientTLS (added by B4 — the
|
||||
# mTLS identity/keychain package, previously the only ungated one at 55.76%).
|
||||
# TestSupport runs tests below without a gate (test doubles are excluded).
|
||||
package-tests:
|
||||
runs-on: macos-15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
package: [WireProtocol, SessionCore, HostRegistry, APIClient, ClientTLS]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Select Xcode 16.3
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
||||
- name: swift test + own-sources coverage gate (>= 80%)
|
||||
run: ios/IntegrationTests/scripts/coverage-gate.sh ${{ matrix.package }}
|
||||
|
||||
testsupport-tests:
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Select Xcode 16.3
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
||||
- name: swift test (TestSupport — no coverage gate; it IS the test doubles)
|
||||
run: swift test --package-path ios/Packages/TestSupport
|
||||
|
||||
# Layer 2: app-target unit tests (WebTermTests, hosted by the app), on the
|
||||
# compact iPhone path AND the regular iPad path (T-iPad-1: the adaptive
|
||||
# NavigationSplitView layout of T-iPad-2 must be exercised in CI, not only
|
||||
# compact). One matrix instead of two near-identical jobs.
|
||||
#
|
||||
# THE NODE DEPENDENCY (B4 fix): the WebTermTests bundle contains
|
||||
# LiveServerSmokeTests, whose SimServerHarness spawns
|
||||
# `node_modules/.bin/tsx src/server.ts`. On a bare checkout there is no
|
||||
# node_modules, so the harness throws
|
||||
# setup: 找不到 …/node_modules/.bin/tsx — 先在 repo 根目录跑 npm install/npm ci
|
||||
# and the test HARD-FAILS (it is not a skip) — i.e. both legs were red on every
|
||||
# run. Fix: `npm ci` on the canonical iPhone leg, which is the one that owns the
|
||||
# live-server smoke; the iPad leg SKIPS that suite instead of paying a second
|
||||
# node-pty native build. Rationale: the iPad leg exists to exercise the app's
|
||||
# regular-width layout, not to re-verify the client↔server contract, which is
|
||||
# already covered three times over (this leg, integration-tests, ui-test).
|
||||
# `-skip-testing` (rather than a test plan) keeps the change inside this file:
|
||||
# test plans live in ios/project.yml, owned by another task.
|
||||
app-tests:
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- leg: iphone
|
||||
device: iPhone 16
|
||||
needsNode: true
|
||||
extraArgs: ""
|
||||
- leg: ipad
|
||||
device: iPad Pro 11-inch (M4)
|
||||
needsNode: false
|
||||
extraArgs: "-skip-testing:WebTermTests/LiveServerSmokeTests"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Select Xcode 16.3
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
||||
- uses: actions/setup-node@v4
|
||||
if: matrix.needsNode
|
||||
with:
|
||||
node-version: 22
|
||||
- name: npm ci (LiveServerSmokeTests spawns node_modules/.bin/tsx)
|
||||
if: matrix.needsNode
|
||||
run: npm ci
|
||||
- name: Install XcodeGen
|
||||
run: brew install xcodegen
|
||||
- name: Generate project
|
||||
run: cd ios && xcodegen generate
|
||||
- name: xcodebuild test (WebTermTests, ${{ matrix.device }} simulator)
|
||||
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests exercises the
|
||||
# real data-protection keychain, which returns -34018 for UNSIGNED test
|
||||
# hosts; simulator ad-hoc signing needs no certificates. (W5-fix
|
||||
# handoff finding, verified locally in the ui-test leg runs.)
|
||||
run: |
|
||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
||||
-destination 'platform=iOS Simulator,name=${{ matrix.device }}' \
|
||||
${{ matrix.extraArgs }} \
|
||||
test
|
||||
|
||||
# Layer 3: contract tests against the real Node server (T-iOS-16 test list).
|
||||
# npm ci compiles node-pty (needs the Xcode toolchain — present on the
|
||||
# runner); the Swift ServerHarness then boots the server itself.
|
||||
integration-tests:
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Select Xcode 16.3
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: npm ci (builds node-pty native module)
|
||||
run: npm ci
|
||||
- name: swift test vs real server (ServerHarness boots tsx src/server.ts)
|
||||
run: swift test --package-path ios/IntegrationTests
|
||||
|
||||
# Layer 4 (W5, plan §9): the ONE scripted XCUITest happy path — 配对(手输) →
|
||||
# 列表 → attach → 输入 → gate approve — against a live loopback server. The
|
||||
# runner process reads WEBTERM_SERVER_URL (delivered via xcodebuild's
|
||||
# TEST_RUNNER_ env prefix) and makes its own HTTP assertions against the
|
||||
# server (/live-sessions, /live-sessions/:id/preview, held /hook/permission).
|
||||
#
|
||||
# iPad leg (B4 fix): ProjectsLayoutUITests has an explicit `isPad` branch
|
||||
# (landscape + NavigationSplitView sidebar reveal, T-iPad-4) that NEVER ran —
|
||||
# the only UI leg was iPhone. It now runs on an iPad simulator too. Only THAT
|
||||
# suite: HappyPathUITests has no regular-width branch at all (no sidebar
|
||||
# reveal), so running it on iPad would fail for a missing-feature reason rather
|
||||
# than a regression — making the happy path iPad-adaptive is app-layer work,
|
||||
# tracked separately.
|
||||
ui-test:
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- leg: iphone
|
||||
device: iPhone 16
|
||||
suites: ""
|
||||
- leg: ipad
|
||||
device: iPad Pro 11-inch (M4)
|
||||
suites: "-only-testing:WebTermUITests/ProjectsLayoutUITests"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Select Xcode 16.3
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: npm ci (builds node-pty native module)
|
||||
run: npm ci
|
||||
- name: Start web-terminal server on loopback
|
||||
run: |
|
||||
PORT=3217 BIND_HOST=127.0.0.1 SHELL_PATH=/bin/bash USE_TMUX=0 \
|
||||
nohup node_modules/.bin/tsx src/server.ts > uitest-server.log 2>&1 &
|
||||
echo $! > uitest-server.pid
|
||||
ready=0
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -sf http://127.0.0.1:3217/live-sessions > /dev/null; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$ready" -ne 1 ]; then
|
||||
echo "server did not come up on 127.0.0.1:3217" >&2
|
||||
cat uitest-server.log >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Install XcodeGen
|
||||
run: brew install xcodegen
|
||||
- name: Generate project
|
||||
run: cd ios && xcodegen generate
|
||||
# The 输入 step taps the KeyBar, which is the terminal's
|
||||
# inputAccessoryView — it only exists while the SOFT keyboard is up.
|
||||
- name: Disable simulator hardware keyboard (KeyBar rides the soft keyboard)
|
||||
run: defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false
|
||||
- name: xcodebuild test (WebTermUITests, ${{ matrix.device }} simulator)
|
||||
# TEST_RUNNER_<VAR> must be an ENV VAR of the xcodebuild process (it
|
||||
# strips the prefix and injects <VAR> into the test-runner process);
|
||||
# passing it as a KEY=VALUE argument makes it a build setting, which
|
||||
# never reaches the runner (verified empirically, 2026-07-05).
|
||||
# NO CODE_SIGNING_ALLOWED=NO here: the app must be (ad-hoc) signed or
|
||||
# the data-protection keychain rejects every SecItem call (-34018) and
|
||||
# pairing dies at "保存到本机失败" (verified empirically, run 6).
|
||||
# Simulator builds sign locally without any certificate.
|
||||
env:
|
||||
TEST_RUNNER_WEBTERM_SERVER_URL: http://127.0.0.1:3217
|
||||
run: |
|
||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTermUITests \
|
||||
-destination 'platform=iOS Simulator,name=${{ matrix.device }}' \
|
||||
${{ matrix.suites }} test
|
||||
- name: Server log + shutdown
|
||||
if: always()
|
||||
run: |
|
||||
cat uitest-server.log || true
|
||||
kill "$(cat uitest-server.pid)" 2>/dev/null || true
|
||||
|
||||
# Device-matrix floor (plan §9: "iOS 17 最低目标模拟器各一轮"): run the
|
||||
# WebTermTests unit bundle on an iOS 17.x simulator runtime.
|
||||
#
|
||||
# CI-ONLY LEG: local machines are NOT expected to hold the ~7 GB iOS 17
|
||||
# runtime, so this leg is never reproducible on a dev box.
|
||||
#
|
||||
# NO SILENT SKIP (B4 fix): this step used to `exit 0` with a ::notice when the
|
||||
# runner image had no iOS 17.x runtime, and the test step was gated on
|
||||
# `if: steps.sim17.outputs.runtime != ''` — so on image drift the whole
|
||||
# deployment-floor round vanished and the job still reported GREEN. A leg that
|
||||
# can silently stop testing is worse than no leg. Now: if the runtime is
|
||||
# missing, it is DOWNLOADED (`xcodebuild -downloadPlatform iOS
|
||||
# -buildVersion`); if the download does not produce a usable runtime, the job
|
||||
# FAILS. The test step is unconditional.
|
||||
ios17-floor-tests:
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 90 # the runtime download alone can take ~15 min
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Select Xcode 16.3 (build toolchain)
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
||||
- name: Install XcodeGen
|
||||
run: brew install xcodegen
|
||||
- name: Generate project
|
||||
run: cd ios && xcodegen generate
|
||||
- name: Ensure an iOS 17.x simulator runtime (download if the image lacks it)
|
||||
id: sim17
|
||||
env:
|
||||
IOS17_FALLBACK_VERSION: "17.5"
|
||||
run: |
|
||||
find_runtime() {
|
||||
xcrun simctl list runtimes \
|
||||
| grep -Eo 'com\.apple\.CoreSimulator\.SimRuntime\.iOS-17-[0-9]+' \
|
||||
| tail -n 1
|
||||
}
|
||||
runtime="$(find_runtime || true)"
|
||||
if [ -z "$runtime" ]; then
|
||||
echo "::warning title=iOS 17 runtime absent::downloading iOS ${IOS17_FALLBACK_VERSION} simulator runtime (runner image drift)"
|
||||
sudo xcodebuild -downloadPlatform iOS -buildVersion "$IOS17_FALLBACK_VERSION" || true
|
||||
runtime="$(find_runtime || true)"
|
||||
fi
|
||||
if [ -z "$runtime" ]; then
|
||||
echo "::error title=iOS 17 floor leg cannot run::no iOS 17.x simulator runtime and the download failed — the deployment-floor round did NOT run, so this job fails instead of reporting a false green" >&2
|
||||
xcrun simctl list runtimes >&2
|
||||
exit 1
|
||||
fi
|
||||
udid="$(xcrun simctl create 'iPhone 15 iOS17' 'iPhone 15' "$runtime")"
|
||||
echo "created $udid with $runtime"
|
||||
echo "udid=$udid" >> "$GITHUB_OUTPUT"
|
||||
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests needs a signed
|
||||
# (ad-hoc, certificate-free on simulator) app host — unsigned = -34018.
|
||||
#
|
||||
# -skip-testing LiveServerSmokeTests: same reason as the iPad leg — that
|
||||
# suite spawns node_modules/.bin/tsx and would hard-fail without `npm ci`.
|
||||
# This leg's job is the DEPLOYMENT FLOOR (does the app build and behave on
|
||||
# iOS 17), not the server contract, so it stays Node-free.
|
||||
- name: xcodebuild test (WebTermTests on the iOS 17.x floor)
|
||||
run: |
|
||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
||||
-destination "platform=iOS Simulator,id=${{ steps.sim17.outputs.udid }}" \
|
||||
-skip-testing:WebTermTests/LiveServerSmokeTests \
|
||||
test
|
||||
30
.github/workflows/relay-tripwire.yml
vendored
Normal file
30
.github/workflows/relay-tripwire.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# PERMANENT cross-tenant isolation tripwire (INV1) — see docs/PLAN_RELAY_AUTH_ISOLATION.md T13.
|
||||
#
|
||||
# This job MUST be a REQUIRED status check. A green build is impossible if cross-tenant isolation
|
||||
# regresses (device A reaching host B). DO NOT delete, skip, or make this non-required. If it ever
|
||||
# goes red, BLOCK the merge (CRITICAL per code-review.md).
|
||||
name: relay-tripwire
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
cross-tenant-tripwire:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: relay-auth
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: Install relay-auth (+ local relay-contracts)
|
||||
run: npm install
|
||||
- name: Strict typecheck
|
||||
run: npx tsc --noEmit
|
||||
- name: THE cross-tenant tripwire (A -> B must be 403)
|
||||
run: npx vitest run tripwire/cross-tenant
|
||||
- name: Full relay-auth suite
|
||||
run: npx vitest run
|
||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -3,11 +3,23 @@ node_modules/
|
||||
|
||||
# build output
|
||||
dist/
|
||||
# EXCEPTION: `agent/src/dist/` holds SOURCE (the packaging config for the distributable binary),
|
||||
# not build output. The blanket `dist/` above swallowed it, so it was never committed — a fresh
|
||||
# clone was missing it and could neither typecheck `agent/src/index.ts` nor import it from the
|
||||
# committed `agent/test/buildBinary.test.ts`. The directory must be re-included FIRST: git does not
|
||||
# descend into an excluded directory, so un-ignoring only the file inside it would not work.
|
||||
!agent/src/dist/
|
||||
!agent/src/dist/**
|
||||
public/build/
|
||||
desktop/build/
|
||||
desktop/dist-app/
|
||||
|
||||
# local Claude Code settings (not shared)
|
||||
.claude/settings.local.json
|
||||
|
||||
# per-session git worktrees (EnterWorktree) — live on disk, never committed
|
||||
.claude/worktrees/
|
||||
|
||||
# logs / OS cruft
|
||||
*.log
|
||||
npm-debug.log*
|
||||
@@ -16,3 +28,6 @@ npm-debug.log*
|
||||
# test coverage
|
||||
coverage/
|
||||
.gstack/
|
||||
|
||||
# deploy secrets (RELAY-PHASE1) — .env.example is committed, .env is not
|
||||
deploy/.env
|
||||
|
||||
25
CLAUDE.md
25
CLAUDE.md
@@ -16,6 +16,26 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
**Language decision: TypeScript (`.ts`), not `.js`** — ARCHITECTURE §0 records this divergence from TECH_DOC's original `.js` filenames. Wherever the two docs conflict, ARCHITECTURE wins on *how* (it was cross-validated and corrected); TECH_DOC wins on *why/scope*.
|
||||
|
||||
## Session Workflow: One Worktree per Session (MANDATORY)
|
||||
|
||||
**Every session that changes files works in its own git worktree, and merges back to `develop` when the work is done.** Don't develop directly on `develop` in the main checkout (the one exception is a change to this workflow itself — the rule can't bootstrap inside its own worktree).
|
||||
|
||||
1. **Start of session** — before touching any file, call the `EnterWorktree` tool with a task-descriptive name (e.g. `EnterWorktree({name: "fix-cjk-locale"})`). It creates `.claude/worktrees/<name>/` on branch **`worktree-<name>`** (the tool prefixes it — the name you pass is *not* the branch name) and moves the session's cwd into it. Do all work there.
|
||||
- Base ref is `head` (configured in `.claude/settings.json` → `worktree.baseRef`), so the worktree branches from the **current `develop` HEAD**, not `origin/main`. This matters: `develop` runs ~75 commits ahead of `origin/main`, so the default `fresh` base ref would silently produce a badly stale worktree. `develop` is the working trunk; `main` is the release branch.
|
||||
- It branches from the last **commit**, so uncommitted edits sitting in the main checkout do **not** carry over. Commit or stash them first if the task needs them.
|
||||
- Read-only sessions (answering a question, inspecting a remote host) don't need a worktree — only create one when files will change.
|
||||
2. **During the session** — commit inside the worktree as normal (conventional-commit format, see the global git-workflow rule). Tests/`tsc` run against the worktree copy, so concurrent sessions never collide on the working tree; each holds a `locked` worktree of its own, so never `git worktree remove` a directory this session didn't create.
|
||||
3. **End of session — merge back.** Commit everything in the worktree first, then, **in this order**:
|
||||
```bash
|
||||
# 1. ExitWorktree({action: "keep"}) → cwd returns to the main checkout, branch survives
|
||||
git merge --no-ff worktree-<name> # 2. from the main checkout, on develop
|
||||
git worktree remove .claude/worktrees/<name> && git branch -d worktree-<name> # 3. clean up
|
||||
```
|
||||
**Order matters:** `ExitWorktree({action: "remove"})` deletes the branch along with the directory, so calling it before the merge throws the work away. (It does refuse when commits aren't yet on `develop` — a safety net, not a plan.) `keep` is also the right call whenever the work is unfinished and the session should be resumable.
|
||||
4. **Do not merge to `main`** as part of this flow — `main` is promoted from `develop` separately.
|
||||
|
||||
Subagents dispatched with `isolation: worktree` (PLAN §4) get their own throwaway worktrees on top of this — that is a separate, nested mechanism and does not replace the session-level worktree.
|
||||
|
||||
## Development Workflow: Plan & Progress Log (MANDATORY)
|
||||
|
||||
Work proceeds against a **phased plan** and is tracked in a **progress log that acts as cross-session memory**. A new Claude instance must be able to read the log and know exactly where things stand. Follow these rules:
|
||||
@@ -65,6 +85,8 @@ npm test # unit tests (vitest, all modules)
|
||||
|
||||
Config is via env vars only (no hardcoding): `PORT`, `SHELL_PATH`, `BIND_HOST`, `IDLE_TTL`, `SCROLLBACK_BYTES`, `MAX_PAYLOAD_BYTES`, `USE_TMUX` (1/0/auto), `ALLOWED_ORIGINS`. Note `allowedOrigins` is derived from the host's network-interface IPs (not from `BIND_HOST` — `0.0.0.0` is never a valid Origin); see ARCHITECTURE §3.1.
|
||||
|
||||
`WEBTERM_TOKEN` (w5-access-token, optional) — a shared access token that gates the WS handshake (alongside, not replacing, the Origin check) and every remote HTTP route. **Unset ⇒ auth disabled**, so LAN zero-config is preserved exactly as before; only when set does the gate activate. When set it must be 16–512 URL/cookie-safe chars (`[A-Za-z0-9._~+/=-]`) or the server refuses to start. Deliver it once via `GET /?token=<t>` (or `POST /auth`), which sets an `HttpOnly; SameSite=Strict; Secure-when-https` cookie the browser auto-sends thereafter; loopback hook ingest (`/hook*`) is exempt so the smart-features side-channel keeps working. **Honest tradeoff:** it is a bar-raiser, **not** a TLS/Tailscale substitute — on bare `ws://` the token travels in cleartext and is replayable by a LAN sniffer; it only meaningfully hardens the relay/tunnel (TLS-terminated) path. Never port-forward the raw port to the internet. See `src/http/auth.ts` and `docs/plans/w5-access-token.md`.
|
||||
|
||||
## Architecture (the parts that span files)
|
||||
|
||||
The server is a **byte-shuttle, not a terminal**. It does not parse ANSI/terminal semantics — xterm.js (browser) interprets escape sequences and renders; node-pty (server) provides the pseudo-terminal so the shell believes it has a real TTY. This separation is the central simplification — keep it. Don't add terminal-semantic parsing on the server.
|
||||
@@ -75,10 +97,11 @@ Data flow: `keypress → xterm onData → WS → pty.write() → shell stdin`, a
|
||||
|
||||
**PTY lifecycle ≠ WebSocket lifecycle.** A WS disconnect must NOT kill the PTY — the Claude Code task running inside has to keep going. Sessions are keyed by `sessionId` (client stores it in localStorage):
|
||||
|
||||
- **Home = a session chooser (v0.5), not an auto-tab.** Opening the app does NOT auto-create a blank session or auto-restore/discover tabs. It lands on the **launcher** (`public/launcher.ts`): a grid of the host's running sessions as live preview thumbnails — the user picks one to open (full scrollback replay) or `+ New session`. Closing the last tab returns to the chooser.
|
||||
- `attach(null)` → spawn a new PTY + shell; PTY output goes into a ~2MB **ring buffer** and (if a WS is attached) forwards live.
|
||||
- `attach(sessionId)` → look up the session, **replay the ring buffer**, then resume the live stream. This is what makes "refresh the page and the Claude session is still there" work.
|
||||
- WS close → `detach` one client (PTY keeps running), not kill; the idle clock starts only when the **last** client leaves.
|
||||
- **Multi-device sharing (v0.4):** a session may have **many concurrent WS clients** — a new attach **JOINS (mirror)**, it does **not** kick. Output/exit/status broadcast to all; any client can type (shared control); the PTY size is the **min cols/rows across clients** (tmux-style). This relaxes the original "one WS per session" invariant (#5). `GET /live-sessions` lists running sessions so any device **auto-shows them as tabs**; `?join=<id>` and the 🔗 share-QR open a specific shared session.
|
||||
- **Multi-device sharing (v0.4):** a session may have **many concurrent WS clients** — a new attach **JOINS (mirror)**, it does **not** kick. Output/exit/status broadcast to all; any client can type (shared control). **PTY sizing = latest-writer-wins:** the device that most recently fit/focused drives the size, so whichever device you're actively using is full-screen (a shared PTY can only be one size; min-sizing letterboxed the bigger screen). Attach/detach never resize — the active device keeps its size; the frontend re-sends dims on pane-show and window-focus so switching devices reclaims full-screen. (The earlier `blur` size-vote message was removed in the min→latest-writer pivot.) This relaxes the original "one WS per session" invariant (#5). `GET /live-sessions` lists running sessions so the **v0.5 home launcher (session chooser)** shows them as live preview thumbnails to pick from; `?join=<id>` and the 🔗 share-QR open a specific shared session; the **🗂 manage page** (`/manage.html`) is a full-page **grid of live preview thumbnails** — each card renders the session's current screen via a read-only xterm (fed by `GET /live-sessions/:id/preview` → `RingBuffer.tail()`, no attach) so you can see what each session is doing, with open/kill (`DELETE /live-sessions[/:id]`).
|
||||
- Orphan reclaim: detached longer than `IDLE_TTL` (default 24h) **with no new output since detach** → reclaim. (node-pty can't reliably detect a foreground child, so liveness is approximated via last-output time — see ARCHITECTURE §3.5 / M3.) On server exit, `pty.kill()` all sessions (no cross-restart persistence in v0.1; tmux backend is a v0.2 idea).
|
||||
|
||||
Represent session state as immutable snapshots (id/start-time fixed at creation); hold mutable runtime handles (pty/ws) separately.
|
||||
|
||||
269
README.md
269
README.md
@@ -1,103 +1,232 @@
|
||||
# Web Terminal
|
||||
|
||||
A browser-based terminal that exposes the **host machine's local shell** over WebSocket. Open `http://<host-ip>:3000` from any device on your LAN (phone, tablet, another computer) and you get a live, interactive terminal — primarily for **vibe coding**: hand Claude Code a task, walk away, reconnect from anywhere to check on it.
|
||||
A self-hosted, browser-based terminal **and** Claude-Code session/project workbench. It exposes the **host machine's local shell** over WebSocket: open `http://<host-ip>:3000` from any device on your LAN (phone, tablet, another laptop) and you get a live, interactive shell. The core use case is **vibe coding** — hand Claude Code a task, walk away, then reconnect from anywhere to check on it, approve a tool call from your phone's lock screen, or kick off the next one.
|
||||
|
||||
Sessions survive disconnects: the shell (and whatever's running in it) keeps going when you close the tab; reconnect and the output replays.
|
||||
Sessions survive disconnects: the shell (and whatever's running in it) keeps going when you close the tab; reconnect and the scrollback replays. The server is a **byte-shuttle** — it ferries raw bytes between the shell and the browser and never parses terminal/ANSI semantics; xterm.js renders, node-pty provides the TTY.
|
||||
|
||||
> ⚠️ **This hands a full shell to anyone who can reach the port.** LAN-only, no authentication. **Never** port-forward or tunnel it to the public internet. See [Security](#security).
|
||||
> ⚠️ **This hands a full shell to anyone who can reach the port.** LAN-only by design; the optional `WEBTERM_TOKEN` access token raises the bar but is **not** a substitute for TLS/Tailscale. **Never** port-forward or tunnel it to the public internet. See [Security & deployment](#security--deployment).
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-tab** — each tab is an independent shell session. Tabs auto-name to the current folder (double-click to rename), show a connection dot (🟢/🟡/🔴) and an unread-output dot, and can be drag-reordered. `+` opens a new tab in the active tab's directory.
|
||||
- **Claude Code cockpit** (with hooks, see below) — each tab shows Claude's status (⚙ working / ⏳ needs approval / ✓ idle), sends a browser notification when it needs you, and lets you **tap Approve / Reject** on a tool request with no typing.
|
||||
- **tmux keepalive** (optional) — run the shell inside tmux so sessions survive a server/host restart, not just a disconnect.
|
||||
- **Mobile + desktop shortcut bar** — one-tap Esc / Esc·Esc / ⇧Tab / arrows / Enter / ^C / ^O / ^T / ^B / Tab / `/` (the Claude Code keys a phone keyboard can't produce).
|
||||
- **Session keepalive + replay** — the PTY keeps running across disconnects; reconnect replays a ~2 MB scrollback ring buffer.
|
||||
- **Toolbar** — 🔍 scrollback search · ⚙ themes & font size · ▦ all-sessions dashboard · 📱 QR connect (scan to open on another device). Clickable links. Installable as a PWA.
|
||||
### Core terminal
|
||||
- **WebSocket terminal** — xterm.js in the browser, node-pty on the host. Keystrokes shuttle to the shell; output streams back. No terminal parsing on the server.
|
||||
- **Sessions survive disconnects** — the PTY lifecycle is decoupled from the WebSocket. Closing a tab *detaches* a client; the shell keeps running. Reconnect replays a ~2 MB scrollback ring buffer.
|
||||
- **Auto-reconnect** — exponential backoff (1s/2s/4s… capped at 30s), carrying the `localStorage` session id, so a flaky network or a phone waking from sleep just resumes.
|
||||
- **Mobile touch key-bar** — one-tap Esc / Esc·Esc / ⇧Tab / arrows / Enter / ^C / ^O / ^T / ^B / ^R / ^L / ^D / Tab / `/` — the high-frequency Claude Code keys a soft keyboard can't produce, sent as raw bytes (no soft-keyboard pop).
|
||||
- **Multi-device session mirroring** — many devices can attach to the *same* session at once. Output, exit and status broadcast to all; any device can type (shared control). PTY sizing is **latest-writer-wins**: whichever device you're actively using drives the size and stays full-screen.
|
||||
|
||||
## Requirements
|
||||
### Tabs & home
|
||||
- **Multi-tab** — each tab is an independent shell. Tabs auto-name to the current folder (double-click to rename), show a connection dot and an unread-output dot, and can be drag-reordered. `+` opens a new tab in the active tab's directory.
|
||||
- **Home Sessions chooser** — opening the app lands on a chooser, not a blank shell. It shows the host's running sessions as **live preview thumbnails** (read-only renders of each screen) so you can see what each one is doing, plus a dashed **+ New session** tile. Pick one to open (full scrollback replay) or start fresh.
|
||||
- **Per-session Open / Kill** — manage sessions right from the chooser; no separate manage page.
|
||||
- **Sessions ↔ Projects toggle** — a segmented control on the home screen flips between the running-sessions view and the projects view.
|
||||
- **⌂ Home overlay** — a Home button in the tab bar overlays the chooser on top of the current terminal so you can start another session/project without closing your tabs.
|
||||
|
||||
- Node.js ≥ 18 (developed on v24)
|
||||
- macOS/Linux. On macOS, `node-pty` compiles a native addon — install **Xcode Command Line Tools** (`xcode-select --install`) if `npm install` fails.
|
||||
### Split-grid watch board (v0.8, desktop)
|
||||
On a large screen (≥ 1024px) the terminal area can split into a grid so several **live, interactive** sessions show at once — built for watching multiple Claude Code runs in parallel. Desktop-only (a 2×2 of terminals is unusable on a phone); the server and wire protocol are untouched, and single-pane mode is unchanged.
|
||||
- **Layouts** — a toolbar toggle cycles **single / 1×2 / 1×3 / 2×2 / 2×3**. The board shows the first N tabs (drag-reorder the tab bar, or drag a tab straight onto a quadrant, to choose which); a dashed **+ New session** tile fills any empty slot. The choice persists.
|
||||
- **Click-to-focus** — the quadrant you click wears a focus ring and owns the keyboard, mobile key-bar, voice, and the approval bar; **Ctrl+`** cycles focus (⇧ reverses). Only the focused pane takes keyboard focus, so panes don't fight over it.
|
||||
- **Inline approve per quadrant** — a background quadrant waiting on a tool permission glows amber and shows its own **✓ / ✗** buttons, so you can clear approvals across several sessions without switching; its OS notification is suppressed while it's on screen.
|
||||
- **Maximize (⛶) / monitor (👁) per quadrant** — ⛶ expands one quadrant to fill the grid (the others stay live behind it); 👁 flips a quadrant to a **read-only preview** (polled screen snapshots — no WebSocket attach, no resize) so watching a session in a small quadrant never shrinks it for another device using it full-screen.
|
||||
- **Resizable splitters + saved presets** — drag the gutters between panes to re-balance column/row sizes (persisted per layout), and save a layout + its split as a named preset to re-apply in one click.
|
||||
|
||||
## Install
|
||||
### Claude Code cockpit
|
||||
- **Live per-tab status** — Claude Code hooks POST to the server (loopback side-channel); each tab badge shows **working / waiting-for-approval / idle / stuck** in real time. Install once with `npm run setup-hooks`.
|
||||
- **Remote approve / reject** — when Claude asks for tool permission, the request is *held* server-side and an **Approve / Reject** bar appears on every attached device — resolve it with a tap, no typing. Works across multiple devices (closing one mirror doesn't cancel the prompt for the others).
|
||||
- **Plan-mode / permission-mode relay** — start a session in a chosen `--permission-mode` (default / acceptEdits / plan / auto). When Claude exits plan mode, the approval bar becomes a **three-way** gate (approve+auto / approve+review / keep planning). The high-risk `auto` mode is gated behind `ALLOW_AUTO_MODE`.
|
||||
- **tmux keepalive** — run the shell inside tmux so sessions survive a **server/host restart**, not just a disconnect. Auto-detected, or forced with `USE_TMUX`.
|
||||
- **Session history / resume** — browse past Claude Code sessions (from `~/.claude/projects`) and resume one.
|
||||
|
||||
### Projects (v0.6)
|
||||
- **Auto-discovered git repos** — scans configurable roots for `.git`, showing each repo's branch and dirty state. Read-only, cached, with a depth-bounded BFS that skips `node_modules`/dotdirs/symlinks.
|
||||
- **Per-project launchers** — each card has brand-logo buttons: **Claude** and **Codex** open a new tab running that CLI in the repo; **VS Code** asks the *host* to open the editor on that path. Projects with an active Claude session highlight the Claude button (with a count).
|
||||
- **Project detail page** — branch + a **git worktrees** list, the project's **active sessions** (open/kill), a **CLAUDE.md viewer** with a **Generate / Update (`/init`)** button, a **read-only git diff viewer**, and worktree **create / prune / remove**.
|
||||
- **Git panel** — ambient git state on the detail page: a **sync band** (`↑ahead ↓behind`, upstream / detached-HEAD / "never fetched" states, green "in sync" only when nothing is pending *and* the last fetch is recent), a **commit log** with the unpushed boundary marked, **PR status** (via the host's `gh`, when installed), and **stage / commit / push / fetch** actions. All writes are Origin-guarded `POST /projects/git/*` routes running `git` through `execFile` (no shell) with timeouts.
|
||||
|
||||
### Walk-away workbench (v0.7)
|
||||
- **Mobile Web Push + lock-screen triage** — the host actively notifies your phone on **needs-input** (high priority, with **Allow / Deny** action buttons) and **done** (low priority). Approve or deny a held tool request straight from the lock screen without opening the app, secured by a per-decision capability token. Optional **ntfy / Pushover** bridge for setups without HTTPS Web Push.
|
||||
- **Voice dictation** — push-to-talk mic on the input bar (Web Speech API); release to send the transcript as input (optional auto-Enter). Hidden where unsupported; audio never touches the server.
|
||||
- **Quick-reply chips + saved-prompt palette** — tap-to-send chips (`yes`, `continue`, `1/2/3`, `Esc`) plus a persistent palette of your own named snippets.
|
||||
- **Activity timeline** — a human-readable, timestamped stream of what happened while you were gone ("ran Bash · edited 3 files · waiting for approval · done"), built from discrete hook events in a bounded per-session ring.
|
||||
- **Stuck / idle alert** — if a session goes silent past a threshold (no output, not idle, not exited) it fires a one-shot "possibly stuck" alert through the same notification channel. Runs on the existing reaper tick (no extra timer).
|
||||
- **statusLine telemetry → per-tab gauges** — a statusLine script feeds back context-usage, cumulative cost, model, lines +/−, PR state, and rate-limit (5h/7d) telemetry, rendered as **per-tab gauges** on tabs, thumbnails and project cards. Stale telemetry greys out.
|
||||
|
||||
### UX
|
||||
- Themed UI (Amber dark theme + light / solarized, adjustable font size), scrollback **search**, an all-sessions **dashboard**, **share-session QR/link** (`?join=<id>`), **connect-device QR**, a keyboard-shortcut **cheat-sheet**, an installable **PWA**, clickable links, and a line-icon toolbar with hover tooltips.
|
||||
|
||||
---
|
||||
|
||||
## Clients
|
||||
|
||||
The browser is the reference client; three native clients and a remote-access
|
||||
service are also in the repo (all consume the same wire protocol — the server
|
||||
stays a byte-shuttle).
|
||||
|
||||
- **iOS / iPad app** ([`ios/`](ios/)) — a native SwiftUI + SwiftTerm **pure
|
||||
remote client** built for the walk-away loop: native terminal with scrollback
|
||||
replay, QR/manual pairing (Keychain), the remote **Approve / Reject** gate +
|
||||
three-way plan gate, **APNs push with lock-screen Allow / Deny behind Face ID**,
|
||||
deep links (including the web's `?join=` share link), Projects with a **git
|
||||
panel + worktree lifecycle**, `claude --resume` history, multi-session switcher,
|
||||
timeline, diff, quick-reply, **terminal find bar**, **voice push-to-talk with a
|
||||
confirm step**, theme (system/dark/light) + Dynamic Type, and an **adaptive
|
||||
iPhone/iPad split layout**. Supports the optional `WEBTERM_TOKEN` access token
|
||||
(per-host, in the Keychain). Looks like the desktop (amber-gold, dark-first).
|
||||
P0 needed zero server changes; P1 added two declared additive touch-points.
|
||||
**Merged into `develop`.** See [`ios/README.md`](ios/README.md).
|
||||
- **Android app** ([`android/`](android/)) — a Kotlin/Compose client targeting
|
||||
functional parity with iOS, module-for-module mirroring the iOS package set
|
||||
(`:wire-protocol` / `:session-core` / `:api-client` / `:client-tls` /
|
||||
`:host-registry` / `:terminal-view` / `:app`). Same wire protocol, same
|
||||
Origin-iff-guarded discipline, same **`WEBTERM_TOKEN`** contract (hand-written
|
||||
cookie, Keystore-backed storage), plus FCM push with lock-screen Allow / Deny,
|
||||
Projects, timeline, diff, quick-reply and an adaptive layout. Terminal
|
||||
rendering wraps the Apache-2.0 **Termux** terminal view. All modules build and
|
||||
unit-test locally (`./gradlew test :app:assembleDebug koverVerify`) and the app
|
||||
has been installed and launched on an emulator; **real-device QA is still
|
||||
pending**. Design in
|
||||
[`docs/ANDROID_CLIENT_PLAN.md`](docs/ANDROID_CLIENT_PLAN.md), setup notes in
|
||||
[`android/README.md`](android/README.md).
|
||||
- **Desktop app** ([`desktop/`](desktop/)) — a Mac/Windows **Electron shell that
|
||||
embeds this Node server + node-pty** (all-in-one), for native notifications,
|
||||
tray, deep links and launch-at-login. Design in
|
||||
[`docs/DESKTOP_PLAN.md`](docs/DESKTOP_PLAN.md). *(Built; packaged & launch-
|
||||
verified on Apple Silicon.)*
|
||||
- **Rendezvous relay** (`term-relay/` · `agent/` · `relay-e2e/` · `relay-web/` ·
|
||||
`relay-auth/` · `relay-contracts/`) — an "ngrok-for-Claude-Code" with
|
||||
**end-to-end encryption** to reach a self-hosted host from anywhere, without a
|
||||
VPN or port-forwarding. Security-audited but **pre-production / not deployed**;
|
||||
see [`docs/PLAN_RELAY_INDEX.md`](docs/PLAN_RELAY_INDEX.md) and
|
||||
[`docs/DEPLOY_RELAY.md`](docs/DEPLOY_RELAY.md). For remote access **today**, use
|
||||
**Tailscale** (§ Security & deployment below).
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
### Prerequisites
|
||||
- **Node.js ≥ 18** (developed on v24).
|
||||
- **macOS / Linux.** On macOS, `node-pty` compiles a native addon — install **Xcode Command Line Tools** (`xcode-select --install`) if `npm install` fails. After a major Node version bump, run `npm rebuild`.
|
||||
|
||||
### Install & run
|
||||
```bash
|
||||
npm install # installs deps; postinstall makes node-pty's spawn-helper executable
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
npm run build:web # bundle the frontend (public/main.ts → public/build/main.js)
|
||||
npm install # installs deps (incl. node-pty native build); postinstall fixes the spawn-helper bit
|
||||
npm run build:web # bundle the frontend (public/main.ts → public/build/) with esbuild
|
||||
npm start # serve on 0.0.0.0:3000
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
Then find your LAN IP and open it from any device on the same network:
|
||||
```bash
|
||||
# find your LAN IP (macOS):
|
||||
ipconfig getifaddr en0
|
||||
# open http://<that-ip>:3000 on any device on the same network
|
||||
ipconfig getifaddr en0 # macOS
|
||||
# open http://<that-ip>:3000
|
||||
```
|
||||
|
||||
For frontend development, run `npm run dev:web` (esbuild --watch) alongside `npm start`.
|
||||
|
||||
## Claude Code cockpit (optional)
|
||||
|
||||
To see Claude's status per tab and approve/reject tool calls from your phone, install the hooks once:
|
||||
For frontend development, run `npm run dev:web` (esbuild `--watch`) alongside `npm start`.
|
||||
|
||||
### Enable the Claude Code cockpit (optional but recommended)
|
||||
```bash
|
||||
npm run setup-hooks # adds http hooks to ~/.claude/settings.json (backs it up)
|
||||
npm run setup-hooks # adds the hooks + statusLine to ~/.claude/settings.json (backs it up)
|
||||
# npm run setup-hooks -- --remove # to uninstall
|
||||
```
|
||||
This wires Claude Code's hooks → **live per-tab status**, the **statusLine gauges**, and **push** notifications. The hooks are a no-op outside web-terminal (they only fire when `$WEBTERM_*` env vars are set in spawned shells), so they're safe to leave installed. Then run `claude` inside a tab.
|
||||
|
||||
Then run `claude` inside a tab. The hooks POST to the server (loopback only) when Claude starts/stops/needs permission; the tab badge updates live, and a `PermissionRequest` shows an **Approve / Reject** bar. The hooks are a no-op outside web-terminal (they curl `$WEBTERM_HOOK_URL`, which is only set in spawned shells), so they're safe to leave installed.
|
||||
> **Login shells (why hooks can always find `node`)** — sessions spawn the shell as a **login shell** (`zsh -l`, POSIX only), so it loads your full profile (`~/.zprofile`, `~/.zshrc`, …) and rebuilds `PATH`. Without this, a GUI-launched app (desktop build) or a long-lived tmux keepalive can hand the shell a minimal `PATH`, and hooks that call an nvm-/brew-managed `node` fail with `node: command not found`. If you still hit that on an old session, start a fresh one so it picks up the login-shell `PATH`.
|
||||
|
||||
**tmux keepalive** — run with `USE_TMUX=1` (or just have `tmux` on PATH; it auto-detects) to keep sessions alive across a server restart:
|
||||
`USE_TMUX=1 npm start` keeps sessions alive across a server restart.
|
||||
|
||||
### Tests
|
||||
```bash
|
||||
USE_TMUX=1 npm start
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
All via environment variables (no hardcoding):
|
||||
|
||||
| Var | Default | Meaning |
|
||||
|-----|---------|---------|
|
||||
| `PORT` | `3000` | listen port |
|
||||
| `BIND_HOST` | `0.0.0.0` | listen address |
|
||||
| `SHELL_PATH` | `$SHELL` or `/bin/zsh` | shell to spawn |
|
||||
| `IDLE_TTL` | `86400` (s) | reclaim a detached session after this idle time |
|
||||
| `SCROLLBACK_BYTES` | `2097152` | per-session replay ring buffer (bytes) |
|
||||
| `MAX_PAYLOAD_BYTES` | `1048576` | max single WS frame |
|
||||
| `USE_TMUX` | `auto` | `1`/`0`/`auto` — run the shell inside tmux (keepalive across restart); `auto` = on if `tmux` is on PATH |
|
||||
| `ALLOWED_ORIGINS` | (derived) | extra allowed WS origins, comma-separated |
|
||||
|
||||
`allowedOrigins` is **derived from the host's network-interface IPs** (plus `localhost` and anything in `ALLOWED_ORIGINS`) — never from `BIND_HOST`, since `0.0.0.0` is never a real browser Origin.
|
||||
|
||||
## Security
|
||||
|
||||
This is a no-auth, LAN-only tool by design. The defenses that matter:
|
||||
|
||||
- **Origin check (cannot be skipped):** the WS handshake rejects any `Origin` not on the allow-list (HTTP 401). This blocks Cross-Site WebSocket Hijacking — a malicious page in your browser trying to connect to `ws://<your-lan-ip>:3000`.
|
||||
- **Path-scoped upgrades:** only `/term` is accepted for WS upgrade.
|
||||
- **Frame size cap:** oversized frames are rejected (`MAX_PAYLOAD_BYTES`).
|
||||
- **Never expose to the public internet.** `ws://` is **unencrypted** — on an untrusted network (café/office Wi-Fi) traffic (including keystrokes: passwords, API keys) can be sniffed. The recommended deployment is **[Tailscale](https://tailscale.com/)** (WireGuard-encrypted), which also lets you use `wss://` (the frontend auto-selects `wss` on HTTPS).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm test # vitest, all modules
|
||||
npm test # vitest, all modules (~1600 tests, 80% coverage gate)
|
||||
npm run typecheck # tsc (backend + frontend)
|
||||
npm run build # compile backend to dist/
|
||||
```
|
||||
|
||||
Real-PTY end-to-end tests (`test/integration/`) auto-skip where `posix_spawn` is unavailable (e.g. sandboxes) and run everywhere else.
|
||||
---
|
||||
|
||||
## How it works
|
||||
## Configuration
|
||||
|
||||
The server is a **byte-shuttle, not a terminal**: `node-pty` provides a pseudo-terminal so the shell believes it has a real TTY, and `xterm.js` in the browser interprets the ANSI bytes and renders. The server never parses terminal semantics. PTY lifecycle is decoupled from the WebSocket — a disconnect *detaches* (the PTY keeps running) rather than killing it, which is what makes session survival work.
|
||||
All config is via environment variables (no hardcoding). Invalid values fail fast at startup.
|
||||
|
||||
Design and rationale: [`docs/TECH_DOC.md`](docs/TECH_DOC.md) (why) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (how).
|
||||
### Core
|
||||
| Var | Default | Purpose |
|
||||
|-----|---------|---------|
|
||||
| `PORT` | `3000` | Listen port. |
|
||||
| `BIND_HOST` | `0.0.0.0` | Listen address. |
|
||||
| `SHELL_PATH` | `$SHELL` or `/bin/zsh` | Shell to spawn. |
|
||||
| `IDLE_TTL` | `86400` (s) | Reclaim a detached session after this idle time (no new output since detach). |
|
||||
| `SCROLLBACK_BYTES` | `2097152` (2 MB) | Per-session replay ring buffer size. |
|
||||
| `MAX_PAYLOAD_BYTES` | `1048576` (1 MB) | Max single WS frame; oversized frames are rejected. |
|
||||
| `WS_PATH` | `/term` | The only path accepted for WS upgrade. |
|
||||
| `MAX_SESSIONS` | `50` | Cap on concurrent sessions (DoS guard). |
|
||||
| `MAX_MSGS_PER_SEC` | `2000` | Per-connection WS frame-rate cap; over-limit frames are dropped (not disconnected). |
|
||||
| `USE_TMUX` | `auto` | `1`/`0`/`auto` — run the shell inside tmux (keepalive across restart); `auto` = on if `tmux` is on PATH. |
|
||||
| `ALLOWED_ORIGINS` | (derived) | Extra allowed WS origins, comma-separated. The base list is derived from the host's NIC IPs + localhost — never from `BIND_HOST`. |
|
||||
| `WEBTERM_TOKEN` | (unset → auth **disabled**, **secret**) | Optional shared access token gating every HTTP route + the WS upgrade behind a `webterm_auth` cookie (alongside, never instead of, the Origin check). 16–512 chars of `[A-Za-z0-9._~+/=-]` or the server refuses to start. Never logged. See [Security & deployment](#security--deployment) for the honest limits. |
|
||||
| `PERM_TIMEOUT_MS` | `300000` (5 min) | How long a held tool-permission request waits for a remote decision before falling back to Claude's own prompt. Must be > 0. |
|
||||
| `REAP_INTERVAL_MS` | `60000` | Idle-reaper / stuck-sweep tick interval. |
|
||||
| `PREVIEW_BYTES` | `24576` (24 KB) | Tail of scrollback served for live preview thumbnails. |
|
||||
|
||||
### Projects (v0.6)
|
||||
| Var | Default | Purpose |
|
||||
|-----|---------|---------|
|
||||
| `PROJECT_ROOTS` | `$HOME` | Comma-separated absolute roots to scan for git repos (`~`/`~/...` expanded; relative paths rejected). |
|
||||
| `PROJECT_SCAN_DEPTH` | `4` | How deep to descend looking for `.git`. |
|
||||
| `PROJECT_SCAN_TTL` | `10000` (ms) | Cache TTL for repo discovery. |
|
||||
| `PROJECT_DIRTY_CHECK` | `1` (on) | Run `git status --porcelain` to flag dirty repos. |
|
||||
| `EDITOR_CMD` | `code` | Command the host runs for the project "VS Code" button. |
|
||||
|
||||
### Walk-away workbench (v0.7)
|
||||
| Var | Default | Purpose |
|
||||
|-----|---------|---------|
|
||||
| `VAPID_PUBLIC_KEY` | (unset → push off) | Web Push public key, exposed to the service worker. |
|
||||
| `VAPID_PRIVATE_KEY` | (unset, **secret**) | Web Push private key; required to enable push. Never logged. |
|
||||
| `VAPID_SUBJECT` | `mailto:admin@localhost` | VAPID `sub`. |
|
||||
| `PUSH_STORE_PATH` | `~/.web-terminal-push-subs.json` | Persisted push-subscription store. |
|
||||
| `PUSH_MAX_SUBS` | `50` | Max stored push subscriptions (DoS guard). |
|
||||
| `NOTIFY_DONE` | `1` (on) | Send the low-priority DONE push on Stop/SessionEnd. |
|
||||
| `NOTIFY_DND` | `0` (off) | Global do-not-disturb default. |
|
||||
| `DECISION_TOKEN_TTL_MS` | = `PERM_TIMEOUT_MS` | Lifetime of a lock-screen decision capability token. |
|
||||
| `TIMELINE_MAX` | `200` | Per-session activity-timeline event ring cap. |
|
||||
| `TIMELINE_ENABLED` | `1` (on) | Capture/serve the activity timeline. |
|
||||
| `STUCK_TTL` | `600` (s) | Silence window before a "possibly stuck" alert; `0` disables. |
|
||||
| `STUCK_ALERT` | `1` (on) | Master switch for stuck alerts. |
|
||||
| `DIFF_TIMEOUT_MS` | `2000` | Timeout for a single `git diff`. |
|
||||
| `DIFF_MAX_BYTES` | `2097152` (2 MB) | Patch truncation cap. |
|
||||
| `DIFF_MAX_FILES` | `300` | Max files returned before the diff is marked `truncated`. |
|
||||
| `STATUSLINE_TTL_MS` | `30000` | After this with no update, per-tab telemetry greys out as stale. |
|
||||
| `WORKTREE_ENABLED` | `1` (on) | Master switch for the create-worktree feature (the only write-to-disk action). |
|
||||
| `WORKTREE_ROOT` | (unset → `<repo>-worktrees`) | Root that new worktrees must land inside. |
|
||||
| `WORKTREE_TIMEOUT_MS` | `10000` | Timeout for `git worktree add`. |
|
||||
| `DEFAULT_PERMISSION_MODE` | `default` | `--permission-mode` used when a session is started without an explicit choice (`default`/`acceptEdits`/`plan`/`auto`). |
|
||||
| `ALLOW_AUTO_MODE` | `0` (off) | Whether the high-risk `auto` (bypass-permissions) mode is offered/honored. |
|
||||
|
||||
### Spawn-injected env (set by the server / read by hook scripts — not user config)
|
||||
`WEBTERM_SESSION`, `WEBTERM_HOOK_URL`, `WEBTERM_STATUSLINE_URL` are injected into each spawned shell so the hooks/statusLine know which session they belong to and where to POST. The optional ntfy/Pushover bridge reads `WEBTERM_NTFY_URL` / `WEBTERM_NTFY_TOPIC` / `WEBTERM_NTFY_TOKEN` and `WEBTERM_PUSHOVER_TOKEN` / `WEBTERM_PUSHOVER_USER` — secrets stay in env, never written into `settings.json` or command argv.
|
||||
|
||||
---
|
||||
|
||||
## Security & deployment
|
||||
|
||||
This is a **LAN-only** tool by design — with no token set it hands a full shell to anyone who can reach the port, and even with one set it is not an internet-facing service. The defenses that matter:
|
||||
|
||||
- **WS Origin validation (cannot be skipped):** the WebSocket handshake rejects any `Origin` not on the allow-list (HTTP 401). This blocks Cross-Site WebSocket Hijacking — a malicious page in your browser trying to connect to `ws://<your-lan-ip>:3000`. Only `WS_PATH` is accepted for upgrade.
|
||||
- **CSRF / Origin guards on state-changing routes:** every route with a side effect (`DELETE /live-sessions`, `POST /open-in-editor`, `POST /push/subscribe`, `POST /hook/decision`, `POST /projects/worktree` + `/worktree/prune` + `DELETE /projects/worktree`, `POST /projects/git/{stage,commit,push,fetch}`) requires an allowed Origin (403 otherwise). Read-only discovery routes don't.
|
||||
- **Loopback-only hook ingest:** the side-channel ingest endpoints (`/hook`, `/hook/permission`, `/hook/status`) only accept loopback peers — the Claude process always runs on the host.
|
||||
- **Per-IP rate limits** on push subscribe and lock-screen decision routes; a per-connection WS frame-rate cap.
|
||||
- **Safe git exec + per-decision capability tokens:** all `git` calls use `execFile` (no shell) with timeouts and path validation; the lock-screen Allow/Deny is authorized by a token bound to that session's current pending request (it expires on resolve/timeout).
|
||||
- **Optional shared access token (`WEBTERM_TOKEN`) — a bar-raiser, not authentication.** Unset ⇒ the gate is **disabled** and behaviour is byte-identical to a no-auth server (LAN zero-config preserved). Set (16–512 chars of `[A-Za-z0-9._~+/=-]`, else the server refuses to start) ⇒ every HTTP route **and** the WS upgrade require an `HttpOnly; SameSite=Strict` `webterm_auth` cookie, delivered once by `GET /?token=<t>` or `POST /auth`; the loopback hook ingest (`/hook*`) stays exempt so the Claude Code side-channel keeps working. It sits *in front of* the Origin check and never replaces it. **Honest limits:** on bare `ws://` the token travels in cleartext and is replayable by a LAN sniffer; it is one shared secret with no per-user identity and no revocation short of changing the env var and restarting. It meaningfully hardens only the TLS-terminated relay/tunnel path. See `src/http/auth.ts` and [`docs/plans/w5-access-token.md`](docs/plans/w5-access-token.md).
|
||||
- **Beyond that, there is no authentication.** Treat the whole app as "shell access for anyone on the network." **Never expose it to the public internet.** `ws://` is unencrypted, so on an untrusted network keystrokes (passwords, API keys) can be sniffed. The recommended deployment is **[Tailscale](https://tailscale.com/)** (WireGuard-encrypted), which also gives you `wss://` — the frontend auto-selects `wss` on HTTPS. **Web Push requires HTTPS** (a secure context), so the push features need the Tailscale/TLS deploy; bare LAN-over-HTTP falls back to the ntfy/Pushover bridge.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
The server is a **byte-shuttle, not a terminal**: `node-pty` gives the shell a real pseudo-terminal, the server shuttles raw bytes over the WebSocket, and `xterm.js` in the browser interprets ANSI and renders. The server never parses terminal semantics — every "smart" feature (status, timeline, telemetry, diff, push) rides an **out-of-band side-channel** (loopback HTTP/JSON from Claude Code hooks, or `git` subprocesses), keeping the terminal stream a pure pipe.
|
||||
|
||||
The other central design point: **PTY lifecycle ≠ WebSocket lifecycle.** A WS close *detaches* a client (the PTY keeps running for other devices and for reconnect); only an idle timeout (or explicit kill / server shutdown) ends a session.
|
||||
|
||||
Tested with **vitest** (~1600 tests, 80% coverage gate across backend + the logic-bearing frontend modules), plus real-PTY integration tests that auto-skip where `posix_spawn` is unavailable (sandboxes) and run everywhere else.
|
||||
|
||||
Design and rationale: [`docs/TECH_DOC.md`](docs/TECH_DOC.md) (the *why*) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (the *how*). Feature PRDs: [`docs/FEATURE_PROJECT_MANAGER.md`](docs/FEATURE_PROJECT_MANAGER.md) (v0.6) and [`docs/FEATURE_WALKAWAY_WORKBENCH.md`](docs/FEATURE_WALKAWAY_WORKBENCH.md) (v0.7).
|
||||
|
||||
1609
agent/package-lock.json
generated
Normal file
1609
agent/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
agent/package.json
Normal file
34
agent/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "web-terminal-agent",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "P2 — Host Agent for the rendezvous-relay service. Ed25519 per-host identity, single-use pairing redemption (§4.5), outbound mTLS wss tunnel holding the §4.1 mux (codec via relay-contracts), loopback splice to the UNCHANGED web-terminal, heartbeat/backoff, cert auto-rotation, fast revocation, and the agent-side E2E endpoint (§4.4). Ciphertext-shuttle on the customer's own machine. See docs/PLAN_RELAY_AGENT.md.",
|
||||
"bin": {
|
||||
"web-terminal-agent": "dist/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "esbuild src/main.ts --bundle --platform=node --format=esm --outfile=dist/cli.js --banner:js='#!/usr/bin/env node\nimport{createRequire as __cjs}from\"node:module\";const require=__cjs(import.meta.url);'",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"relay-contracts": "file:../relay-contracts",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"esbuild": "^0.28.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
273
agent/src/certs/nativeRenew.ts
Normal file
273
agent/src/certs/nativeRenew.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Native-tunnel cert auto-renew wiring — TASK A5 (PLAN_ZERO_TOUCH_ROLLOUT).
|
||||
*
|
||||
* The native run-loop (`superviseNative`) used to only MONITOR the frp-client leaf's freshness; the
|
||||
* leaf therefore expired at ~24h and the tunnel dropped until a manual re-pair. This module closes
|
||||
* that gap by driving `createCertRotator`/`renewCert` (crypto is REUSED, never reimplemented):
|
||||
*
|
||||
* - `createMtlsFetch` — the injected `fetchImpl` the rotator hands to `renewCert`. It POSTs /renew
|
||||
* over mTLS presenting the CURRENT keystore leaf (re-read on every call, so the first renewal
|
||||
* after a rotation already authenticates with the freshly issued leaf). mTLS IS the auth — no
|
||||
* token, `rejectUnauthorized` always true (INV4/INV14). The private key stays in-process.
|
||||
* - `wireAutoRenew` — routes the rotator callbacks: rotated → restart frpc onto the new leaf and
|
||||
* log (non-secret); revoked (403) → tear the tunnel down (INV12); error → log + let the rotator
|
||||
* retry with backoff. A failed renewal NEVER crashes the supervisor.
|
||||
* - `startNativeAutoRenew` — the builder `superviseNative` calls: loads the identity, builds the
|
||||
* mTLS fetch + rotator at ~2/3-TTL, and starts it. Returns null (auto-renew disabled) if the host
|
||||
* is not enrolled (no identity), rather than throwing into the run-loop.
|
||||
*/
|
||||
import { request as httpsRequest } from 'node:https'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import { resolveHostIdentity } from '../config/hostRecord.js'
|
||||
import type { Keystore } from '../keys/keystore.js'
|
||||
import type { Logger } from '../log/logger.js'
|
||||
import type { TimerLike } from '../transport/seams.js'
|
||||
import { createBackoff } from '../transport/backoff.js'
|
||||
import { buildTlsOptions, type CertParser, type TlsClientOptions } from '../transport/dial.js'
|
||||
import { DEFAULT_CERT_RENEW_WINDOW_MS } from '../health/probe.js'
|
||||
import {
|
||||
createCertRotator,
|
||||
type CertExpiredBeyondGraceError,
|
||||
type CertRotator,
|
||||
} from './rotation.js'
|
||||
|
||||
/** Non-secret message from an unknown thrown value (never serializes cert/key material). */
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
|
||||
/**
|
||||
* Socket-idle timeout for a /renew request. A stalled or overloaded control-plane (or a NAT that
|
||||
* silently drops the connection after the TLS handshake) must NOT leave the renewal Promise pending
|
||||
* forever — that would starve the rotator's backoff-retry loop and let the leaf silently expire. On
|
||||
* timeout the request is destroyed and the rejection surfaces through the rotator's onError→backoff.
|
||||
*/
|
||||
export const RENEW_REQUEST_TIMEOUT_MS = 15_000
|
||||
/**
|
||||
* Hard cap on the buffered /renew response body. The reply is a small `{cert,caChain}` JSON; anything
|
||||
* beyond a few KB is malformed or hostile, so we destroy the stream and reject rather than buffer it.
|
||||
*/
|
||||
export const MAX_RENEW_RESPONSE_BYTES = 64 * 1024
|
||||
|
||||
// --- mTLS fetch --------------------------------------------------------------------------------
|
||||
|
||||
/** A single mTLS request the fetch shim delegates to (injectable so the shim is offline-testable). */
|
||||
export interface MtlsRequestInit {
|
||||
readonly method: string
|
||||
readonly headers: Record<string, string>
|
||||
readonly body?: string
|
||||
}
|
||||
export interface MtlsResponse {
|
||||
readonly status: number
|
||||
readonly body: string
|
||||
}
|
||||
export type MtlsRequest = (
|
||||
url: string,
|
||||
tls: TlsClientOptions,
|
||||
init: MtlsRequestInit,
|
||||
) => Promise<MtlsResponse>
|
||||
|
||||
/** Default mTLS transport: a `node:https` POST presenting the client cert/key + pinned CA. */
|
||||
const defaultMtlsRequest: MtlsRequest = (url, tls, init) =>
|
||||
new Promise<MtlsResponse>((resolve, reject) => {
|
||||
const req = httpsRequest(
|
||||
url,
|
||||
{
|
||||
method: init.method,
|
||||
headers: init.headers,
|
||||
cert: tls.cert,
|
||||
key: tls.key,
|
||||
// ca omitted ⇒ verify the server against the system roots (LE-fronted CP). Present only when a
|
||||
// private CA is pinned (not for /renew).
|
||||
...(tls.ca !== undefined ? { ca: tls.ca } : {}),
|
||||
rejectUnauthorized: tls.rejectUnauthorized, // always true (anti-MITM, INV14)
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = []
|
||||
let total = 0
|
||||
res.on('data', (c: Buffer) => {
|
||||
total += c.length
|
||||
if (total > MAX_RENEW_RESPONSE_BYTES) {
|
||||
res.destroy() // MEDIUM: refuse an unbounded body — a renew reply is a few-KB JSON
|
||||
reject(new Error(`renew response body exceeded ${MAX_RENEW_RESPONSE_BYTES} byte cap`))
|
||||
return
|
||||
}
|
||||
chunks.push(c)
|
||||
})
|
||||
res.on('end', () =>
|
||||
resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }),
|
||||
)
|
||||
res.on('error', reject) // a mid-stream socket error must reject, not hang
|
||||
},
|
||||
)
|
||||
// HIGH: bound the request so a peer that accepts the connection but never replies rejects (and the
|
||||
// rotator re-enters backoff) instead of pending forever — destroy(err) emits 'error' → reject below.
|
||||
req.setTimeout(RENEW_REQUEST_TIMEOUT_MS, () => {
|
||||
req.destroy(new Error(`renew request timed out after ${RENEW_REQUEST_TIMEOUT_MS}ms`))
|
||||
})
|
||||
req.on('error', reject)
|
||||
if (init.body !== undefined) req.write(init.body)
|
||||
req.end()
|
||||
})
|
||||
|
||||
function toHeaderRecord(headers: RequestInit['headers']): Record<string, string> {
|
||||
if (!headers) return {}
|
||||
if (headers instanceof Headers) {
|
||||
const out: Record<string, string> = {}
|
||||
headers.forEach((v, k) => {
|
||||
out[k] = v
|
||||
})
|
||||
return out
|
||||
}
|
||||
if (Array.isArray(headers)) return Object.fromEntries(headers)
|
||||
return { ...(headers as Record<string, string>) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `fetch`-shaped shim `renewCert` uses. Each call re-reads the CURRENT keystore leaf via
|
||||
* `buildTlsOptions` (which fail-fast throws NotEnrolled/CertExpired — the rotator then logs + retries
|
||||
* with backoff, never crashing) and delegates to the mTLS transport, mapping the result to a real
|
||||
* `Response` (so `res.ok`/`res.status`/`res.json()` behave exactly as `renewCert` expects).
|
||||
*/
|
||||
export function createMtlsFetch(
|
||||
ks: Keystore,
|
||||
opts: { request?: MtlsRequest; certParser?: CertParser } = {},
|
||||
): typeof fetch {
|
||||
const request = opts.request ?? defaultMtlsRequest
|
||||
const shim = async (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
// Present the current frp-client leaf (client auth), but verify the /renew SERVER cert against the
|
||||
// SYSTEM roots — its host (the LE-fronted control-plane) is publicly trusted; pinning the private
|
||||
// enroll caChain here fails with "unable to get local issuer certificate". So drop `ca` (absent →
|
||||
// node uses the default roots); rejectUnauthorized stays true.
|
||||
// Deliberately still fail-closed on an EXPIRED leaf: nginx would refuse to forward it anyway, so
|
||||
// a lapsed leaf is routed to the plain `/recover` endpoint by the rotator instead of through here.
|
||||
const full = buildTlsOptions(ks, { ...(opts.certParser ? { certParser: opts.certParser } : {}) })
|
||||
const tls: TlsClientOptions = { cert: full.cert, key: full.key, rejectUnauthorized: full.rejectUnauthorized }
|
||||
const reqInit: MtlsRequestInit = {
|
||||
method: init?.method ?? 'GET',
|
||||
headers: toHeaderRecord(init?.headers),
|
||||
...(typeof init?.body === 'string' ? { body: init.body } : {}),
|
||||
}
|
||||
const { status, body } = await request(url, tls, reqInit)
|
||||
return new Response(body, { status })
|
||||
}
|
||||
return shim as typeof fetch
|
||||
}
|
||||
|
||||
// --- rotator wiring ----------------------------------------------------------------------------
|
||||
|
||||
/** Non-secret identifiers logged alongside renew events (INV9). */
|
||||
export interface AutoRenewLogIds {
|
||||
readonly subdomain: string | null
|
||||
readonly hostId: string | null
|
||||
}
|
||||
|
||||
/** The two run-loop effects the rotator drives. */
|
||||
export interface AutoRenewHooks {
|
||||
/** Restart the supervised frpc so it re-reads the rotated cert (a leaf rotation only). */
|
||||
restartChild(): void
|
||||
/** Tear the tunnel down (host revoked ⇒ never reconnect, INV12). */
|
||||
stop(): void
|
||||
}
|
||||
|
||||
/** Handle for the wired auto-renew loop. */
|
||||
export interface AutoRenewController {
|
||||
stop(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire a rotator's callbacks to the run-loop and start it. Rotated → restart frpc; revoked → stop;
|
||||
* error → log (non-secret) and let the rotator retry with backoff. Returns a controller that stops
|
||||
* the rotator's scheduled timer.
|
||||
*/
|
||||
export function wireAutoRenew(
|
||||
rotator: CertRotator,
|
||||
hooks: AutoRenewHooks,
|
||||
logger: Logger,
|
||||
ids: AutoRenewLogIds,
|
||||
): AutoRenewController {
|
||||
const meta = { subdomain: ids.subdomain, hostId: ids.hostId }
|
||||
rotator.onRotated(() => {
|
||||
logger.log('info', 'frp-client cert rotated; restarting frpc onto the fresh leaf', meta)
|
||||
hooks.restartChild()
|
||||
})
|
||||
rotator.onRevoked(() => {
|
||||
logger.log('warn', 'frp-client cert renewal refused (host revoked); tearing down tunnel', meta)
|
||||
hooks.stop()
|
||||
})
|
||||
rotator.onError((err) => {
|
||||
logger.log('warn', 'frp-client cert renewal failed; will retry with backoff', {
|
||||
...meta,
|
||||
error: errorMessage(err),
|
||||
})
|
||||
})
|
||||
// Terminal: the grace window is spent, so every further attempt is guaranteed to fail. Say so once,
|
||||
// at error level, naming the fix — and deliberately do NOT stop the supervisor: `pair` writes fresh
|
||||
// cert files that the restart-on-exit frpc child picks up without a manual service restart.
|
||||
rotator.onExhausted((err) => {
|
||||
logger.log('error', 'frp-client cert expired beyond recovery grace — run `web-terminal-agent pair <CODE>` to re-pair this host', {
|
||||
...meta,
|
||||
expiredForMs: err.expiredForMs,
|
||||
graceMs: err.graceMs,
|
||||
})
|
||||
})
|
||||
rotator.start()
|
||||
return { stop: () => rotator.stop() }
|
||||
}
|
||||
|
||||
// --- builder -----------------------------------------------------------------------------------
|
||||
|
||||
/** Injection seams for `startNativeAutoRenew` (all optional; unset ⇒ real transport/timers). */
|
||||
export interface NativeAutoRenewOpts {
|
||||
readonly mtlsRequest?: MtlsRequest
|
||||
readonly certParser?: CertParser
|
||||
/** Window in which an already-expired leaf may still be recovered via `/recover`. */
|
||||
readonly expiredGraceMs?: number
|
||||
/** Plain (NON-mTLS) fetch for the `/recover` call; unset ⇒ global fetch. */
|
||||
readonly recoverFetchImpl?: typeof fetch
|
||||
readonly timer?: TimerLike
|
||||
readonly renewBeforeMs?: number
|
||||
readonly retryBaseMs?: number
|
||||
readonly now?: () => Date
|
||||
readonly parseCert?: (pem: string) => Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Build + start native cert auto-renew for `superviseNative`. Renews at ~2/3 of the leaf TTL
|
||||
* (default `DEFAULT_CERT_RENEW_WINDOW_MS`, the same window the health probe alarms on). Returns null
|
||||
* (auto-renew disabled, logged) when the host has no identity — an unenrolled run-loop must not throw.
|
||||
*/
|
||||
export function startNativeAutoRenew(
|
||||
cfg: AgentConfig,
|
||||
ks: Keystore,
|
||||
hooks: AutoRenewHooks,
|
||||
logger: Logger,
|
||||
opts: NativeAutoRenewOpts = {},
|
||||
): AutoRenewController | null {
|
||||
const id = ks.loadIdentity()
|
||||
if (id === null) {
|
||||
logger.log('warn', 'no identity in keystore — cert auto-renew disabled', {})
|
||||
return null
|
||||
}
|
||||
const fetchImpl = createMtlsFetch(ks, {
|
||||
...(opts.mtlsRequest ? { request: opts.mtlsRequest } : {}),
|
||||
...(opts.certParser ? { certParser: opts.certParser } : {}),
|
||||
})
|
||||
const rotator = createCertRotator(cfg, id, ks, {
|
||||
fetchImpl,
|
||||
...(opts.expiredGraceMs !== undefined ? { expiredGraceMs: opts.expiredGraceMs } : {}),
|
||||
...(opts.recoverFetchImpl ? { recoverFetchImpl: opts.recoverFetchImpl } : {}),
|
||||
renewBeforeMs: opts.renewBeforeMs ?? DEFAULT_CERT_RENEW_WINDOW_MS,
|
||||
...(opts.timer ? { timer: opts.timer } : {}),
|
||||
...(opts.now ? { now: opts.now } : {}),
|
||||
...(opts.parseCert ? { parseCert: opts.parseCert } : {}),
|
||||
...(opts.retryBaseMs !== undefined
|
||||
? { retryBackoff: createBackoff({ baseMs: opts.retryBaseMs, jitter: false }) }
|
||||
: {}),
|
||||
})
|
||||
// Prefer the resolved identity (config > enrolment record > leaf SPIFFE SAN) so renewal warnings
|
||||
// actually name the host — `cfg` alone is null on every install that predates the record.
|
||||
const ids = resolveHostIdentity(cfg, () => ks.loadCert()?.certPem ?? null)
|
||||
return wireAutoRenew(rotator, hooks, logger, ids)
|
||||
}
|
||||
33
agent/src/certs/pem.ts
Normal file
33
agent/src/certs/pem.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* PEM helpers shared by the native enroll (enroll/pair.ts) and renew (certs/rotation.ts) paths.
|
||||
*
|
||||
* The control-plane returns the frp-client leaf + CA chain as base64-encoded DER (cert as a string,
|
||||
* caChain as a string[]); the keystore + frpc need PEM files. `derBase64ToPem` wraps a base64 DER body
|
||||
* back into CERTIFICATE armor at 64 columns.
|
||||
*/
|
||||
|
||||
/** base64(DER) → PEM (CERTIFICATE armor, 64-col wrapped). */
|
||||
export function derBase64ToPem(derBase64: string, label = 'CERTIFICATE'): string {
|
||||
const body = derBase64.replace(/\s+/g, '')
|
||||
const lines = body.match(/.{1,64}/g) ?? []
|
||||
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a control-plane cert response ({cert: base64 DER, caChain: base64 DER[]}) to PEM strings
|
||||
* for the keystore. Throws if the shape is wrong. Shared by enroll + renew so both stay in lockstep.
|
||||
*/
|
||||
export function certResponseToPem(cert: unknown, caChain: unknown): { certPem: string; caChainPem: string } {
|
||||
if (
|
||||
typeof cert !== 'string' ||
|
||||
!Array.isArray(caChain) ||
|
||||
caChain.length === 0 ||
|
||||
!caChain.every((c) => typeof c === 'string')
|
||||
) {
|
||||
throw new Error('cert response missing cert/caChain')
|
||||
}
|
||||
return {
|
||||
certPem: derBase64ToPem(cert),
|
||||
caChainPem: (caChain as string[]).map((c) => derBase64ToPem(c)).join(''),
|
||||
}
|
||||
}
|
||||
262
agent/src/certs/rotation.ts
Normal file
262
agent/src/certs/rotation.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Short-lived cert rotation — PLAN_RELAY_AGENT T13 (INV14). Renews the mTLS cert BEFORE expiry via
|
||||
* P3's renewal endpoint using a fresh CSR over the SAME Ed25519 key (pubkey unchanged, only the
|
||||
* cert rotates). Installs the new cert atomically (keystore writeFile is whole-file). A 403 from
|
||||
* the renewal endpoint ⇒ the host was revoked ⇒ onRevoked (⇒ T14 teardown, INV12).
|
||||
*
|
||||
* NOTE (cross-plan / open Q#5): the renewal URL + its auth (mTLS with the current cert vs a short
|
||||
* renewal token) is P3-owned. This derives `${enrollUrl}` → `.../renew` and injects fetch; when P3
|
||||
* freezes the route, adjust `renewalUrlFor`. Single integration point.
|
||||
*/
|
||||
import { X509Certificate } from 'node:crypto'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import type { AgentIdentity } from '../keys/identity.js'
|
||||
import type { Keystore } from '../keys/keystore.js'
|
||||
import type { TimerLike } from '../transport/seams.js'
|
||||
import { createBackoff, type BackoffPolicy } from '../transport/backoff.js'
|
||||
import { buildCsr } from '../enroll/csr.js'
|
||||
import { certResponseToPem } from './pem.js'
|
||||
|
||||
export const DEFAULT_RENEW_BEFORE_MS = 5 * 60_000 // renew 5 min before expiry
|
||||
|
||||
/**
|
||||
* How long after `notAfter` a lapsed leaf may still be swapped for a fresh one (30 days).
|
||||
*
|
||||
* `/renew` is mTLS-authenticated by the very leaf it renews, so a lapsed leaf cannot renew itself —
|
||||
* a deadlock that bricked a host for 8 days in production (the laptop slept through its renewal
|
||||
* window, then the agent logged `client certificate has expired` 6380 times and never recovered).
|
||||
* Inside this window the agent switches to the `/recover` endpoint instead; past it, only a re-pair
|
||||
* can help and the rotator says so once and stops.
|
||||
*/
|
||||
export const DEFAULT_EXPIRED_RENEW_GRACE_MS = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
/** The leaf lapsed longer ago than the recovery grace allows ⇒ operator must re-pair this host. */
|
||||
export class CertExpiredBeyondGraceError extends Error {
|
||||
constructor(
|
||||
/** How long ago the leaf expired (ms) — non-secret, safe to log. */
|
||||
readonly expiredForMs: number,
|
||||
/** The grace window that was exceeded (ms). */
|
||||
readonly graceMs: number,
|
||||
) {
|
||||
super('client certificate expired beyond the recovery grace window; re-pair required')
|
||||
this.name = 'CertExpiredBeyondGraceError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface CertRotator {
|
||||
start(): void
|
||||
stop(): void
|
||||
onRotated(cb: () => void): void
|
||||
onRevoked(cb: () => void): void
|
||||
/** A renewal attempt failed (network/HTTP, NOT a 403 revoke). The rotator retries with backoff. */
|
||||
onError(cb: (err: unknown) => void): void
|
||||
/**
|
||||
* TERMINAL: the leaf expired past the renewal grace window, so no future attempt can succeed. The
|
||||
* rotator has stopped; recovery requires an operator re-pair.
|
||||
*/
|
||||
onExhausted(cb: (err: CertExpiredBeyondGraceError) => void): void
|
||||
}
|
||||
|
||||
export type RenewOutcome = 'rotated' | 'revoked'
|
||||
|
||||
/** Derive P3's renewal route from the enroll URL (integration point, open Q#5). */
|
||||
export function renewalUrlFor(cfg: AgentConfig): string {
|
||||
return cfg.enrollUrl.replace(/\/enroll$/, '/renew')
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovery route for a leaf that has ALREADY EXPIRED — a sibling PATH on the same enroll host.
|
||||
*
|
||||
* It cannot be `/renew`, because nginx will not forward an expired client certificate at all: under
|
||||
* `ssl_verify_client optional` it answers a bare `400 Bad Request`, and `optional_no_ca` does not
|
||||
* help either — nginx only tolerates CHAIN errors there (`ngx_ssl_verify_error_optional` covers
|
||||
* self-signed / unknown-issuer / unverifiable-leaf, NOT `X509_V_ERR_CERT_HAS_EXPIRED`). So recovery
|
||||
* drops mTLS entirely: it is a plain HTTPS POST carrying the expired cert in the BODY. Nothing is
|
||||
* lost by that — the accompanying CSR is self-signed by the same private key, and the control-plane
|
||||
* signer already enforces CSR proof-of-possession plus `CSR key == registered key`, so possession is
|
||||
* proven exactly as the TLS handshake used to prove it.
|
||||
*/
|
||||
export function recoveryUrlFor(cfg: AgentConfig): string {
|
||||
if (cfg.recoverUrl != null && cfg.recoverUrl.length > 0) return cfg.recoverUrl
|
||||
return cfg.enrollUrl.replace(/\/enroll$/, '/recover')
|
||||
}
|
||||
|
||||
/** Ms until (validTo − renewBeforeMs), clamped to ≥ 0. */
|
||||
export function computeRenewDelayMs(
|
||||
certPem: string,
|
||||
renewBeforeMs: number,
|
||||
now: Date,
|
||||
parse: (pem: string) => Date = (p) => new Date(new X509Certificate(p).validTo),
|
||||
): number {
|
||||
const validTo = parse(certPem).getTime()
|
||||
return Math.max(0, validTo - renewBeforeMs - now.getTime())
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform one renewal round-trip. Returns 'rotated' (new cert stored) or 'revoked' (403). Any
|
||||
* other HTTP/network failure throws (caller retries with backoff; the tunnel stays up until the
|
||||
* cert actually expires).
|
||||
*/
|
||||
export async function renewCert(
|
||||
cfg: AgentConfig,
|
||||
id: AgentIdentity,
|
||||
ks: Keystore,
|
||||
fetchImpl: typeof fetch,
|
||||
opts: { url?: string } = {},
|
||||
): Promise<RenewOutcome> {
|
||||
const csr = buildCsr(id, cfg.subdomain ?? 'web-terminal-agent')
|
||||
const res = await fetchImpl(opts.url ?? renewalUrlFor(cfg), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ csr }),
|
||||
})
|
||||
if (res.status === 403) return 'revoked'
|
||||
if (!res.ok) throw new Error(`cert renewal failed: HTTP ${res.status}`)
|
||||
// The control-plane returns cert=base64(DER) + caChain=base64(DER)[]; normalize to PEM for the
|
||||
// keystore + frpc (same shape as native enroll).
|
||||
const json = (await res.json()) as { cert?: unknown; caChain?: unknown }
|
||||
const { certPem, caChainPem } = certResponseToPem(json.cert, json.caChain)
|
||||
ks.saveCert(certPem, caChainPem) // atomic whole-file install
|
||||
return 'rotated'
|
||||
}
|
||||
|
||||
/**
|
||||
* One recovery round-trip for an EXPIRED leaf: plain HTTPS (no client cert — see `recoveryUrlFor`)
|
||||
* POSTing the expired cert alongside a fresh CSR over the SAME key. Same outcome contract as
|
||||
* `renewCert`: 'rotated' installs the new leaf, 403 ⇒ 'revoked', anything else throws to the retry.
|
||||
*/
|
||||
export async function recoverCert(
|
||||
cfg: AgentConfig,
|
||||
id: AgentIdentity,
|
||||
ks: Keystore,
|
||||
fetchImpl: typeof fetch,
|
||||
url: string = recoveryUrlFor(cfg),
|
||||
): Promise<RenewOutcome> {
|
||||
const certs = ks.loadCert()
|
||||
if (certs === null) throw new Error('cannot recover without the expired leaf on disk')
|
||||
const csr = buildCsr(id, cfg.subdomain ?? 'web-terminal-agent')
|
||||
const res = await fetchImpl(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ cert: certs.certPem, csr }),
|
||||
})
|
||||
if (res.status === 403) return 'revoked'
|
||||
if (!res.ok) throw new Error(`cert recovery failed: HTTP ${res.status}`)
|
||||
const json = (await res.json()) as { cert?: unknown; caChain?: unknown }
|
||||
const { certPem, caChainPem } = certResponseToPem(json.cert, json.caChain)
|
||||
ks.saveCert(certPem, caChainPem)
|
||||
return 'rotated'
|
||||
}
|
||||
|
||||
export function createCertRotator(
|
||||
cfg: AgentConfig,
|
||||
id: AgentIdentity,
|
||||
ks: Keystore,
|
||||
opts: {
|
||||
renewBeforeMs?: number
|
||||
timer?: TimerLike
|
||||
fetchImpl?: typeof fetch
|
||||
now?: () => Date
|
||||
parseCert?: (pem: string) => Date
|
||||
/** Backoff policy for retrying a FAILED renewal (default 1s→30s). Reset after a success. */
|
||||
retryBackoff?: BackoffPolicy
|
||||
/** Plain (NON-mTLS) fetch used only for the expired-leaf `/recover` call. */
|
||||
recoverFetchImpl?: typeof fetch
|
||||
/** Window in which an expired leaf may still be recovered. 0 ⇒ no recovery at all. */
|
||||
expiredGraceMs?: number
|
||||
} = {},
|
||||
): CertRotator {
|
||||
const renewBeforeMs = opts.renewBeforeMs ?? DEFAULT_RENEW_BEFORE_MS
|
||||
const parseCert = opts.parseCert ?? ((p) => new Date(new X509Certificate(p).validTo))
|
||||
const timer = opts.timer ?? {
|
||||
setTimeout: (cb, ms) => setTimeout(cb, ms),
|
||||
clearTimeout: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
|
||||
setInterval: (cb, ms) => setInterval(cb, ms),
|
||||
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
|
||||
}
|
||||
const doFetch = opts.fetchImpl ?? fetch
|
||||
const recoverFetch = opts.recoverFetchImpl ?? fetch
|
||||
const expiredGraceMs = opts.expiredGraceMs ?? DEFAULT_EXPIRED_RENEW_GRACE_MS
|
||||
const now = opts.now ?? (() => new Date())
|
||||
const retryBackoff = opts.retryBackoff ?? createBackoff({ jitter: true })
|
||||
let handle: unknown = null
|
||||
let rotatedCb: (() => void) | null = null
|
||||
let revokedCb: (() => void) | null = null
|
||||
let errorCb: ((err: unknown) => void) | null = null
|
||||
let exhaustedCb: ((err: CertExpiredBeyondGraceError) => void) | null = null
|
||||
|
||||
function schedule(): void {
|
||||
const certs = ks.loadCert()
|
||||
if (certs === null) return
|
||||
const delay = computeRenewDelayMs(certs.certPem, renewBeforeMs, now(), parseCert)
|
||||
handle = timer.setTimeout(runRenewal, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* How this attempt must be made, derived from how stale the leaf on disk is:
|
||||
* `normal` — still valid ⇒ ordinary mTLS `/renew`;
|
||||
* `recover` — expired but inside the grace window ⇒ plain `/recover` with the cert in the body;
|
||||
* `exhausted` — expired past the grace window ⇒ nothing can succeed; only an operator re-pair.
|
||||
*/
|
||||
function attemptPlan(): { mode: 'normal' | 'recover' | 'exhausted'; expiredForMs: number } {
|
||||
const certs = ks.loadCert()
|
||||
if (certs === null) return { mode: 'normal', expiredForMs: 0 }
|
||||
const expiredForMs = now().getTime() - parseCert(certs.certPem).getTime()
|
||||
if (expiredForMs <= 0) return { mode: 'normal', expiredForMs: 0 }
|
||||
return { mode: expiredForMs > expiredGraceMs ? 'exhausted' : 'recover', expiredForMs }
|
||||
}
|
||||
|
||||
function runRenewal(): void {
|
||||
const plan = attemptPlan()
|
||||
if (plan.mode === 'exhausted') {
|
||||
// Terminal: report ONCE and arm nothing. The old code retried forever, which is how a single
|
||||
// real failure turned into 6380 identical warnings that buried the signal.
|
||||
handle = null
|
||||
exhaustedCb?.(new CertExpiredBeyondGraceError(plan.expiredForMs, expiredGraceMs))
|
||||
return
|
||||
}
|
||||
const attempt =
|
||||
plan.mode === 'recover'
|
||||
? recoverCert(cfg, id, ks, recoverFetch)
|
||||
: renewCert(cfg, id, ks, doFetch)
|
||||
void attempt
|
||||
.then((outcome) => {
|
||||
if (outcome === 'revoked') {
|
||||
revokedCb?.()
|
||||
return
|
||||
}
|
||||
retryBackoff.reset() // a healthy renewal clears the retry backoff for the next cycle
|
||||
rotatedCb?.()
|
||||
schedule()
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// Network/HTTP failure (never a 403 revoke): surface it (caller logs, no secret) and retry
|
||||
// with backoff. The cert is still valid until expiry, so the tunnel stays up meanwhile — a
|
||||
// failed renewal must NEVER tear the supervisor down.
|
||||
errorCb?.(err)
|
||||
handle = timer.setTimeout(runRenewal, retryBackoff.nextDelayMs())
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
start(): void {
|
||||
schedule()
|
||||
},
|
||||
stop(): void {
|
||||
if (handle !== null) timer.clearTimeout(handle)
|
||||
handle = null
|
||||
},
|
||||
onRotated(cb): void {
|
||||
rotatedCb = cb
|
||||
},
|
||||
onRevoked(cb): void {
|
||||
revokedCb = cb
|
||||
},
|
||||
onError(cb): void {
|
||||
errorCb = cb
|
||||
},
|
||||
onExhausted(cb): void {
|
||||
exhaustedCb = cb
|
||||
},
|
||||
}
|
||||
}
|
||||
171
agent/src/cli.ts
Normal file
171
agent/src/cli.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* CLI entrypoint — PLAN_RELAY_AGENT T5, extended for the native tunnel (PLAN_TUNNEL_AUTOMATION B5).
|
||||
* `pair | run | status | install | uninstall`. All side effects (network/FS/tunnel/provision) are
|
||||
* injected via `CliDeps` so `runCli` stays pure and offline-testable.
|
||||
*
|
||||
* `pair <CODE> --install` is the NATIVE zero-touch onboard: P-256 keygen (FIX H-host-2) → CSR →
|
||||
* POST /enroll → provision the pinned frpc binary → write frpc.toml → install BOTH units (base-app
|
||||
* + agent, base-app env routed to the base-app unit; the agent unit supervises frpc — NOT the old
|
||||
* relay `runTunnel` rendezvous) → print `https://<sub>.terminal.<domain>`.
|
||||
*
|
||||
* `status` prints host_id/subdomain/online ONLY — never key/cert material (INV9).
|
||||
*/
|
||||
import type { AgentConfig } from './config/agentConfig.js'
|
||||
import type { AgentIdentity } from './keys/identity.js'
|
||||
import type { Keystore } from './keys/keystore.js'
|
||||
import { assertNativeZone, type InstallOptions } from './service/install.js'
|
||||
import { subdomainOrigin } from './service/originConfig.js'
|
||||
import type { EnrollResult } from 'relay-contracts'
|
||||
|
||||
export type CliCommand = 'pair' | 'run' | 'status' | 'install' | 'uninstall'
|
||||
const COMMANDS: readonly CliCommand[] = ['pair', 'run', 'status', 'install', 'uninstall']
|
||||
|
||||
export interface CliArgs {
|
||||
readonly command: CliCommand
|
||||
readonly code?: string
|
||||
readonly flags: Readonly<Record<string, string | boolean>>
|
||||
}
|
||||
|
||||
export class CliUsageError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'CliUsageError'
|
||||
}
|
||||
}
|
||||
|
||||
/** The non-secret enrollment result the native onboard needs (host id + assigned subdomain). */
|
||||
export interface NativeEnrollResult {
|
||||
readonly hostId: string
|
||||
readonly subdomain: string
|
||||
}
|
||||
|
||||
export interface CliDeps {
|
||||
loadConfig(): AgentConfig
|
||||
openKeystore(stateDir: string): Keystore
|
||||
/** Ed25519 identity for the legacy relay path. */
|
||||
generateIdentity(): AgentIdentity
|
||||
/** P-256 identity for the native frp-client key (FIX H-host-2); private key never leaves the host. */
|
||||
generateP256Identity(): AgentIdentity
|
||||
/** Legacy relay redemption (Ed25519, E2E rendezvous). */
|
||||
redeem(cfg: AgentConfig, code: string, id: AgentIdentity, ks: Keystore): Promise<EnrollResult>
|
||||
/** Native enroll: build the P-256 CSR, POST /enroll, store the returned cert; return ids only. */
|
||||
enrollNative(
|
||||
cfg: AgentConfig,
|
||||
code: string,
|
||||
id: AgentIdentity,
|
||||
ks: Keystore,
|
||||
): Promise<NativeEnrollResult>
|
||||
/** Download + verify + place the pinned frpc binary (B3); returns its path. */
|
||||
provisionFrpc(cfg: AgentConfig): Promise<string>
|
||||
/** Write the native `frpc.toml` for `subdomain` (base-app env is routed via installService). */
|
||||
writeFrpcConfig(cfg: AgentConfig, subdomain: string): void
|
||||
/** True iff a written native `frpc.toml` exists in `cfg.stateDir` (native-onboard signal). */
|
||||
nativeConfigExists(cfg: AgentConfig): boolean
|
||||
/** Legacy relay run-loop (Ed25519 WS rendezvous, T10 backoff + T9 heartbeat). */
|
||||
runTunnel(cfg: AgentConfig, ks: Keystore): Promise<number>
|
||||
/** Native run-loop: supervise the pinned frpc child (restart-on-exit backoff + health probe). */
|
||||
superviseFrpc(cfg: AgentConfig, ks: Keystore): Promise<number>
|
||||
/** Resolve per-host install inputs (S0 env incl. loopback BIND_HOST, tunnel origin) from env/flags. */
|
||||
resolveInstallOptions(): InstallOptions
|
||||
installService(cfg: AgentConfig, options: InstallOptions): Promise<void>
|
||||
uninstallService(): Promise<void>
|
||||
print(line: string): void
|
||||
}
|
||||
|
||||
/** Parse argv (already sliced past node/script) into a typed CliArgs. */
|
||||
export function parseArgs(argv: readonly string[]): CliArgs {
|
||||
const [command, ...rest] = argv
|
||||
if (command === undefined || !COMMANDS.includes(command as CliCommand)) {
|
||||
throw new CliUsageError(`unknown command '${command ?? ''}' (expected ${COMMANDS.join(' | ')})`)
|
||||
}
|
||||
const flags: Record<string, string | boolean> = {}
|
||||
const positionals: string[] = []
|
||||
for (const token of rest) {
|
||||
if (token.startsWith('--')) {
|
||||
const [k, v] = token.slice(2).split('=')
|
||||
flags[k!] = v ?? true
|
||||
} else {
|
||||
positionals.push(token)
|
||||
}
|
||||
}
|
||||
const args: CliArgs = { command: command as CliCommand, flags }
|
||||
if (command === 'pair') {
|
||||
const code = positionals[0]
|
||||
if (code === undefined) throw new CliUsageError('usage: web-terminal-agent pair <CODE>')
|
||||
return { ...args, code }
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
/**
|
||||
* Native zero-touch onboard for `pair <CODE> --install` (B5). Order is load-bearing: keygen(P-256)
|
||||
* → enroll (CSR + POST /enroll) → provision frpc (so the binary exists before the agent unit
|
||||
* starts) → write frpc.toml → install BOTH units (which start them) → print the tunnel URL.
|
||||
*/
|
||||
async function pairInstallNative(
|
||||
code: string,
|
||||
cfg: AgentConfig,
|
||||
ks: Keystore,
|
||||
deps: CliDeps,
|
||||
): Promise<number> {
|
||||
const options = deps.resolveInstallOptions()
|
||||
if (!options.domain) {
|
||||
throw new CliUsageError(
|
||||
'native install requires TUNNEL_DOMAIN — the origin is https://<sub>.terminal.<domain>',
|
||||
)
|
||||
}
|
||||
assertNativeZone(options.zone) // FIX L-host-zone: native ⇒ `terminal`
|
||||
|
||||
const id = deps.generateP256Identity() // keygen (P-256, FIX H-host-2)
|
||||
ks.saveIdentity(id)
|
||||
const enroll = await deps.enrollNative(cfg, code, id, ks) // CSR → POST /enroll → store cert
|
||||
await deps.provisionFrpc(cfg) // pinned frpc binary on disk before the service starts (B3)
|
||||
deps.writeFrpcConfig(cfg, enroll.subdomain) // frpc.toml
|
||||
await deps.installService(cfg, options) // base-app + agent units; base-app env routed → started
|
||||
|
||||
deps.print(subdomainOrigin(enroll.subdomain, options.domain, options.zone))
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Dispatch a parsed CliArgs; returns a process exit code (0 = success). */
|
||||
export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
|
||||
const cfg = deps.loadConfig()
|
||||
const ks = deps.openKeystore(cfg.stateDir)
|
||||
switch (args.command) {
|
||||
case 'pair': {
|
||||
if (args.flags['install']) return pairInstallNative(args.code!, cfg, ks, deps)
|
||||
// Legacy relay pair (Ed25519 rendezvous, no install).
|
||||
const id = ks.loadIdentity() ?? deps.generateIdentity()
|
||||
ks.saveIdentity(id)
|
||||
const enroll = await deps.redeem(cfg, args.code!, id, ks)
|
||||
deps.print(`paired: host ${enroll.hostId} subdomain ${enroll.subdomain}`)
|
||||
return 0
|
||||
}
|
||||
case 'run': {
|
||||
const id = ks.loadIdentity()
|
||||
if (id === null || ks.loadCert() === null) {
|
||||
throw new CliUsageError('not enrolled — run `web-terminal-agent pair <CODE>` first')
|
||||
}
|
||||
// Native onboard = a P-256 frp-client identity + a written frpc.toml. Supervise frpc as a
|
||||
// child (restart-on-exit backoff + health probe) instead of the legacy Ed25519 relay tunnel.
|
||||
if (id.alg === 'p256' && deps.nativeConfigExists(cfg)) {
|
||||
return deps.superviseFrpc(cfg, ks)
|
||||
}
|
||||
return deps.runTunnel(cfg, ks)
|
||||
}
|
||||
case 'status': {
|
||||
const enrolled = ks.loadIdentity() !== null && ks.loadCert() !== null
|
||||
// INV9: print only non-secret identifiers.
|
||||
deps.print(`enrolled: ${enrolled}`)
|
||||
deps.print(`host_id: ${cfg.hostId ?? '(none)'}`)
|
||||
deps.print(`subdomain: ${cfg.subdomain ?? '(none)'}`)
|
||||
return 0
|
||||
}
|
||||
case 'install':
|
||||
await deps.installService(cfg, deps.resolveInstallOptions())
|
||||
return 0
|
||||
case 'uninstall':
|
||||
await deps.uninstallService()
|
||||
return 0
|
||||
}
|
||||
}
|
||||
257
agent/src/cli/deps.ts
Normal file
257
agent/src/cli/deps.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* CliDeps factory — PLAN_RELAY_PHASE1 C2, extended for the native tunnel (PLAN_TUNNEL_AUTOMATION B5).
|
||||
* Wires the abstract `CliDeps` seams (consumed by `runCli`) to their real implementations: env-driven
|
||||
* config, the on-disk keystore, identity generation (Ed25519 + P-256), §4.5 pairing redemption, the
|
||||
* native enroll + frpc provisioning + frpc.toml writer, the supervised tunnel, and the two-unit OS
|
||||
* service install. All side effects live here so `cli.ts`/`runCli` stay pure and unit-testable.
|
||||
*/
|
||||
import { execFile } from 'node:child_process'
|
||||
import { X509Certificate } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { homedir, userInfo } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { CliDeps, NativeEnrollResult } from '../cli.js'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import { resolveHostIdentity, saveHostRecord } from '../config/hostRecord.js'
|
||||
import { loadAgentConfig } from '../config/agentConfig.js'
|
||||
import { openKeystore } from '../keys/keystore.js'
|
||||
import { generateIdentity, generateP256Identity } from '../keys/identity.js'
|
||||
import type { AgentIdentity } from '../keys/identity.js'
|
||||
import type { Keystore } from '../keys/keystore.js'
|
||||
import { redeemPairingCode } from '../enroll/pair.js'
|
||||
import { runTunnel } from '../transport/runTunnel.js'
|
||||
import { buildNativeFrpcToml } from '../transport/frpcToml.js'
|
||||
import { superviseFrpc } from '../transport/frpSupervise.js'
|
||||
import { provisionFrpc } from '../provision/frpcBinary.js'
|
||||
import { startNativeAutoRenew } from '../certs/nativeRenew.js'
|
||||
import {
|
||||
probeLoopbackBaseApp,
|
||||
renderHealthStatus,
|
||||
runHealthProbe,
|
||||
startHealthMonitor,
|
||||
} from '../health/probe.js'
|
||||
import { createLogger } from '../log/logger.js'
|
||||
import { ensureAllowedOrigin } from '../service/originConfig.js'
|
||||
import {
|
||||
buildInstallOptions,
|
||||
detectPlatform,
|
||||
NATIVE_ORIGIN_ZONE,
|
||||
installService as installServiceUnit,
|
||||
uninstallService as uninstallServiceUnit,
|
||||
type InstallDeps,
|
||||
type ServicePlatform,
|
||||
} from '../service/install.js'
|
||||
|
||||
/** Keystore file names in `stateDir` (kept in lockstep with `keys/keystore.ts`). */
|
||||
const KEYSTORE_CERT = 'agent.cert.pem'
|
||||
const KEYSTORE_KEY = 'agent.key.pem'
|
||||
const KEYSTORE_CA = 'agent.ca.pem'
|
||||
const FRPC_TOML = 'frpc.toml'
|
||||
const FRPC_LOG = 'frpc.log'
|
||||
const BASE_APP_ENV_FILE = 'base-app.env'
|
||||
const DEFAULT_LOCAL_PORT = 3000
|
||||
|
||||
/** Resolve this process's own executable path (the bundled `dist/cli.js`) for the service unit. */
|
||||
function selfBinPath(): string {
|
||||
return fileURLToPath(import.meta.url)
|
||||
}
|
||||
|
||||
function runCommand(cmd: string, args: readonly string[]): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
execFile(cmd, [...args], (err) => (err ? reject(err) : resolve()))
|
||||
})
|
||||
}
|
||||
|
||||
function realInstallDeps(): InstallDeps {
|
||||
return {
|
||||
writeFile: (path, content) => {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, content)
|
||||
},
|
||||
runCommand,
|
||||
getuid: () => (typeof process.getuid === 'function' ? process.getuid() : 0),
|
||||
homedir,
|
||||
username: () => userInfo().username,
|
||||
binPath: selfBinPath,
|
||||
}
|
||||
}
|
||||
|
||||
/** Map the current OS to its service manager, or fail fast with a clear message. */
|
||||
function requirePlatform(): ServicePlatform {
|
||||
const platform = detectPlatform(process.platform)
|
||||
if (platform === null) {
|
||||
throw new Error(`service install/uninstall is unsupported on platform '${process.platform}'`)
|
||||
}
|
||||
return platform
|
||||
}
|
||||
|
||||
/** Parse a positive-integer PORT from the env (falls back to the base-app default 3000). */
|
||||
function resolveLocalPort(): number {
|
||||
const raw = process.env.PORT
|
||||
const port = raw ? Number.parseInt(raw, 10) : NaN
|
||||
return Number.isInteger(port) && port > 0 ? port : DEFAULT_LOCAL_PORT
|
||||
}
|
||||
|
||||
/** Native enroll: build the P-256 CSR + POST /enroll (via the frozen redeem flow), store the cert. */
|
||||
async function enrollNative(
|
||||
cfg: AgentConfig,
|
||||
code: string,
|
||||
id: AgentIdentity,
|
||||
ks: Keystore,
|
||||
): Promise<NativeEnrollResult> {
|
||||
const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks, { allowMissingContentSecret: true })
|
||||
// Write the identifiers down: the long-running `run` process has no other way to learn them, and
|
||||
// without them every log line from the tunnel reads `{"subdomain":null,"hostId":null}`.
|
||||
saveHostRecord(cfg.stateDir, { hostId: enroll.hostId, subdomain: enroll.subdomain })
|
||||
return { hostId: enroll.hostId, subdomain: enroll.subdomain }
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the native `frpc.toml` into `stateDir`, pointing frpc at this host's keystore cert/key/CA and
|
||||
* the loopback base app. Also materializes the base-app ALLOWED_ORIGINS env file. The frps shared
|
||||
* token comes from `FRP_AUTH_TOKEN` (deploy secret; never logged). NOTE: the frpc binary run/e2e is
|
||||
* pending the B3 tar.gz extraction; this seam only emits the config.
|
||||
*/
|
||||
function writeFrpcConfig(cfg: AgentConfig, subdomain: string): void {
|
||||
const domain = process.env.TUNNEL_DOMAIN
|
||||
const toml = buildNativeFrpcToml({
|
||||
subdomain,
|
||||
localPort: resolveLocalPort(),
|
||||
authToken: process.env.FRP_AUTH_TOKEN ?? '',
|
||||
certFile: join(cfg.stateDir, KEYSTORE_CERT),
|
||||
keyFile: join(cfg.stateDir, KEYSTORE_KEY),
|
||||
trustedCaFile: join(cfg.stateDir, KEYSTORE_CA),
|
||||
})
|
||||
mkdirSync(cfg.stateDir, { recursive: true })
|
||||
writeFileSync(join(cfg.stateDir, FRPC_TOML), toml, { mode: 0o600 })
|
||||
if (domain) {
|
||||
ensureAllowedOrigin(join(cfg.stateDir, BASE_APP_ENV_FILE), subdomain, domain, undefined, NATIVE_ORIGIN_ZONE)
|
||||
}
|
||||
}
|
||||
|
||||
/** The stored frp-client leaf's `notAfter`, or null if no cert/parse failure (non-secret metadata). */
|
||||
function certNotAfter(ks: Keystore): Date | null {
|
||||
const cert = ks.loadCert()
|
||||
if (cert === null) return null
|
||||
try {
|
||||
return new X509Certificate(cert.certPem).validToDate
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The single frpc log path in `stateDir`. Used by BOTH the supervisor's file-logging spawn (writer)
|
||||
* and `readFrpcLog` (reader) so the health probe can never scan a different file than frpc writes.
|
||||
*/
|
||||
export function frpcLogPath(stateDir: string): string {
|
||||
return join(stateDir, FRPC_LOG)
|
||||
}
|
||||
|
||||
/** Read the accumulated frpc log (empty string if not yet written) for the proxy-started scan. */
|
||||
export function readFrpcLog(stateDir: string): string {
|
||||
const path = frpcLogPath(stateDir)
|
||||
if (!existsSync(path)) return ''
|
||||
try {
|
||||
return readFileSync(path, 'utf8')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Native run-loop (B4/H4 + A5): supervise the pinned frpc child with restart-on-exit backoff while a
|
||||
* periodic health probe (frpc alive, base-app loopback reachable, proxy-started, cert-not-expiring)
|
||||
* logs NON-SECRET status only (INV9), AND auto-renew the frp-client leaf at ~2/3 TTL so the tunnel
|
||||
* never drops on cert expiry (A5): a successful renewal restarts frpc onto the fresh leaf, a 403
|
||||
* revoke tears the tunnel down, and a failed renewal retries with backoff without crashing the
|
||||
* supervisor. Resolves when the supervisor stops (SIGTERM/SIGINT).
|
||||
*/
|
||||
function superviseNative(cfg: AgentConfig, ks: Keystore): Promise<number> {
|
||||
const logger = createLogger('info')
|
||||
const binPath = join(cfg.stateDir, 'bin', 'frpc')
|
||||
const tomlPath = join(cfg.stateDir, FRPC_TOML)
|
||||
// Tee the frpc child's stdout/stderr into `<stateDir>/frpc.log` (the SAME path `readFrpcLog`
|
||||
// scans below) so the proxy-started health sub-check has real content — without this wiring the
|
||||
// log stays empty and `HealthReport.healthy` can never be true (B4/H4 goal).
|
||||
const handle = superviseFrpc(binPath, tomlPath, { logger, logFile: frpcLogPath(cfg.stateDir) })
|
||||
const port = resolveLocalPort()
|
||||
const monitor = startHealthMonitor(
|
||||
() =>
|
||||
runHealthProbe({
|
||||
isFrpcAlive: () => handle.isChildAlive(),
|
||||
probeBaseApp: () => probeLoopbackBaseApp(port, (url) => fetch(url)),
|
||||
readFrpcLog: () => readFrpcLog(cfg.stateDir),
|
||||
certNotAfter: () => certNotAfter(ks),
|
||||
now: () => new Date(),
|
||||
}),
|
||||
(report) => {
|
||||
// INV9: only non-secret identifiers (subdomain/host id/expiry date) + boolean flags are logged.
|
||||
const host = resolveHostIdentity(cfg, () => ks.loadCert()?.certPem ?? null)
|
||||
const ids = { ...host, certNotAfter: certNotAfter(ks) }
|
||||
for (const line of renderHealthStatus(ids, report)) logger.log('info', line)
|
||||
},
|
||||
)
|
||||
// A5: silently renew the leaf before it expires. Restart frpc onto the fresh cert on rotation;
|
||||
// stop the whole supervisor on a 403 revoke (INV12). Null ⇒ unenrolled (auto-renew disabled).
|
||||
const autoRenew = startNativeAutoRenew(
|
||||
cfg,
|
||||
ks,
|
||||
{
|
||||
restartChild: () => handle.restartChild(),
|
||||
stop: () => {
|
||||
void handle.stop()
|
||||
},
|
||||
},
|
||||
logger,
|
||||
)
|
||||
const onSignal = (): void => {
|
||||
void handle.stop()
|
||||
}
|
||||
process.once('SIGTERM', onSignal)
|
||||
process.once('SIGINT', onSignal)
|
||||
return handle.done.finally(() => {
|
||||
monitor.stop()
|
||||
autoRenew?.stop()
|
||||
})
|
||||
}
|
||||
|
||||
/** Build the concrete CliDeps used by the real CLI entrypoint. */
|
||||
export function createCliDeps(): CliDeps {
|
||||
return {
|
||||
loadConfig: () => loadAgentConfig(process.env),
|
||||
openKeystore: (stateDir) => openKeystore(stateDir),
|
||||
generateIdentity: () => generateIdentity(),
|
||||
generateP256Identity: () => generateP256Identity(),
|
||||
redeem: (cfg: AgentConfig, code, id, ks) => redeemPairingCode(cfg.enrollUrl, code, id, ks),
|
||||
enrollNative: (cfg, code, id, ks) => enrollNative(cfg, code, id, ks),
|
||||
provisionFrpc: async (cfg) => {
|
||||
const result = await provisionFrpc({
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
binDir: join(cfg.stateDir, 'bin'),
|
||||
})
|
||||
return result.binPath
|
||||
},
|
||||
writeFrpcConfig: (cfg, subdomain) => writeFrpcConfig(cfg, subdomain),
|
||||
nativeConfigExists: (cfg) => existsSync(join(cfg.stateDir, FRPC_TOML)),
|
||||
superviseFrpc: (cfg, ks) => superviseNative(cfg, ks),
|
||||
runTunnel: async (cfg, ks) => {
|
||||
const handle = await runTunnel(cfg, ks)
|
||||
const onSignal = (): void => {
|
||||
void handle.stop()
|
||||
}
|
||||
process.once('SIGTERM', onSignal)
|
||||
process.once('SIGINT', onSignal)
|
||||
return handle.done
|
||||
},
|
||||
resolveInstallOptions: () => buildInstallOptions(process.env),
|
||||
installService: (cfg, options) =>
|
||||
installServiceUnit(cfg, requirePlatform(), realInstallDeps(), options),
|
||||
uninstallService: () => uninstallServiceUnit(requirePlatform(), { runCommand, homedir }),
|
||||
print: (line) => {
|
||||
process.stdout.write(`${line}\n`)
|
||||
},
|
||||
}
|
||||
}
|
||||
103
agent/src/config/agentConfig.ts
Normal file
103
agent/src/config/agentConfig.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Agent configuration — PLAN_RELAY_AGENT T2. Zod-validated at the startup boundary (INV9):
|
||||
* - relayUrl MUST be wss:// (encrypted tunnel only)
|
||||
* - enrollUrl MUST be https:// (enrollment over TLS only)
|
||||
* - localTargetUrl MUST be ws:// to a LOOPBACK host (anti-SSRF: the agent forwards ONLY to
|
||||
* the local web-terminal, never an arbitrary target).
|
||||
* Fail-fast on missing/invalid values — never silently default a security-relevant field.
|
||||
*/
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { isLoopbackHostLiteral } from '../net/loopbackLiteral.js'
|
||||
|
||||
export interface AgentConfig {
|
||||
readonly relayUrl: string
|
||||
readonly enrollUrl: string
|
||||
readonly stateDir: string
|
||||
readonly localTargetUrl: string
|
||||
readonly subdomain: string | null
|
||||
readonly hostId: string | null
|
||||
/**
|
||||
* Renewal endpoint used ONLY when the current leaf has already expired (the strict `/renew` vhost
|
||||
* rejects an expired client cert before it reaches the control-plane). Optional: when unset it is
|
||||
* derived from `enrollUrl` by swapping the `enroll.` label for `recover.` — see
|
||||
* `certs/rotation.ts` `recoveryRenewalUrlFor`.
|
||||
*/
|
||||
readonly recoverUrl?: string | null | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff `url` is ws:// to a loopback host (a well-formed 127.0.0.0/8 IPv4 literal, localhost, or
|
||||
* ::1). Uses the shared strict check so a crafted suffixed hostname such as
|
||||
* `ws://127.0.0.1.attacker.example.com:3000` — which the outbound dial would DNS-resolve and connect
|
||||
* to wherever it points — is REJECTED, closing the anti-SSRF bypass (not merely `startsWith('127.')`).
|
||||
*/
|
||||
export function isLoopbackWsUrl(url: string): boolean {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(url)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (parsed.protocol !== 'ws:') return false
|
||||
return isLoopbackHostLiteral(parsed.hostname)
|
||||
}
|
||||
|
||||
function hasScheme(url: string, scheme: string): boolean {
|
||||
try {
|
||||
return new URL(url).protocol === scheme
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const AgentConfigSchema = z
|
||||
.object({
|
||||
relayUrl: z
|
||||
.string()
|
||||
.refine((u) => hasScheme(u, 'wss:'), 'relayUrl must be a wss:// URL'),
|
||||
enrollUrl: z
|
||||
.string()
|
||||
.refine((u) => hasScheme(u, 'https:'), 'enrollUrl must be an https:// URL'),
|
||||
stateDir: z.string().min(1),
|
||||
localTargetUrl: z
|
||||
.string()
|
||||
.refine(isLoopbackWsUrl, 'localTargetUrl must be a ws:// loopback URL (anti-SSRF)'),
|
||||
subdomain: z.string().min(1).nullable(),
|
||||
hostId: z.string().min(1).nullable(),
|
||||
recoverUrl: z
|
||||
.string()
|
||||
.refine((u) => hasScheme(u, 'https:'), 'recoverUrl must be an https:// URL')
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.readonly()
|
||||
|
||||
const DEFAULT_LOCAL_TARGET = 'ws://127.0.0.1:3000'
|
||||
|
||||
/** Default state dir where key/cert/content-secret live (~/.web-terminal-agent). */
|
||||
export function defaultStateDir(): string {
|
||||
return join(homedir(), '.web-terminal-agent')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build + validate an AgentConfig from env + explicit argv overrides (argv wins). Throws a
|
||||
* ZodError (fail-fast) if any required field is missing or fails a scheme/loopback refinement.
|
||||
*/
|
||||
export function loadAgentConfig(
|
||||
env: NodeJS.ProcessEnv,
|
||||
argv: Partial<AgentConfig> = {},
|
||||
): AgentConfig {
|
||||
const merged = {
|
||||
relayUrl: argv.relayUrl ?? env.RELAY_URL,
|
||||
enrollUrl: argv.enrollUrl ?? env.ENROLL_URL,
|
||||
stateDir: argv.stateDir ?? env.STATE_DIR ?? defaultStateDir(),
|
||||
localTargetUrl: argv.localTargetUrl ?? env.LOCAL_TARGET_URL ?? DEFAULT_LOCAL_TARGET,
|
||||
subdomain: argv.subdomain ?? env.SUBDOMAIN ?? null,
|
||||
hostId: argv.hostId ?? env.HOST_ID ?? null,
|
||||
recoverUrl: argv.recoverUrl ?? env.RECOVER_URL ?? null,
|
||||
}
|
||||
return AgentConfigSchema.parse(merged)
|
||||
}
|
||||
97
agent/src/config/hostRecord.ts
Normal file
97
agent/src/config/hostRecord.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Enrolment identifiers (`hostId` / `subdomain`) persisted next to the keystore.
|
||||
*
|
||||
* WHY: `pair --install` learns both from the control-plane's enroll response, but nothing ever wrote
|
||||
* them down — so the long-running `run` process had `cfg.subdomain === null` and `cfg.hostId === null`
|
||||
* and every log line came out as `{"subdomain":null,"hostId":null}`. When the tunnel broke in
|
||||
* production, 6380 warnings named no host at all, which is exactly the moment you want them to.
|
||||
*
|
||||
* They are NOT secrets (the subdomain is a public DNS label), so this is a plain JSON file — kept in
|
||||
* `stateDir` only because that is the one directory the agent already owns on every platform.
|
||||
*
|
||||
* Legacy installs enrolled before this existed have no record. Their leaf still carries the
|
||||
* subdomain in its SPIFFE URI SAN, so `resolveHostIdentity` recovers it from there rather than
|
||||
* forcing a re-pair just to get an identifier back into the logs.
|
||||
*/
|
||||
import { X509Certificate } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type { AgentConfig } from './agentConfig.js'
|
||||
|
||||
const RECORD_FILE = 'host.json'
|
||||
const DIR_MODE = 0o700
|
||||
|
||||
/** Non-secret identifiers assigned by the control-plane at enrolment. */
|
||||
export interface HostRecord {
|
||||
readonly hostId: string | null
|
||||
readonly subdomain: string | null
|
||||
}
|
||||
|
||||
/** A non-empty string, or null — anything else on disk is treated as absent (never trusted). */
|
||||
function stringOrNull(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
/** Persist the enrolment identifiers into `stateDir`. Overwrites any previous record. */
|
||||
export function saveHostRecord(stateDir: string, record: HostRecord): void {
|
||||
if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true, mode: DIR_MODE })
|
||||
writeFileSync(join(stateDir, RECORD_FILE), `${JSON.stringify(record, null, 2)}\n`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persisted identifiers, or null if this host has none. A missing, unreadable, or
|
||||
* malformed file is reported as "no record" — this feeds a logging path and must never throw into
|
||||
* the run loop.
|
||||
*/
|
||||
export function loadHostRecord(stateDir: string): HostRecord | null {
|
||||
const path = join(stateDir, RECORD_FILE)
|
||||
if (!existsSync(path)) return null
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
||||
if (typeof parsed !== 'object' || parsed === null) return null
|
||||
const rec = parsed as Record<string, unknown>
|
||||
return { hostId: stringOrNull(rec['hostId']), subdomain: stringOrNull(rec['subdomain']) }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** SPIFFE IDs issued for hosts end in `/host/<subdomain>` (see relay-auth `spiffeIdFor`). */
|
||||
const SPIFFE_HOST_RE = /URI:(spiffe:\/\/[^\s,]*\/host\/([^\s,/]+))/
|
||||
|
||||
/**
|
||||
* Recover the subdomain from a leaf's SPIFFE URI SAN, or null if it carries none. Parse failures
|
||||
* are null, never throws — a legacy install with a damaged cert must still start.
|
||||
*/
|
||||
export function subdomainFromCertPem(certPem: string): string | null {
|
||||
try {
|
||||
const san = new X509Certificate(certPem).subjectAltName ?? ''
|
||||
const match = SPIFFE_HOST_RE.exec(san)
|
||||
return match?.[2] ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the identifiers to log for this host, most authoritative first:
|
||||
* 1. explicit config (argv/env `SUBDOMAIN` / `HOST_ID`) — an operator override always wins;
|
||||
* 2. the enrolment record written by `pair`;
|
||||
* 3. the subdomain embedded in the stored leaf (legacy installs; yields no hostId).
|
||||
*
|
||||
* `readCertPem` is injected so this stays a pure decision over a supplied cert.
|
||||
*/
|
||||
export function resolveHostIdentity(
|
||||
cfg: AgentConfig,
|
||||
readCertPem: () => string | null,
|
||||
): HostRecord {
|
||||
const record = loadHostRecord(cfg.stateDir)
|
||||
const subdomain =
|
||||
cfg.subdomain ??
|
||||
record?.subdomain ??
|
||||
((): string | null => {
|
||||
const pem = readCertPem()
|
||||
return pem === null ? null : subdomainFromCertPem(pem)
|
||||
})()
|
||||
return { hostId: cfg.hostId ?? record?.hostId ?? null, subdomain }
|
||||
}
|
||||
45
agent/src/dist/buildBinary.ts
vendored
Normal file
45
agent/src/dist/buildBinary.ts
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Static-binary build spec — PLAN_RELAY_AGENT T16 (EXPLORE §6 distribution rank 2). Produces a
|
||||
* `bun --compile` spec for a one-`curl | sh` install. `npx web-terminal-agent` stays the MVP path
|
||||
* (rank 1). The bundle EXCLUDES dev/test deps and any terminal parser (INV11 re-check at the
|
||||
* package boundary — the agent is a byte-shuttle, never an ANSI interpreter).
|
||||
*/
|
||||
export type BinaryTarget = 'darwin-arm64' | 'darwin-x64' | 'linux-x64' | 'linux-arm64'
|
||||
|
||||
export const BINARY_TARGETS: readonly BinaryTarget[] = [
|
||||
'darwin-arm64',
|
||||
'darwin-x64',
|
||||
'linux-x64',
|
||||
'linux-arm64',
|
||||
]
|
||||
|
||||
export interface BuildSpec {
|
||||
readonly tool: 'bun'
|
||||
readonly entry: string
|
||||
readonly target: BinaryTarget
|
||||
readonly bunTarget: string // bun's --target triple
|
||||
readonly outfile: string
|
||||
readonly minify: true
|
||||
/** Package-name substrings that must NOT appear in the bundle graph (INV11 tripwire). */
|
||||
readonly forbiddenDeps: readonly string[]
|
||||
}
|
||||
|
||||
const BUN_TRIPLE: Readonly<Record<BinaryTarget, string>> = {
|
||||
'darwin-arm64': 'bun-darwin-arm64',
|
||||
'darwin-x64': 'bun-darwin-x64',
|
||||
'linux-x64': 'bun-linux-x64',
|
||||
'linux-arm64': 'bun-linux-arm64',
|
||||
}
|
||||
|
||||
/** Build the `bun --compile` spec for a target triple. Entry is the CLI. */
|
||||
export function buildBinaryConfig(target: BinaryTarget): BuildSpec {
|
||||
return {
|
||||
tool: 'bun',
|
||||
entry: 'src/cli.ts',
|
||||
target,
|
||||
bunTarget: BUN_TRIPLE[target],
|
||||
outfile: `dist/web-terminal-agent-${target}`,
|
||||
minify: true,
|
||||
forbiddenDeps: ['xterm', 'ansi', 'vt100'],
|
||||
}
|
||||
}
|
||||
144
agent/src/e2e/hostEndpoint.ts
Normal file
144
agent/src/e2e/hostEndpoint.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Host E2E endpoint (§4.4) — PLAN_RELAY_AGENT T15. The HOST side of the authenticated ECDH: absorb
|
||||
* ClientHello → verify the device-auth-proof FIRST → reply HostHello → derive DirectionalKeys →
|
||||
* per-stream seal(h2c)/open(c2h). Distinct from live frames, every replay-bound output is ALSO
|
||||
* sealed under K_content via the T19 ReplaySealer (FIX 3).
|
||||
*
|
||||
* ANTI-MITM (INV2): the device-auth-proof `verifyDeviceProof` is P5-OWNED and reaches this module
|
||||
* INJECTED (FIX 6b) — this file MUST NOT `import { verifyDeviceAuthProof } from 'relay-e2e'`. The
|
||||
* proof is verified BEFORE any key derivation; a forged proof ⇒ MitmAbortError, NO HostHello, NO
|
||||
* DirectionalKeys. A no-stub guard test asserts this file imports no verifier and that swapping the
|
||||
* injected verifier for `async () => true` makes the MITM test fail.
|
||||
*
|
||||
* BLOCKING GATE (open Q#2 — DEFERRED): the real §4.4 crypto (`sealFrame`/`openFrame`/
|
||||
* `createE2ESession`) + host-handshake wiring (`createHostHandshake`) live in P4 `relay-e2e/`, and
|
||||
* the P5 verifier + forged-proof vector are a hard pre-W4 gate. P4 is NOT built yet, so those are
|
||||
* INJECTED here as seams typed to the frozen relay-contracts signatures — production passes the
|
||||
* relay-e2e impls verbatim, NEVER a stub. INV11: even after open(), bytes go to loopback OPAQUE.
|
||||
*/
|
||||
import type {
|
||||
ClientHello,
|
||||
E2ESession,
|
||||
HandshakeResult,
|
||||
HostHello,
|
||||
} from 'relay-contracts'
|
||||
import type { AgentIdentity } from '../keys/identity.js'
|
||||
import type { FrameTransform } from '../transport/streamRouter.js'
|
||||
import type { ReplaySealer } from './replaySeal.js'
|
||||
|
||||
/** Host-side device-proof verifier — INJECTED (P5 issues+verifies), bound to the ClientHello. */
|
||||
export type VerifyDeviceProof = (
|
||||
proof: string,
|
||||
binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array },
|
||||
) => Promise<boolean>
|
||||
|
||||
export class MitmAbortError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'MitmAbortError'
|
||||
}
|
||||
}
|
||||
|
||||
/** P4 host-handshake wiring seam (`createHostHandshake`), typed to §4.4 shapes. */
|
||||
export interface HostHandshake {
|
||||
respond(clientHello: ClientHello): Promise<{ hello: HostHello; result: HandshakeResult }>
|
||||
}
|
||||
export type CreateHostHandshake = (deps: {
|
||||
verifyDeviceProof: VerifyDeviceProof
|
||||
identity: AgentIdentity
|
||||
}) => HostHandshake
|
||||
|
||||
/** P4 §4.4 crypto core, injected (impls live in relay-e2e/). */
|
||||
export interface E2ECryptoDeps {
|
||||
createHostHandshake: CreateHostHandshake
|
||||
createE2ESession(role: 'host', result: HandshakeResult): E2ESession
|
||||
/** Wire codec for the HostHello reply (P4/P6-owned framing). */
|
||||
encodeHostHello(hello: HostHello): Uint8Array
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce the HostHello + HandshakeResult for a ClientHello. The proof is verified FIRST; a forged
|
||||
* proof aborts with MitmAbortError and NO key derivation (anti-MITM). FIX 2: the result carries
|
||||
* DirectionalKeys{c2h,h2c}, never a single sessionKey.
|
||||
*/
|
||||
export async function makeHostHello(
|
||||
clientHello: ClientHello,
|
||||
id: AgentIdentity,
|
||||
verifyDeviceProof: VerifyDeviceProof,
|
||||
deps: Pick<E2ECryptoDeps, 'createHostHandshake'>,
|
||||
): Promise<{ hello: HostHello; result: HandshakeResult }> {
|
||||
const ok = await verifyDeviceProof(clientHello.deviceAuthProof, {
|
||||
clientEphPub: clientHello.clientEphPub,
|
||||
clientNonce: clientHello.clientNonce,
|
||||
})
|
||||
if (!ok) {
|
||||
throw new MitmAbortError('device-auth-proof verification failed — aborting, no keys derived')
|
||||
}
|
||||
const handshake = deps.createHostHandshake({ verifyDeviceProof, identity: id })
|
||||
return handshake.respond(clientHello)
|
||||
}
|
||||
|
||||
/**
|
||||
* FrameTransform + a `seedSession` seam. The wiring layer intercepts the first (ClientHello) frame,
|
||||
* runs `makeHostHello` (async, verify-first), then calls `seedSession` with the derived
|
||||
* E2ESession + the encoded HostHello (queued as a control frame the router flushes upstream).
|
||||
* After seeding: inbound = session.open(c2h) → loopback (OPAQUE, INV11); outbound = session.seal
|
||||
* (h2c) → tunnel AND replay.seal(K_content) — two DISTINCT ciphertexts (FIX 3).
|
||||
*/
|
||||
export interface E2ETransform extends FrameTransform {
|
||||
seedSession(streamId: number, session: E2ESession, hostHelloBytes: Uint8Array): void
|
||||
}
|
||||
|
||||
interface StreamE2EState {
|
||||
session: E2ESession | null
|
||||
readonly control: Uint8Array[]
|
||||
}
|
||||
|
||||
export function createE2ETransform(
|
||||
_id: AgentIdentity,
|
||||
_verifyDeviceProof: VerifyDeviceProof,
|
||||
replay: ReplaySealer,
|
||||
): E2ETransform {
|
||||
const streams = new Map<number, StreamE2EState>()
|
||||
|
||||
function stateFor(streamId: number): StreamE2EState {
|
||||
let s = streams.get(streamId)
|
||||
if (s === undefined) {
|
||||
s = { session: null, control: [] }
|
||||
streams.set(streamId, s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
return {
|
||||
openStream(streamId: number): void {
|
||||
stateFor(streamId)
|
||||
},
|
||||
closeStream(streamId: number): void {
|
||||
streams.delete(streamId)
|
||||
},
|
||||
seedSession(streamId: number, session: E2ESession, hostHelloBytes: Uint8Array): void {
|
||||
const s = stateFor(streamId)
|
||||
s.session = session
|
||||
s.control.push(hostHelloBytes)
|
||||
},
|
||||
inbound(streamId: number, cipher: Uint8Array): Uint8Array | null {
|
||||
const s = stateFor(streamId)
|
||||
if (s.session === null) return null // handshake not yet seeded; wiring layer handles it
|
||||
return s.session.open(cipher) // opaque plaintext to loopback (INV11)
|
||||
},
|
||||
outbound(streamId: number, plain: Uint8Array): Uint8Array {
|
||||
const s = stateFor(streamId)
|
||||
if (s.session === null) {
|
||||
throw new MitmAbortError('cannot seal before the E2E session is established')
|
||||
}
|
||||
replay.seal(plain) // FIX 3: recoverable K_content seal, DISTINCT from the live h2c frame
|
||||
return s.session.seal(plain)
|
||||
},
|
||||
takeControlFrames(streamId: number): Uint8Array[] {
|
||||
const s = streams.get(streamId)
|
||||
if (s === undefined || s.control.length === 0) return []
|
||||
return s.control.splice(0, s.control.length)
|
||||
},
|
||||
}
|
||||
}
|
||||
70
agent/src/e2e/replaySeal.ts
Normal file
70
agent/src/e2e/replaySeal.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Replay-frame sealer (recoverable K_content) — PLAN_RELAY_AGENT T19 (FIX 3).
|
||||
*
|
||||
* Live host→client frames use the EPHEMERAL DirectionalKeys.h2c (forward-secret, gone after
|
||||
* reconnect). But "refresh the page and the Claude session is still there" needs the ring-buffer /
|
||||
* preview ciphertext to be RECOVERABLE — so every replay-bound output is ALSO sealed under the
|
||||
* host-scoped recoverable K_content = deriveContentKey({ hostContentSecret, sessionId, alg }),
|
||||
* DISTINCT from the live h2c frame. The browser re-derives the identical K_content (P5) and opens
|
||||
* it (P6). This is the single agent-side consumer of the FIX 3 recoverable key.
|
||||
*
|
||||
* INTEGRATION SEAM: the frozen §4.4 replay-crypto IMPLEMENTATIONS live in P4 `relay-e2e/`
|
||||
* (`deriveContentKey`, `sealReplayFrame`). P4 is not built yet, so they are INJECTED here typed to
|
||||
* the frozen relay-contracts signatures — production wiring passes the relay-e2e impls verbatim.
|
||||
* `hostContentSecret` comes from Keystore.loadContentSecret() (T3); NEVER the ephemeral key,
|
||||
* NEVER logged, NEVER sent to the relay (INV2/INV9).
|
||||
*
|
||||
* PER-GENERATION EPOCH (F6 fix): K_content = HKDF(hostContentSecret, salt=sessionId, info=const) is
|
||||
* byte-identical for a given (host, sessionId) and RECOVERABLE by design — but the deterministic seal
|
||||
* nonce is seq, which resets to 0 on every sealer reconstruction (restart / re-attach). Two sealer
|
||||
* GENERATIONS would therefore seal DISTINCT plaintext under the SAME (key, nonce) → catastrophic AEAD
|
||||
* reuse. Each `createReplaySealer` call mints a FRESH, NON-SECRET `epoch` (randomUUID) that is folded
|
||||
* into K_content derivation, so a restart / new generation yields a FRESH key even for the same
|
||||
* (hostContentSecret, sessionId); seq=0 can never collide across generations. The epoch is exposed on
|
||||
* the sealer so the wiring can persist it with the ring buffer / replay stream and serve it to the
|
||||
* browser, which re-derives the matching key. Recoverability WITHIN one generation (same epoch ⇒ same
|
||||
* key) is preserved.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AeadAlg, AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts'
|
||||
|
||||
/** The two §4.4 replay primitives, typed to the frozen relay-contracts signatures (P4 impls). */
|
||||
export interface ReplayCrypto {
|
||||
deriveContentKey(params: ReplayKeyParams): AeadKey
|
||||
sealReplayFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope
|
||||
}
|
||||
|
||||
export interface ReplaySealer {
|
||||
/**
|
||||
* The fresh, NON-SECRET per-generation epoch folded into K_content (F6). The wiring persists it
|
||||
* with the ring buffer / replay stream so the browser re-derives the matching key.
|
||||
*/
|
||||
readonly epoch: string
|
||||
/** K_content seal with monotonic seq per session (INV13); NOT the live h2c frame. */
|
||||
seal(plaintext: Uint8Array): E2EEnvelope
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a per-(host, session) replay sealer for ONE generation. A FRESH `epoch` is minted per call
|
||||
* and folded into K_content, which is derived ONCE from { hostContentSecret, sessionId, alg, epoch };
|
||||
* seq is strictly monotonic from 0 (INV13). A restart / re-attach constructs a NEW generation with a
|
||||
* NEW epoch ⇒ a FRESH key, so seq=0 never collides across generations (F6).
|
||||
*/
|
||||
export function createReplaySealer(
|
||||
hostContentSecret: Uint8Array,
|
||||
sessionId: string,
|
||||
alg: AeadAlg,
|
||||
crypto: ReplayCrypto,
|
||||
): ReplaySealer {
|
||||
const epoch = randomUUID()
|
||||
const key = crypto.deriveContentKey({ hostContentSecret, sessionId, alg, epoch })
|
||||
let seq = 0n
|
||||
return {
|
||||
epoch,
|
||||
seal(plaintext: Uint8Array): E2EEnvelope {
|
||||
const env = crypto.sealReplayFrame(key, seq, plaintext)
|
||||
seq += 1n
|
||||
return env
|
||||
},
|
||||
}
|
||||
}
|
||||
118
agent/src/enroll/csr.ts
Normal file
118
agent/src/enroll/csr.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* PKCS#10 CSR over the Ed25519 identity — PLAN_RELAY_AGENT T4.
|
||||
*
|
||||
* Node has no built-in CSR generator, so this is a compact, self-contained DER encoder that
|
||||
* emits a standard PKCS#10 CertificationRequest signed with the in-process Ed25519 key (INV4 —
|
||||
* only the PUBLIC key + a signature leave; the private key is never serialized here).
|
||||
*
|
||||
* NOTE (cross-plan / open Q#1): the exact SPIFFE-style cert PROFILE (SAN = subdomain vs a SPIFFE
|
||||
* URI) is set by P3's CA. This builds the standard subject-CN PKCS#10 shape; when P3 freezes its
|
||||
* profile, extend `buildCsr`'s attributes here. That is the single integration point.
|
||||
*/
|
||||
import type { AgentIdentity } from '../keys/identity.js'
|
||||
|
||||
// --- minimal DER encoding helpers -------------------------------------------------------------
|
||||
|
||||
function derLen(len: number): Uint8Array {
|
||||
if (len < 0x80) return Uint8Array.from([len])
|
||||
const bytes: number[] = []
|
||||
let n = len
|
||||
while (n > 0) {
|
||||
bytes.unshift(n & 0xff)
|
||||
n >>= 8
|
||||
}
|
||||
return Uint8Array.from([0x80 | bytes.length, ...bytes])
|
||||
}
|
||||
|
||||
function tlv(tag: number, value: Uint8Array): Uint8Array {
|
||||
const len = derLen(value.length)
|
||||
const out = new Uint8Array(1 + len.length + value.length)
|
||||
out[0] = tag
|
||||
out.set(len, 1)
|
||||
out.set(value, 1 + len.length)
|
||||
return out
|
||||
}
|
||||
|
||||
function concat(chunks: readonly Uint8Array[]): Uint8Array {
|
||||
const total = chunks.reduce((s, c) => s + c.length, 0)
|
||||
const out = new Uint8Array(total)
|
||||
let off = 0
|
||||
for (const c of chunks) {
|
||||
out.set(c, off)
|
||||
off += c.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const SEQUENCE = 0x30
|
||||
const SET = 0x31
|
||||
const INTEGER = 0x02
|
||||
const BIT_STRING = 0x03
|
||||
const OID = 0x06
|
||||
const UTF8_STRING = 0x0c
|
||||
const CONTEXT_0 = 0xa0
|
||||
|
||||
// OID 2.5.4.3 (commonName), 1.3.101.112 (Ed25519), 1.2.840.10045.4.3.2 (ecdsa-with-SHA256) as
|
||||
// pre-encoded DER value bytes.
|
||||
const OID_CN = Uint8Array.from([0x55, 0x04, 0x03])
|
||||
const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70])
|
||||
const OID_ECDSA_WITH_SHA256 = Uint8Array.from([0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x02])
|
||||
|
||||
/** SubjectPublicKeyInfo DER for a raw Ed25519 public key (fixed 44-byte structure). */
|
||||
function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
|
||||
const algId = tlv(SEQUENCE, tlv(OID, OID_ED25519))
|
||||
const pubBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), raw]))
|
||||
return tlv(SEQUENCE, concat([algId, pubBits]))
|
||||
}
|
||||
|
||||
/**
|
||||
* SubjectPublicKeyInfo bytes for the CSR. For P-256, `id.publicKey` IS already the full EC SPKI DER
|
||||
* (built by `keys/identity.ts`), so it is embedded verbatim; for Ed25519 the raw 32-byte key is
|
||||
* wrapped into the fixed SPKI structure.
|
||||
*/
|
||||
function spkiFor(id: AgentIdentity): Uint8Array {
|
||||
return id.alg === 'p256' ? id.publicKey : spkiFromRawEd25519(id.publicKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* The signatureAlgorithm AlgorithmIdentifier: `SEQUENCE { OID }` (no parameters — RFC 5758 §3.2 for
|
||||
* ecdsa-with-SHA256, and Ed25519 likewise omits parameters).
|
||||
*/
|
||||
function sigAlgFor(id: AgentIdentity): Uint8Array {
|
||||
const oid = id.alg === 'p256' ? OID_ECDSA_WITH_SHA256 : OID_ED25519
|
||||
return tlv(SEQUENCE, tlv(OID, oid))
|
||||
}
|
||||
|
||||
/** X.501 Name with a single CN=<subject> RDN. */
|
||||
function nameFromCn(cn: string): Uint8Array {
|
||||
const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))]))
|
||||
const rdn = tlv(SET, atv)
|
||||
return tlv(SEQUENCE, rdn)
|
||||
}
|
||||
|
||||
function toPem(der: Uint8Array, label: string): string {
|
||||
const b64 = Buffer.from(der).toString('base64')
|
||||
const lines = b64.match(/.{1,64}/g) ?? []
|
||||
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a PKCS#10 CSR (PEM) for `id` with subject CN=`subject`, signed by the identity's key. The
|
||||
* Ed25519 and P-256 (ecdsa-with-SHA256, FIX H-host-2) paths share this one encoder — only the SPKI
|
||||
* and the signatureAlgorithm differ. The private key is used in-process only; never serialized into
|
||||
* the output (INV4).
|
||||
*/
|
||||
export function buildCsr(id: AgentIdentity, subject: string): string {
|
||||
const version = tlv(INTEGER, Uint8Array.from([0x00]))
|
||||
const name = nameFromCn(subject)
|
||||
const spki = spkiFor(id)
|
||||
const attributes = tlv(CONTEXT_0, new Uint8Array(0)) // [0] IMPLICIT empty SET OF Attribute
|
||||
const requestInfo = tlv(SEQUENCE, concat([version, name, spki, attributes]))
|
||||
|
||||
const signature = id.sign(requestInfo) // over CertificationRequestInfo, per id.alg
|
||||
const sigAlg = sigAlgFor(id)
|
||||
const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature]))
|
||||
|
||||
const csr = tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits]))
|
||||
return toPem(csr, 'CERTIFICATE REQUEST')
|
||||
}
|
||||
169
agent/src/enroll/pair.ts
Normal file
169
agent/src/enroll/pair.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* §4.5 pairing-code redemption (agent side) — PLAN_RELAY_AGENT T4.
|
||||
*
|
||||
* REDEEM: POST /enroll { code, agentPubkey, csr } (raw code NEVER persisted — INV5).
|
||||
* RETURN: the FROZEN EnrollResult (imported from relay-contracts, never redeclared), validated
|
||||
* with EnrollResultSchema at the boundary (untrusted external data). On success the FIX 3
|
||||
* `hostContentSecret` (wrapped to this agent's Ed25519 identity by P3) is UNWRAPPED in-process
|
||||
* and persisted 0600 via the keystore; the WRAPPED bytes are never stored, the unwrapped secret
|
||||
* is never logged/re-sent (INV5/INV9).
|
||||
*
|
||||
* Only the PUBLIC key + CSR leave the host (INV4).
|
||||
*/
|
||||
import { EnrollResultSchema, decodeBase64UrlBytes, encodeBase64UrlBytes } from 'relay-contracts'
|
||||
import type { EnrollResult } from 'relay-contracts'
|
||||
import type { AgentIdentity } from '../keys/identity.js'
|
||||
import type { Keystore } from '../keys/keystore.js'
|
||||
import { buildCsr } from './csr.js'
|
||||
import { derBase64ToPem } from '../certs/pem.js'
|
||||
|
||||
/** v0.8 shared-token gate vs v0.9+ per-host Ed25519. Default is `'ed25519'` from v0.9. */
|
||||
export type EnrollMode = 'token' | 'ed25519'
|
||||
export const DEFAULT_ENROLL_MODE: EnrollMode = 'ed25519'
|
||||
|
||||
export class EnrollError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'EnrollError'
|
||||
}
|
||||
}
|
||||
export class PairingCodeSpentError extends EnrollError {
|
||||
constructor() {
|
||||
super('pairing code already redeemed (single-use)')
|
||||
this.name = 'PairingCodeSpentError'
|
||||
}
|
||||
}
|
||||
export class PairingCodeExpiredError extends EnrollError {
|
||||
constructor() {
|
||||
super('pairing code expired')
|
||||
this.name = 'PairingCodeExpiredError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap the P3-wrapped `hostContentSecret` using the agent's enrollment identity (in-process).
|
||||
* P3's exact wrap scheme is not yet frozen (cross-plan integration point); this seam is injected
|
||||
* so the real unwrap drops in without touching the redeem flow. Default = passthrough.
|
||||
*/
|
||||
export type UnwrapContentSecret = (wrapped: Uint8Array, id: AgentIdentity) => Uint8Array
|
||||
const passthroughUnwrap: UnwrapContentSecret = (wrapped) => wrapped
|
||||
|
||||
export interface RedeemOptions {
|
||||
readonly fetchImpl?: typeof fetch
|
||||
readonly mode?: EnrollMode
|
||||
readonly agentToken?: string
|
||||
readonly unwrapContentSecret?: UnwrapContentSecret
|
||||
readonly subject?: string
|
||||
/**
|
||||
* Native frp-client enroll has NO E2E content secret — the control-plane returns
|
||||
* `hostContentSecret: null`. When true, tolerate its absence (skip unwrap + storage); the plain
|
||||
* frpc byte tunnel needs no content key. Defaults false so the legacy relay path still requires it.
|
||||
*/
|
||||
readonly allowMissingContentSecret?: boolean
|
||||
}
|
||||
|
||||
interface EnrollResponseJson {
|
||||
hostId: string
|
||||
subdomain: string
|
||||
cert: string
|
||||
caChain: string
|
||||
hostContentSecret: string // base64url over the wire
|
||||
}
|
||||
|
||||
function parseEnrollResult(json: unknown, allowMissingContentSecret = false): EnrollResult {
|
||||
const j = json as Partial<EnrollResponseJson>
|
||||
if (typeof j.hostContentSecret !== 'string') {
|
||||
if (!allowMissingContentSecret) {
|
||||
throw new EnrollError('enroll response missing hostContentSecret')
|
||||
}
|
||||
// Native frp-client enroll: cert = base64(DER) string, caChain = base64(DER) string[], no content
|
||||
// key. The keystore + frpc need PEM, so convert here. Empty secret sentinel is never stored.
|
||||
const caChain: unknown = j.caChain
|
||||
if (
|
||||
typeof j.hostId !== 'string' ||
|
||||
typeof j.subdomain !== 'string' ||
|
||||
typeof j.cert !== 'string' ||
|
||||
!Array.isArray(caChain) ||
|
||||
caChain.length === 0 ||
|
||||
!caChain.every((c) => typeof c === 'string')
|
||||
) {
|
||||
throw new EnrollError('enroll response missing required fields')
|
||||
}
|
||||
return {
|
||||
hostId: j.hostId,
|
||||
subdomain: j.subdomain,
|
||||
cert: derBase64ToPem(j.cert),
|
||||
caChain: (caChain as string[]).map((c) => derBase64ToPem(c)).join(''),
|
||||
hostContentSecret: new Uint8Array(0),
|
||||
}
|
||||
}
|
||||
const candidate = {
|
||||
hostId: j.hostId,
|
||||
subdomain: j.subdomain,
|
||||
cert: j.cert,
|
||||
caChain: j.caChain,
|
||||
hostContentSecret: decodeBase64UrlBytes(j.hostContentSecret),
|
||||
}
|
||||
const result = EnrollResultSchema.safeParse(candidate)
|
||||
if (!result.success) {
|
||||
throw new EnrollError(`enroll response failed schema validation: ${result.error.message}`)
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem `code` at `enrollUrl`. Sends only pubkey + CSR (INV4); never persists the raw code
|
||||
* (INV5). Stores the returned cert + CA chain and the unwrapped hostContentSecret 0600.
|
||||
*/
|
||||
export async function redeemPairingCode(
|
||||
enrollUrl: string,
|
||||
code: string,
|
||||
id: AgentIdentity,
|
||||
ks: Keystore,
|
||||
opts: RedeemOptions = {},
|
||||
): Promise<EnrollResult> {
|
||||
const doFetch = opts.fetchImpl ?? fetch
|
||||
const mode = opts.mode ?? DEFAULT_ENROLL_MODE
|
||||
const unwrap = opts.unwrapContentSecret ?? passthroughUnwrap
|
||||
|
||||
const body =
|
||||
mode === 'token'
|
||||
? { code, agentToken: opts.agentToken ?? '' }
|
||||
: {
|
||||
code,
|
||||
agentPubkey: encodeBase64UrlBytes(id.publicKey),
|
||||
csr: buildCsr(id, opts.subject ?? 'web-terminal-agent'),
|
||||
}
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await doFetch(enrollUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
} catch (err) {
|
||||
throw new EnrollError(`enroll request failed: ${(err as Error).message}`)
|
||||
}
|
||||
|
||||
if (res.status === 409) throw new PairingCodeSpentError()
|
||||
if (res.status === 410) throw new PairingCodeExpiredError()
|
||||
if (!res.ok) throw new EnrollError(`enroll returned HTTP ${res.status}`)
|
||||
|
||||
let json: unknown
|
||||
try {
|
||||
json = await res.json()
|
||||
} catch (err) {
|
||||
throw new EnrollError(`enroll response was not JSON: ${(err as Error).message}`)
|
||||
}
|
||||
|
||||
const enroll = parseEnrollResult(json, opts.allowMissingContentSecret ?? false)
|
||||
ks.saveCert(enroll.cert, enroll.caChain)
|
||||
// FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored).
|
||||
// Native frp-client enroll has no content secret (empty sentinel) → nothing to unwrap/store.
|
||||
if (enroll.hostContentSecret.length > 0) {
|
||||
const unwrapped = unwrap(enroll.hostContentSecret, id)
|
||||
ks.saveContentSecret(unwrapped)
|
||||
}
|
||||
return enroll
|
||||
}
|
||||
182
agent/src/health/probe.ts
Normal file
182
agent/src/health/probe.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Native-tunnel health probe — TASK B4/H4 (PLAN_TUNNEL_AUTOMATION §3.2 / §5, INV9).
|
||||
*
|
||||
* Reports four independent sub-checks that together say whether this host's native frp tunnel is
|
||||
* actually serving:
|
||||
* (a) frpcAlive — the supervised frpc child process is running;
|
||||
* (b) baseAppReachable — the loopback base app answers `GET http://127.0.0.1:PORT` (loopback ONLY);
|
||||
* (c) proxyStarted — frpc logged a "start proxy success" line (control channel + proxy up);
|
||||
* (d) certFresh — the frp-client leaf's `notAfter` is beyond the renewal window (not expiring).
|
||||
*
|
||||
* Every side effect is an INJECTABLE seam (process-liveness checker, loopback HTTP probe, log
|
||||
* scanner, clock) so the logic is pure and offline-testable. The status renderer emits ONLY
|
||||
* non-secret identifiers (subdomain, host id, cert expiry date, boolean flags) — NEVER keys, certs,
|
||||
* tokens, or CSRs (INV9). No `console.log`.
|
||||
*/
|
||||
|
||||
/** Default renewal window: 8h — one third of the 24h host frp-client TTL (renew at ~2/3 TTL). */
|
||||
export const DEFAULT_CERT_RENEW_WINDOW_MS = 8 * 60 * 60 * 1000
|
||||
|
||||
/** frpc emits this line on the control channel once a proxy is registered and forwarding. */
|
||||
export const FRPC_START_SUCCESS_RE = /start proxy success/i
|
||||
|
||||
/** Loopback host the base-app probe targets — hardcoded so the probe can never reach off-host. */
|
||||
export const LOOPBACK_PROBE_HOST = '127.0.0.1'
|
||||
|
||||
const MIN_PORT = 1
|
||||
const MAX_PORT = 65535
|
||||
|
||||
/** The four independent sub-checks plus the derived overall verdict. */
|
||||
export interface HealthReport {
|
||||
/** The supervised frpc child is running. */
|
||||
readonly frpcAlive: boolean
|
||||
/** `GET http://127.0.0.1:PORT` returned a response (base app is up on loopback). */
|
||||
readonly baseAppReachable: boolean
|
||||
/** frpc logged "start proxy success" (the tunnel proxy is registered). */
|
||||
readonly proxyStarted: boolean
|
||||
/** The frp-client cert's `notAfter` is beyond the renewal window (not near expiry). */
|
||||
readonly certFresh: boolean
|
||||
/** True iff all four sub-checks pass. */
|
||||
readonly healthy: boolean
|
||||
}
|
||||
|
||||
/** Injectable side effects the probe consumes; each is independently faked in tests. */
|
||||
export interface HealthProbeSeams {
|
||||
/** Process-liveness checker (the supervisor exposes whether its frpc child is alive). */
|
||||
readonly isFrpcAlive: () => boolean
|
||||
/** Loopback HTTP probe of the base app; resolves true iff it answered ok. */
|
||||
readonly probeBaseApp: () => Promise<boolean>
|
||||
/** Returns the accumulated frpc stdout/log text to scan for the success line. */
|
||||
readonly readFrpcLog: () => string
|
||||
/** The stored frp-client leaf's `notAfter`, or null if no cert is available. */
|
||||
readonly certNotAfter: () => Date | null
|
||||
/** Current time (injected for deterministic expiry tests). */
|
||||
readonly now: () => Date
|
||||
}
|
||||
|
||||
export interface HealthProbeConfig {
|
||||
/** Certs within this many ms of `notAfter` are "near expiry" (default 8h). */
|
||||
readonly renewWindowMs?: number
|
||||
}
|
||||
|
||||
/** Pure: true iff the frpc log contains a "start proxy success" line. */
|
||||
export function frpcProxyStarted(logText: string): boolean {
|
||||
return FRPC_START_SUCCESS_RE.test(logText)
|
||||
}
|
||||
|
||||
/** Pure: true iff `notAfter` is strictly beyond the renewal window from `now` (not near expiry). */
|
||||
export function certIsFresh(notAfter: Date | null, now: Date, renewWindowMs: number): boolean {
|
||||
if (notAfter === null) return false
|
||||
return notAfter.getTime() - now.getTime() > renewWindowMs
|
||||
}
|
||||
|
||||
/** Minimal shape of a fetch response the loopback probe cares about. */
|
||||
export interface LoopbackResponse {
|
||||
readonly ok: boolean
|
||||
}
|
||||
|
||||
/** Injectable loopback HTTP fetch (real wiring passes global `fetch`). */
|
||||
export type LoopbackFetch = (url: string) => Promise<LoopbackResponse>
|
||||
|
||||
/**
|
||||
* Probe the loopback base app with `GET http://127.0.0.1:PORT/`. The host is hardcoded loopback, so
|
||||
* the probe can never reach an off-host target (anti-SSRF). Any thrown/rejected fetch — or a
|
||||
* non-integer/out-of-range port — resolves to `false` rather than propagating (a probe never throws).
|
||||
*/
|
||||
export async function probeLoopbackBaseApp(port: number, fetchImpl: LoopbackFetch): Promise<boolean> {
|
||||
if (!Number.isInteger(port) || port < MIN_PORT || port > MAX_PORT) return false
|
||||
const url = `http://${LOOPBACK_PROBE_HOST}:${port}/`
|
||||
try {
|
||||
const res = await fetchImpl(url)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Run all four sub-checks and derive the overall verdict. Never throws (each seam is guarded). */
|
||||
export async function runHealthProbe(
|
||||
seams: HealthProbeSeams,
|
||||
config: HealthProbeConfig = {},
|
||||
): Promise<HealthReport> {
|
||||
const renewWindowMs = config.renewWindowMs ?? DEFAULT_CERT_RENEW_WINDOW_MS
|
||||
const frpcAlive = seams.isFrpcAlive()
|
||||
const baseAppReachable = await seams.probeBaseApp()
|
||||
const proxyStarted = frpcProxyStarted(seams.readFrpcLog())
|
||||
const certFresh = certIsFresh(seams.certNotAfter(), seams.now(), renewWindowMs)
|
||||
const healthy = frpcAlive && baseAppReachable && proxyStarted && certFresh
|
||||
return { frpcAlive, baseAppReachable, proxyStarted, certFresh, healthy }
|
||||
}
|
||||
|
||||
/** Non-secret identifiers safe to print in `status` (INV9): NO key/cert/token/CSR material. */
|
||||
export interface StatusIdentifiers {
|
||||
readonly subdomain: string | null
|
||||
readonly hostId: string | null
|
||||
readonly certNotAfter: Date | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Render `status` lines from non-secret identifiers + a health report (INV9). Emits the subdomain,
|
||||
* host id, cert EXPIRY DATE (never the cert bytes), and the boolean sub-check flags — never any key,
|
||||
* cert, token, or CSR material.
|
||||
*/
|
||||
export function renderHealthStatus(
|
||||
ids: StatusIdentifiers,
|
||||
report: HealthReport,
|
||||
): readonly string[] {
|
||||
return [
|
||||
`subdomain: ${ids.subdomain ?? '(none)'}`,
|
||||
`host_id: ${ids.hostId ?? '(none)'}`,
|
||||
`cert_expiry: ${ids.certNotAfter ? ids.certNotAfter.toISOString() : '(unknown)'}`,
|
||||
`frpc_alive: ${report.frpcAlive}`,
|
||||
`base_app_reachable: ${report.baseAppReachable}`,
|
||||
`proxy_started: ${report.proxyStarted}`,
|
||||
`cert_fresh: ${report.certFresh}`,
|
||||
`healthy: ${report.healthy}`,
|
||||
]
|
||||
}
|
||||
|
||||
/** Default periodic health-monitor interval (30s). */
|
||||
export const DEFAULT_HEALTH_INTERVAL_MS = 30_000
|
||||
|
||||
/** Minimal injectable interval timer (fake-timer-testable). */
|
||||
export interface IntervalTimer {
|
||||
setInterval(cb: () => void, ms: number): unknown
|
||||
clearInterval(handle: unknown): void
|
||||
}
|
||||
|
||||
const realIntervalTimer: IntervalTimer = {
|
||||
setInterval: (cb, ms) => setInterval(cb, ms),
|
||||
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
|
||||
}
|
||||
|
||||
/** Handle to a running health monitor. */
|
||||
export interface HealthMonitor {
|
||||
stop(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a periodic health monitor: every `intervalMs`, run `probe()` and hand the report to
|
||||
* `onReport` (the run-loop wires this to a redacting logger — non-secret lines only). A rejected
|
||||
* probe is swallowed (a monitor must never crash the supervisor). `stop()` clears the interval.
|
||||
*/
|
||||
export function startHealthMonitor(
|
||||
probe: () => Promise<HealthReport>,
|
||||
onReport: (report: HealthReport) => void,
|
||||
opts: { intervalMs?: number; timer?: IntervalTimer } = {},
|
||||
): HealthMonitor {
|
||||
const intervalMs = opts.intervalMs ?? DEFAULT_HEALTH_INTERVAL_MS
|
||||
const timer = opts.timer ?? realIntervalTimer
|
||||
const handle = timer.setInterval(() => {
|
||||
void probe()
|
||||
.then(onReport)
|
||||
.catch(() => {
|
||||
/* a probe failure is itself an unhealthy signal; never let it crash the monitor */
|
||||
})
|
||||
}, intervalMs)
|
||||
return {
|
||||
stop(): void {
|
||||
timer.clearInterval(handle)
|
||||
},
|
||||
}
|
||||
}
|
||||
32
agent/src/index.ts
Normal file
32
agent/src/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* web-terminal-agent (P2) — public barrel. The host agent: Ed25519 identity, §4.5 pairing, the
|
||||
* §4.1 mux tunnel + loopback splice, mTLS dial + cert rotation, fast revocation, and the §4.4 E2E
|
||||
* endpoint. Imports the FROZEN shared surface from relay-contracts read-only; wires P4 relay-e2e
|
||||
* crypto via injected seams (see docs/PLAN_RELAY_AGENT.md).
|
||||
*/
|
||||
export * from './config/agentConfig.js'
|
||||
export * from './log/logger.js'
|
||||
export * from './transport/seams.js'
|
||||
export * from './keys/identity.js'
|
||||
export * from './keys/keystore.js'
|
||||
export * from './enroll/csr.js'
|
||||
export * from './enroll/pair.js'
|
||||
export { parseArgs, runCli, CliUsageError } from './cli.js'
|
||||
export type { CliArgs, CliCommand, CliDeps } from './cli.js'
|
||||
export * from './transport/frpScaffold.js'
|
||||
export * from './transport/tunnel.js'
|
||||
export * from './transport/loopback.js'
|
||||
export * from './transport/streamRouter.js'
|
||||
export * from './transport/heartbeat.js'
|
||||
export * from './transport/backoff.js'
|
||||
export * from './transport/flowControl.js'
|
||||
export * from './transport/dial.js'
|
||||
export * from './certs/rotation.js'
|
||||
export * from './lifecycle/revocation.js'
|
||||
export * from './e2e/replaySeal.js'
|
||||
export * from './e2e/hostEndpoint.js'
|
||||
export * from './dist/buildBinary.js'
|
||||
export * from './service/install.js'
|
||||
export * from './service/launchd.js'
|
||||
export * from './service/systemd.js'
|
||||
export * from './service/originConfig.js'
|
||||
143
agent/src/keys/identity.ts
Normal file
143
agent/src/keys/identity.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Agent identity — PLAN_RELAY_AGENT T3 (INV4: the private key NEVER leaves the host).
|
||||
*
|
||||
* Two key algorithms share one `AgentIdentity` shape (discriminated by `alg`):
|
||||
* - `ed25519` — the relay E2E rendezvous path (unchanged).
|
||||
* - `p256` — the native-tunnel HOST frp-client key (FIX H-host-2): the `frp-client-CA` is P-256,
|
||||
* so the host key, its CSR (ECDSA-with-SHA256), and its leaf are all P-256.
|
||||
* `AgentIdentity` exposes ONLY the public key, the §4.2 enroll fingerprint, and an in-process
|
||||
* `sign()`; there is NO API that returns or serializes the private key. The raw private key material
|
||||
* is held in a module-private closure and, for P-256, NEVER leaves the host (only the pubkey + CSR do).
|
||||
*/
|
||||
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto'
|
||||
import type { KeyObject } from 'node:crypto'
|
||||
import { encodeBase64UrlBytes } from 'relay-contracts'
|
||||
|
||||
/** Which key algorithm an identity carries. Drives CSR SPKI + signatureAlgorithm encoding. */
|
||||
export type KeyAlg = 'ed25519' | 'p256'
|
||||
|
||||
export interface AgentIdentity {
|
||||
/** Which key algorithm this identity uses (`ed25519` = relay path, `p256` = native frp-client). */
|
||||
readonly alg: KeyAlg
|
||||
/**
|
||||
* The registry-stored public key bytes (§4.2 agent_pubkey):
|
||||
* - `ed25519`: the raw 32-byte public key;
|
||||
* - `p256`: the EC SubjectPublicKeyInfo DER (what the control-plane P-256 gate compares).
|
||||
*/
|
||||
readonly publicKey: Uint8Array
|
||||
/** §4.2 enroll_fpr: base64url(SHA-256(publicKey)) — pinned by the browser E2E TOFU (§4.4). */
|
||||
readonly enrollFpr: string
|
||||
/**
|
||||
* Signature over `message` using the in-process private key (never returns the key):
|
||||
* - `ed25519`: a raw 64-byte Ed25519 signature;
|
||||
* - `p256`: a DER `ECDSA-Sig-Value` (ecdsa-with-SHA256) — the PKCS#10 signatureValue shape.
|
||||
*/
|
||||
sign(message: Uint8Array): Uint8Array
|
||||
/** Export the PRIVATE key as PKCS#8 PEM — for on-disk 0600 persistence ONLY (keystore). */
|
||||
exportPrivatePkcs8Pem(): string
|
||||
/** The underlying private KeyObject — for in-process crypto (CSR/mTLS) ONLY, never serialized to the wire. */
|
||||
privateKeyObject(): KeyObject
|
||||
}
|
||||
|
||||
/** SHA-256 fingerprint of a raw Ed25519 public key, base64url — §4.2 enroll_fpr. */
|
||||
export function computeEnrollFpr(publicKey: Uint8Array): string {
|
||||
const digest = createHash('sha256').update(publicKey).digest()
|
||||
return encodeBase64UrlBytes(new Uint8Array(digest))
|
||||
}
|
||||
|
||||
/** Raw 32-byte Ed25519 public key from a KeyObject (strips the SPKI DER prefix). */
|
||||
function rawPublicKey(pub: KeyObject): Uint8Array {
|
||||
const der = pub.export({ type: 'spki', format: 'der' })
|
||||
// Ed25519 SPKI is a fixed 44-byte structure; the raw key is the trailing 32 bytes.
|
||||
return new Uint8Array(der.subarray(der.length - 32))
|
||||
}
|
||||
|
||||
function buildIdentity(privateKey: KeyObject, publicKey: KeyObject): AgentIdentity {
|
||||
const rawPub = rawPublicKey(publicKey)
|
||||
const enrollFpr = computeEnrollFpr(rawPub)
|
||||
return {
|
||||
alg: 'ed25519',
|
||||
publicKey: rawPub,
|
||||
enrollFpr,
|
||||
sign(message: Uint8Array): Uint8Array {
|
||||
return new Uint8Array(sign(null, message, privateKey))
|
||||
},
|
||||
exportPrivatePkcs8Pem(): string {
|
||||
return privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
|
||||
},
|
||||
privateKeyObject(): KeyObject {
|
||||
return privateKey
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* P-256 identity (FIX H-host-2). `publicKey` is the EC SubjectPublicKeyInfo DER (the exact bytes the
|
||||
* control-plane frp-client gate compares against the registry); `sign` produces a DER `ECDSA-Sig-Value`
|
||||
* over the SHA-256 digest — the PKCS#10 / X.509 signatureValue shape. The private key never leaves
|
||||
* this closure (INV4); only the SPKI + CSR are emitted off-host.
|
||||
*/
|
||||
function buildP256Identity(privateKey: KeyObject, publicKey: KeyObject): AgentIdentity {
|
||||
const spkiDer = new Uint8Array(publicKey.export({ type: 'spki', format: 'der' }))
|
||||
const enrollFpr = computeEnrollFpr(spkiDer)
|
||||
return {
|
||||
alg: 'p256',
|
||||
publicKey: spkiDer,
|
||||
enrollFpr,
|
||||
sign(message: Uint8Array): Uint8Array {
|
||||
// ecdsa-with-SHA256 → DER ECDSA-Sig-Value (node's default dsaEncoding is 'der').
|
||||
return new Uint8Array(sign('sha256', message, privateKey))
|
||||
},
|
||||
exportPrivatePkcs8Pem(): string {
|
||||
return privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
|
||||
},
|
||||
privateKeyObject(): KeyObject {
|
||||
return privateKey
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate a fresh Ed25519 identity; the private key is held in-memory only (INV4). */
|
||||
export function generateIdentity(): AgentIdentity {
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
|
||||
return buildIdentity(privateKey, publicKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fresh EC P-256 identity for the native-tunnel host frp-client key (FIX H-host-2). The
|
||||
* private key is held in-memory only (INV4) and NEVER serialized off-host; only the pubkey + CSR leave.
|
||||
*/
|
||||
export function generateP256Identity(): AgentIdentity {
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' })
|
||||
return buildP256Identity(privateKey, publicKey)
|
||||
}
|
||||
|
||||
/** Reconstruct an Ed25519 identity from a stored PKCS#8 PEM private key (keystore load path). */
|
||||
export function identityFromPrivatePem(pem: string): AgentIdentity {
|
||||
const privateKey = createPrivateKey(pem)
|
||||
const publicKey = createPublicKey(privateKey)
|
||||
return buildIdentity(privateKey, publicKey)
|
||||
}
|
||||
|
||||
/** Reconstruct a P-256 identity from a stored PKCS#8 PEM private key (keystore load path). */
|
||||
export function p256IdentityFromPrivatePem(pem: string): AgentIdentity {
|
||||
const privateKey = createPrivateKey(pem)
|
||||
const publicKey = createPublicKey(privateKey)
|
||||
return buildP256Identity(privateKey, publicKey)
|
||||
}
|
||||
|
||||
/** Verify an Ed25519 signature against a raw public key — helper for tests/handshake checks. */
|
||||
export function verifySignature(
|
||||
publicKey: Uint8Array,
|
||||
message: Uint8Array,
|
||||
signature: Uint8Array,
|
||||
): boolean {
|
||||
const spkiPrefix = Uint8Array.from([
|
||||
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
||||
])
|
||||
const der = new Uint8Array(spkiPrefix.length + publicKey.length)
|
||||
der.set(spkiPrefix, 0)
|
||||
der.set(publicKey, spkiPrefix.length)
|
||||
const pub = createPublicKey({ key: Buffer.from(der), format: 'der', type: 'spki' })
|
||||
return verify(null, message, pub, signature)
|
||||
}
|
||||
136
agent/src/keys/keystore.ts
Normal file
136
agent/src/keys/keystore.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* On-disk keystore — PLAN_RELAY_AGENT T3 (INV4/INV5). Persists ONLY to `stateDir`, every secret
|
||||
* file mode `0600`. Stores: the Ed25519 private key (PKCS#8 PEM), the mTLS cert + CA chain, and
|
||||
* the FIX 3 unwrapped `hostContentSecret`. The raw pairing code is NEVER written here (T4).
|
||||
*/
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { createPrivateKey } from 'node:crypto'
|
||||
import type { AgentIdentity } from './identity.js'
|
||||
import { identityFromPrivatePem, p256IdentityFromPrivatePem } from './identity.js'
|
||||
|
||||
const SECRET_MODE = 0o600
|
||||
const DIR_MODE = 0o700
|
||||
|
||||
export interface Keystore {
|
||||
saveIdentity(id: AgentIdentity): void
|
||||
loadIdentity(): AgentIdentity | null
|
||||
saveCert(certPem: string, caChainPem: string): void
|
||||
loadCert(): { certPem: string; caChainPem: string } | null
|
||||
saveContentSecret(secret: Uint8Array): void
|
||||
loadContentSecret(): Uint8Array | null
|
||||
}
|
||||
|
||||
const KEY_FILE = 'agent.key.pem'
|
||||
const CERT_FILE = 'agent.cert.pem'
|
||||
const CA_FILE = 'agent.ca.pem'
|
||||
const CONTENT_SECRET_FILE = 'content.secret'
|
||||
|
||||
/** Corrupt/unreadable key material surfaces as a typed error (never a silent empty read). */
|
||||
export class KeystoreError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'KeystoreError'
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDir(stateDir: string): void {
|
||||
if (!existsSync(stateDir)) {
|
||||
mkdirSync(stateDir, { recursive: true, mode: DIR_MODE })
|
||||
return
|
||||
}
|
||||
// Refuse to write secrets into a world-/group-accessible directory (INV5 hard error).
|
||||
const mode = statSync(stateDir).mode & 0o777
|
||||
if ((mode & 0o077) !== 0) {
|
||||
throw new KeystoreError(
|
||||
`stateDir ${stateDir} is group/world accessible (mode ${mode.toString(8)}); refusing to write secrets`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct an `AgentIdentity` from a stored PKCS#8 PEM, branching on the key's algorithm
|
||||
* discriminant (the PKCS#8 AlgorithmIdentifier OID, surfaced as `asymmetricKeyType`): an Ed25519
|
||||
* key → the relay-path builder; an EC P-256 key → the native frp-client builder (EC SPKI publicKey).
|
||||
* A saved P-256 identity therefore round-trips as `alg: 'p256'` with a usable signing key, while
|
||||
* Ed25519 stays byte-identical. Any other algorithm is a hard error (never silently mislabeled).
|
||||
*/
|
||||
function identityFromStoredPem(pem: string): AgentIdentity {
|
||||
const key = createPrivateKey(pem)
|
||||
const alg = key.asymmetricKeyType
|
||||
if (alg === 'ed25519') return identityFromPrivatePem(pem)
|
||||
if (alg === 'ec') {
|
||||
// An `ec` key alone is not proof of P-256 — a P-384/secp256k1 key also reports `ec`. The native
|
||||
// frp-client path is P-256 ONLY, so assert the named curve before treating it as such; any other
|
||||
// curve is a hard error (never silently mislabeled as a usable P-256 identity).
|
||||
const curve = key.asymmetricKeyDetails?.namedCurve
|
||||
if (curve !== 'prime256v1') {
|
||||
throw new Error(
|
||||
`unsupported stored EC identity curve: ${curve ?? 'unknown'} (only prime256v1/P-256 is supported)`,
|
||||
)
|
||||
}
|
||||
return p256IdentityFromPrivatePem(pem)
|
||||
}
|
||||
throw new Error(`unsupported stored identity key algorithm: ${alg ?? 'unknown'}`)
|
||||
}
|
||||
|
||||
function writeSecret(path: string, data: string | Uint8Array): void {
|
||||
writeFileSync(path, data, { mode: SECRET_MODE })
|
||||
// Enforce 0600 even if a prior umask/file left it wider.
|
||||
chmodSync(path, SECRET_MODE)
|
||||
}
|
||||
|
||||
export function openKeystore(stateDir: string): Keystore {
|
||||
const keyPath = join(stateDir, KEY_FILE)
|
||||
const certPath = join(stateDir, CERT_FILE)
|
||||
const caPath = join(stateDir, CA_FILE)
|
||||
const secretPath = join(stateDir, CONTENT_SECRET_FILE)
|
||||
|
||||
return {
|
||||
saveIdentity(id: AgentIdentity): void {
|
||||
ensureDir(stateDir)
|
||||
writeSecret(keyPath, id.exportPrivatePkcs8Pem())
|
||||
},
|
||||
loadIdentity(): AgentIdentity | null {
|
||||
if (!existsSync(keyPath)) return null
|
||||
let pem: string
|
||||
try {
|
||||
pem = readFileSync(keyPath, 'utf8')
|
||||
} catch (err) {
|
||||
throw new KeystoreError(`failed to read identity key: ${(err as Error).message}`)
|
||||
}
|
||||
try {
|
||||
return identityFromStoredPem(pem)
|
||||
} catch (err) {
|
||||
throw new KeystoreError(`corrupt identity key file: ${(err as Error).message}`)
|
||||
}
|
||||
},
|
||||
saveCert(certPem: string, caChainPem: string): void {
|
||||
ensureDir(stateDir)
|
||||
writeSecret(certPath, certPem)
|
||||
writeSecret(caPath, caChainPem)
|
||||
},
|
||||
loadCert(): { certPem: string; caChainPem: string } | null {
|
||||
if (!existsSync(certPath) || !existsSync(caPath)) return null
|
||||
return {
|
||||
certPem: readFileSync(certPath, 'utf8'),
|
||||
caChainPem: readFileSync(caPath, 'utf8'),
|
||||
}
|
||||
},
|
||||
saveContentSecret(secret: Uint8Array): void {
|
||||
ensureDir(stateDir)
|
||||
writeSecret(secretPath, secret)
|
||||
},
|
||||
loadContentSecret(): Uint8Array | null {
|
||||
if (!existsSync(secretPath)) return null
|
||||
return new Uint8Array(readFileSync(secretPath))
|
||||
},
|
||||
}
|
||||
}
|
||||
68
agent/src/lifecycle/revocation.ts
Normal file
68
agent/src/lifecycle/revocation.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Revocation + GOAWAY teardown — PLAN_RELAY_AGENT T14 (INV12). Distinguishes a `revoked` GOAWAY
|
||||
* (immediate teardown, NO reconnect) from the graceful `operatorDrain`/`shutdown` reasons
|
||||
* (finish in-flight, reconnect elsewhere) using ONLY the FROZEN §4.1 3-value `GoAwayReason` +
|
||||
* `decodeGoAwayReason` — never a locally-invented numeric mapping (cross-plan drift = silent
|
||||
* INV12 defeat). An unknown numeric code FAILS CLOSED to `revoked`.
|
||||
*
|
||||
* The RevocationState seam is imported from transport/seams.ts (W0), not redeclared. Its
|
||||
* `isRevoked()` is what T10's reconnectLoop consumes (one-way edge, no task cycle).
|
||||
*/
|
||||
import { decodeGoAwayReason } from 'relay-contracts'
|
||||
import type { GoAwayReason } from 'relay-contracts'
|
||||
import type { RevocationState, RevokeReason } from '../transport/seams.js'
|
||||
|
||||
export type GoAwayAction = 'drain' | 'revoked'
|
||||
|
||||
/**
|
||||
* Pure exhaustive map over the FROZEN 3-value union. `operatorDrain`/`shutdown` → drain (reconnect
|
||||
* elsewhere); `revoked` → revoked (stop). No integer literals — the reason is already a label.
|
||||
*/
|
||||
export function classifyGoAway(reason: GoAwayReason): GoAwayAction {
|
||||
switch (reason) {
|
||||
case 'operatorDrain':
|
||||
case 'shutdown':
|
||||
return 'drain'
|
||||
case 'revoked':
|
||||
return 'revoked'
|
||||
}
|
||||
}
|
||||
|
||||
export function createRevocationState(onTeardown: () => void): RevocationState {
|
||||
let revoked = false
|
||||
return {
|
||||
isRevoked(): boolean {
|
||||
return revoked
|
||||
},
|
||||
markRevoked(_reason: RevokeReason): void {
|
||||
if (revoked) return
|
||||
revoked = true
|
||||
onTeardown() // close all streams + tunnel immediately
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a decoded GOAWAY reason to the revocation state. On `revoked`, marks the state revoked
|
||||
* (⇒ teardown + reconnect suppression). Returns the action taken.
|
||||
*/
|
||||
export function applyGoAway(reason: GoAwayReason, state: RevocationState): GoAwayAction {
|
||||
const action = classifyGoAway(reason)
|
||||
if (action === 'revoked') state.markRevoked('goaway-revoked')
|
||||
return action
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a raw GOAWAY wire code. Decodes via the FROZEN decoder; an UNKNOWN code (decoder throws)
|
||||
* FAILS CLOSED to `revoked` (a default that guessed "drain" would defeat INV12).
|
||||
*/
|
||||
export function applyGoAwayCode(code: number, state: RevocationState): GoAwayAction {
|
||||
let reason: GoAwayReason
|
||||
try {
|
||||
reason = decodeGoAwayReason(code)
|
||||
} catch {
|
||||
state.markRevoked('goaway-revoked') // fail closed
|
||||
return 'revoked'
|
||||
}
|
||||
return applyGoAway(reason, state)
|
||||
}
|
||||
75
agent/src/log/logger.ts
Normal file
75
agent/src/log/logger.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Structured redacting logger — PLAN_RELAY_AGENT T2 (INV9: secrets never logged, no console.log).
|
||||
*
|
||||
* Meta keys whose name matches a secret substring are replaced with `[REDACTED]` before the
|
||||
* line is emitted, so a caller that accidentally passes `{ privateKey, cert, pairingCode,
|
||||
* agentToken }` can never leak the value. Emission goes through an injectable sink (default
|
||||
* stderr) — `console.log` is never used.
|
||||
*/
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
export interface Logger {
|
||||
log(level: LogLevel, msg: string, meta?: Record<string, unknown>): void
|
||||
}
|
||||
|
||||
/** Sink for a formatted line. Default writes to stderr (NOT console.log). */
|
||||
export type LogSink = (line: string) => void
|
||||
|
||||
const LEVEL_ORDER: Readonly<Record<LogLevel, number>> = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40,
|
||||
}
|
||||
|
||||
/**
|
||||
* Substrings that mark a meta key as secret (case-insensitive). Matches `privateKey` (key),
|
||||
* `cert`, `caChain` (chain? no — cert covers it via 'cert'), `pairingCode` (code), `agentToken`
|
||||
* (token), etc. `nonce`/`streamId`/`hostId` intentionally do NOT match (they are not secrets).
|
||||
*/
|
||||
const REDACT_SUBSTRINGS: readonly string[] = [
|
||||
'key',
|
||||
'cert',
|
||||
'code',
|
||||
'token',
|
||||
'secret',
|
||||
'proof',
|
||||
'password',
|
||||
'pem',
|
||||
]
|
||||
|
||||
const REDACTED = '[REDACTED]'
|
||||
|
||||
function isSecretKey(name: string): boolean {
|
||||
const lower = name.toLowerCase()
|
||||
return REDACT_SUBSTRINGS.some((s) => lower.includes(s))
|
||||
}
|
||||
|
||||
function redactMeta(meta: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(meta)) {
|
||||
out[k] = isSecretKey(k) ? REDACTED : v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const defaultSink: LogSink = (line) => {
|
||||
process.stderr.write(`${line}\n`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a redacting logger at a minimum level. Lines below `level` are dropped. Secret meta
|
||||
* values are redacted by key name before serialization (INV9).
|
||||
*/
|
||||
export function createLogger(level: LogLevel, sink: LogSink = defaultSink): Logger {
|
||||
const threshold = LEVEL_ORDER[level]
|
||||
return {
|
||||
log(msgLevel, msg, meta) {
|
||||
if (LEVEL_ORDER[msgLevel] < threshold) return
|
||||
const safeMeta = meta ? redactMeta(meta) : undefined
|
||||
const metaPart = safeMeta ? ` ${JSON.stringify(safeMeta)}` : ''
|
||||
sink(`${msgLevel.toUpperCase()} ${msg}${metaPart}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
30
agent/src/main.ts
Normal file
30
agent/src/main.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* CLI bootstrap — PLAN_RELAY_PHASE1 C2. The `dist/cli.js` entrypoint (the `#!/usr/bin/env node`
|
||||
* shebang is prepended at build time via esbuild `--banner`, NOT here). Reads argv, builds the
|
||||
* real CliDeps, dispatches through `runCli`, and maps any error to a clean stderr line + exit code
|
||||
* (usage errors ⇒ 2, everything else ⇒ 1) so no invocation ever crashes with a raw stack trace.
|
||||
*/
|
||||
import { parseArgs, runCli, CliUsageError } from './cli.js'
|
||||
import { createCliDeps } from './cli/deps.js'
|
||||
|
||||
async function main(): Promise<number> {
|
||||
const argv = process.argv.slice(2)
|
||||
const deps = createCliDeps()
|
||||
try {
|
||||
return await runCli(parseArgs(argv), deps)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
process.stderr.write(`web-terminal-agent: ${message}\n`)
|
||||
return err instanceof CliUsageError ? 2 : 1
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then((code) => {
|
||||
process.exitCode = code
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
process.stderr.write(`web-terminal-agent: fatal ${message}\n`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
29
agent/src/net/loopbackLiteral.ts
Normal file
29
agent/src/net/loopbackLiteral.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Strict loopback-literal check — the single source of truth for both S-GATE-style guards:
|
||||
* - `service/install.ts` isLoopbackBindHost (BIND_HOST S-GATE, FIX C-host-1, CRITICAL)
|
||||
* - `config/agentConfig.ts` isLoopbackWsUrl (localTargetUrl anti-SSRF)
|
||||
*
|
||||
* Both previously used `value.startsWith('127.')`, which treats an arbitrary suffixed HOSTNAME
|
||||
* such as `127.0.0.1.attacker.example.com` or `127.evil.net` as loopback. Because Node resolves
|
||||
* non-literal hosts via DNS before bind()/dial, that prefix match let a crafted value defeat the
|
||||
* exact invariant the guard exists to enforce. This module fails closed: it accepts a value ONLY
|
||||
* when it is an EXACT loopback literal — `localhost`, `::1`/`[::1]`, or a fully-parsed IPv4 address
|
||||
* in 127.0.0.0/8 with NO trailing characters. Anything else (a hostname, a partial IP, `0.0.0.0`,
|
||||
* `::`) is rejected.
|
||||
*/
|
||||
import { isIPv4 } from 'node:net'
|
||||
|
||||
/** Non-IPv4 loopback literals accepted verbatim (localhost + the IPv6 loopback, with/without brackets). */
|
||||
const LOOPBACK_LITERALS: ReadonlySet<string> = new Set(['localhost', '::1', '[::1]'])
|
||||
|
||||
/**
|
||||
* True iff `host` is an EXACT loopback literal: `localhost`, `::1`, `[::1]`, or a well-formed IPv4
|
||||
* address in 127.0.0.0/8 (the whole string must parse as a dotted-quad — no trailing label). A
|
||||
* suffixed hostname like `127.0.0.1.attacker.example.com` is NOT loopback and returns false.
|
||||
*/
|
||||
export function isLoopbackHostLiteral(host: string): boolean {
|
||||
if (LOOPBACK_LITERALS.has(host)) return true
|
||||
// isIPv4 requires the FULL string to be a dotted-quad, so no trailing hostname label can slip
|
||||
// through; the first-octet check then confines it to the 127.0.0.0/8 loopback block.
|
||||
return isIPv4(host) && host.split('.')[0] === '127'
|
||||
}
|
||||
259
agent/src/provision/frpcBinary.ts
Normal file
259
agent/src/provision/frpcBinary.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Pinned frpc binary provisioner — TASK B3 (PLAN_TUNNEL_AUTOMATION §3.2 / §5 supply-chain).
|
||||
*
|
||||
* Mirrors the `dist/buildBinary.ts` verify-download discipline: detect OS/arch, select a PINNED
|
||||
* release `{ version, url, sha256 }`, download the artifact TO DISK (a temp file), verify its
|
||||
* SHA-256 against the pin BEFORE the binary is placed or executed, then place it atomically
|
||||
* (temp -> rename) with the exec bit. On hash mismatch NOTHING is placed and the temp is removed.
|
||||
*
|
||||
* Non-negotiable (§5): never `curl | sh` a streamed secret, never disable TLS verification (no `-k`)
|
||||
* — the default fetch enforces `https:` and there is no insecure escape hatch.
|
||||
*
|
||||
* frp's GitHub releases ship a `.tar.gz` whose payload is `frp_<ver>_<os>_<arch>/{frpc,frps,...}` —
|
||||
* a host cannot exec a `.tar.gz`. So AFTER the archive hash matches its pin (and only then) the bytes
|
||||
* are handed to the path-traversal-hardened extractor in `untar.ts`, which pulls out ONLY the inner
|
||||
* `frpc`; that verified binary is what gets placed. Extraction failure (no `frpc`, corrupt gzip/tar,
|
||||
* unsafe entry name, oversize) places nothing and throws `FrpcProvisionError`.
|
||||
*
|
||||
* The network fetch and the filesystem are INJECTABLE seams (`ProvisionFrpcDeps`) so tests exercise
|
||||
* URL/arch selection, hash-match extraction+placement, hash-mismatch rejection, extraction failures,
|
||||
* and unsupported-platform errors with no network access.
|
||||
*/
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { extractFrpcBinary } from './untar.js'
|
||||
|
||||
export type FrpcPlatform = 'darwin-arm64' | 'darwin-amd64' | 'linux-arm64' | 'linux-amd64'
|
||||
|
||||
export const FRPC_PLATFORMS: readonly FrpcPlatform[] = [
|
||||
'darwin-arm64',
|
||||
'darwin-amd64',
|
||||
'linux-arm64',
|
||||
'linux-amd64',
|
||||
]
|
||||
|
||||
/** A pinned frpc release artifact for one platform. `sha256` is lowercase hex over the artifact. */
|
||||
export interface FrpcReleaseRef {
|
||||
readonly version: string
|
||||
readonly url: string
|
||||
readonly sha256: string
|
||||
}
|
||||
|
||||
const FRPC_VERSION = '0.61.1'
|
||||
const RELEASE_BASE = `https://github.com/fatedier/frp/releases/download/v${FRPC_VERSION}`
|
||||
|
||||
/**
|
||||
* The pinned release map. URLs point at the real frp v0.61.1 assets and the `sha256` fields are the
|
||||
* REAL published digests from `frp_sha256_checksums.txt` (verified against the downloaded
|
||||
* darwin_arm64 archive on 2026-07-09). To bump frp: update `FRPC_VERSION` and paste the new digests
|
||||
* from that release's checksum file. Tests inject their own release map.
|
||||
*/
|
||||
export const FRPC_RELEASES: Readonly<Record<FrpcPlatform, FrpcReleaseRef>> = {
|
||||
'darwin-arm64': {
|
||||
version: FRPC_VERSION,
|
||||
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_darwin_arm64.tar.gz`,
|
||||
sha256: '3e65f13a17a284bd6013e6bb6856bc2720074cea6094cc446c1f4c3932154c2d',
|
||||
},
|
||||
'darwin-amd64': {
|
||||
version: FRPC_VERSION,
|
||||
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_darwin_amd64.tar.gz`,
|
||||
sha256: '403a0ee5e92f083a863d984b7af1e9d70ba2aaa28e87f42f1fe085adf76b8491',
|
||||
},
|
||||
'linux-arm64': {
|
||||
version: FRPC_VERSION,
|
||||
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_linux_arm64.tar.gz`,
|
||||
sha256: 'af6366f2b43920ebfe6235dba6060770399ed1fb18601e5818552bd46a7621f8',
|
||||
},
|
||||
'linux-amd64': {
|
||||
version: FRPC_VERSION,
|
||||
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_linux_amd64.tar.gz`,
|
||||
sha256: 'bff260b68ca7b1461182a46c4f34e9709ba32764eed30a15dd94ac97f50a2c40',
|
||||
},
|
||||
}
|
||||
|
||||
/** Node `os.arch()` values mapped to frp's arch tokens. */
|
||||
const ARCH_MAP: Readonly<Record<string, 'arm64' | 'amd64'>> = {
|
||||
arm64: 'arm64',
|
||||
x64: 'amd64',
|
||||
}
|
||||
const SUPPORTED_OS: ReadonlySet<string> = new Set(['darwin', 'linux'])
|
||||
|
||||
const BIN_NAME = 'frpc'
|
||||
const TMP_NAME = 'frpc.download.tmp'
|
||||
const EXEC_MODE = 0o755
|
||||
const DIR_MODE = 0o700
|
||||
|
||||
/** Map `os.platform()` + `os.arch()` to a supported `FrpcPlatform`, or `null` if unsupported. */
|
||||
export function detectFrpcPlatform(platform: string, arch: string): FrpcPlatform | null {
|
||||
const mappedArch = ARCH_MAP[arch]
|
||||
if (!SUPPORTED_OS.has(platform) || mappedArch === undefined) return null
|
||||
const key = `${platform}-${mappedArch}` as FrpcPlatform
|
||||
return FRPC_PLATFORMS.includes(key) ? key : null
|
||||
}
|
||||
|
||||
/** Injectable network fetch: returns the artifact bytes for `url`. */
|
||||
export type FrpcFetch = (url: string) => Promise<Uint8Array>
|
||||
|
||||
/** Injectable filesystem seam (all async, mirrors `node:fs/promises`). */
|
||||
export interface FrpcFsDeps {
|
||||
mkdir(dir: string): Promise<void>
|
||||
writeFile(path: string, data: Uint8Array): Promise<void>
|
||||
rename(from: string, to: string): Promise<void>
|
||||
chmod(path: string, mode: number): Promise<void>
|
||||
rm(path: string): Promise<void>
|
||||
}
|
||||
|
||||
export interface ProvisionFrpcDeps {
|
||||
readonly fetch: FrpcFetch
|
||||
readonly fs: FrpcFsDeps
|
||||
/** Override the digest function (default: node:crypto SHA-256, lowercase hex). */
|
||||
readonly computeSha256?: (data: Uint8Array) => string
|
||||
}
|
||||
|
||||
export interface ProvisionFrpcOptions {
|
||||
/** `os.platform()` (e.g. `'darwin'`, `'linux'`). */
|
||||
readonly platform: string
|
||||
/** `os.arch()` (e.g. `'arm64'`, `'x64'`). */
|
||||
readonly arch: string
|
||||
/** Directory the verified `frpc` binary is placed into. */
|
||||
readonly binDir: string
|
||||
/** Release map to select from; defaults to the pinned `FRPC_RELEASES`. */
|
||||
readonly releases?: Readonly<Record<FrpcPlatform, FrpcReleaseRef>>
|
||||
}
|
||||
|
||||
export interface ProvisionResult {
|
||||
readonly binPath: string
|
||||
readonly version: string
|
||||
readonly platform: FrpcPlatform
|
||||
}
|
||||
|
||||
/** A verify-download failure (unsupported platform, fetch error, or integrity mismatch). */
|
||||
export class FrpcProvisionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'FrpcProvisionError'
|
||||
}
|
||||
}
|
||||
|
||||
function defaultSha256(data: Uint8Array): string {
|
||||
return createHash('sha256').update(data).digest('hex')
|
||||
}
|
||||
|
||||
/** Default HTTPS fetch — enforces `https:` (never `-k`, never plain http) and a 2xx status. */
|
||||
async function defaultFetch(url: string): Promise<Uint8Array> {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(url)
|
||||
} catch {
|
||||
throw new FrpcProvisionError(`invalid frpc download URL: ${url}`)
|
||||
}
|
||||
if (parsed.protocol !== 'https:') {
|
||||
throw new FrpcProvisionError(`frpc download refuses non-https URL: ${url}`)
|
||||
}
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(url)
|
||||
} catch (err: unknown) {
|
||||
// Surface transport failures (DNS, refused, TLS) through the SAME typed error as every other
|
||||
// failure path, so callers (e.g. the autoupdate rollback path) can pattern-match uniformly.
|
||||
throw new FrpcProvisionError(
|
||||
`frpc download failed: ${err instanceof Error ? err.message : 'network error'} for ${url}`,
|
||||
)
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new FrpcProvisionError(`frpc download failed: HTTP ${res.status} for ${url}`)
|
||||
}
|
||||
return new Uint8Array(await res.arrayBuffer())
|
||||
}
|
||||
|
||||
const defaultFsDeps: FrpcFsDeps = {
|
||||
mkdir: async (dir) => {
|
||||
await mkdir(dir, { recursive: true, mode: DIR_MODE })
|
||||
},
|
||||
writeFile: async (path, data) => {
|
||||
await writeFile(path, data, { mode: 0o600 })
|
||||
},
|
||||
rename: async (from, to) => {
|
||||
await rename(from, to)
|
||||
},
|
||||
chmod: async (path, mode) => {
|
||||
await chmod(path, mode)
|
||||
},
|
||||
rm: async (path) => {
|
||||
await rm(path, { force: true })
|
||||
},
|
||||
}
|
||||
|
||||
function defaultDeps(): ProvisionFrpcDeps {
|
||||
return { fetch: defaultFetch, fs: defaultFsDeps, computeSha256: defaultSha256 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Download + verify + extract + place the pinned frpc binary for the current platform.
|
||||
*
|
||||
* Order (verify-before-extract-before-exec): detect platform -> select pin -> fetch archive ->
|
||||
* write the unverified archive to a per-invocation temp -> SHA-256 verify against the pin. On
|
||||
* mismatch: remove the temp and throw (no extraction, nothing placed). On match: gunzip+untar to
|
||||
* pull ONLY the inner `frpc` (path-traversal-safe), overwrite the temp with that verified binary,
|
||||
* chmod exec, and atomic-rename into place. Any extraction failure removes the temp and throws.
|
||||
* Nothing is ever placed or made executable before the digest matches the pin.
|
||||
*/
|
||||
export async function provisionFrpc(
|
||||
opts: ProvisionFrpcOptions,
|
||||
deps: ProvisionFrpcDeps = defaultDeps(),
|
||||
): Promise<ProvisionResult> {
|
||||
const platform = detectFrpcPlatform(opts.platform, opts.arch)
|
||||
if (platform === null) {
|
||||
throw new FrpcProvisionError(
|
||||
`unsupported platform for frpc: ${opts.platform}/${opts.arch} (supported: ${FRPC_PLATFORMS.join(', ')})`,
|
||||
)
|
||||
}
|
||||
|
||||
const releases = opts.releases ?? FRPC_RELEASES
|
||||
const release = releases[platform]
|
||||
if (release === undefined) {
|
||||
throw new FrpcProvisionError(`no pinned frpc release for platform ${platform}`)
|
||||
}
|
||||
|
||||
const computeSha256 = deps.computeSha256 ?? defaultSha256
|
||||
// Per-invocation-unique temp path: two concurrent provisions (e.g. overlapping autoupdate polls)
|
||||
// must never write/rename through the same temp file (TOCTOU). Same path is used for write+rm+rename.
|
||||
const tmpPath = join(opts.binDir, `${TMP_NAME}.${process.pid}.${randomBytes(6).toString('hex')}`)
|
||||
const binPath = join(opts.binDir, BIN_NAME)
|
||||
|
||||
const bytes = await deps.fetch(release.url)
|
||||
|
||||
await deps.fs.mkdir(opts.binDir)
|
||||
await deps.fs.writeFile(tmpPath, bytes)
|
||||
|
||||
const digest = computeSha256(bytes).toLowerCase()
|
||||
const expected = release.sha256.toLowerCase()
|
||||
if (digest !== expected) {
|
||||
await deps.fs.rm(tmpPath)
|
||||
throw new FrpcProvisionError(
|
||||
`frpc SHA-256 mismatch for ${platform}: expected ${expected}, got ${digest} — nothing placed`,
|
||||
)
|
||||
}
|
||||
|
||||
// Verified — extract ONLY the inner `frpc` from the archive. The extractor selects by basename and
|
||||
// rejects any unsafe entry name, so nothing derived from the archive can escape binDir. On any
|
||||
// extraction failure the (still-archive) temp is removed and nothing is placed.
|
||||
let frpcBytes: Uint8Array
|
||||
try {
|
||||
frpcBytes = extractFrpcBinary(bytes)
|
||||
} catch (err: unknown) {
|
||||
await deps.fs.rm(tmpPath)
|
||||
const detail = err instanceof Error ? err.message : 'unknown error'
|
||||
throw new FrpcProvisionError(
|
||||
`frpc extraction failed for ${platform}: ${detail} — nothing placed`,
|
||||
)
|
||||
}
|
||||
|
||||
// Overwrite the temp with the verified extracted binary, grant exec, then promote atomically.
|
||||
await deps.fs.writeFile(tmpPath, frpcBytes)
|
||||
await deps.fs.chmod(tmpPath, EXEC_MODE)
|
||||
await deps.fs.rename(tmpPath, binPath)
|
||||
|
||||
return { binPath, version: release.version, platform }
|
||||
}
|
||||
159
agent/src/provision/untar.ts
Normal file
159
agent/src/provision/untar.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Minimal, path-traversal-hardened tar extractor for the frp release archive — TASK B3 extraction
|
||||
* step (PLAN_TUNNEL_AUTOMATION §3.2 / §5 supply-chain).
|
||||
*
|
||||
* frp's GitHub releases ship a `.tar.gz` whose payload is `frp_<ver>_<os>_<arch>/{frpc,frps,...}` —
|
||||
* the host cannot exec a `.tar.gz`, so after `frpcBinary.ts` has VERIFIED the archive's SHA-256
|
||||
* against its pin it hands the bytes here to pull out ONLY the inner `frpc`.
|
||||
*
|
||||
* SECURITY (§5): this does NOT reconstruct the archive's directory tree on disk. The caller writes
|
||||
* the returned bytes to a FIXED destination it owns; entries are selected purely by matching the
|
||||
* (sanitized) tar name's BASENAME. Any candidate entry whose name is absolute, contains a NUL, or
|
||||
* has a `..` path segment is REJECTED (never silently skipped, so a `../frpc` decoy cannot be used
|
||||
* to derive a path). A malicious tarball can therefore never cause a write outside the caller's dir.
|
||||
*
|
||||
* Format scope: real frp archives are plain USTAR (magic `ustar `) with 100-byte names (no PAX/GNU
|
||||
* long-name/sparse entries) — verified against frp v0.61.1. The parser reads only what USTAR needs:
|
||||
* name (0..100), size octal (124..136), typeflag (156); regular files are typeflag `0` or legacy NUL.
|
||||
*/
|
||||
import { gunzipSync } from 'node:zlib'
|
||||
|
||||
const TAR_BLOCK = 512
|
||||
const NAME_OFFSET = 0
|
||||
const NAME_LEN = 100
|
||||
const SIZE_OFFSET = 124
|
||||
const SIZE_LEN = 12
|
||||
const TYPEFLAG_OFFSET = 156
|
||||
|
||||
// USTAR regular-file type flags: '0' (0x30) and the legacy NUL (0x00). Everything else (dir '5',
|
||||
// symlink '2', PAX 'x'/'g', GNU 'L', …) is not a plain file and is skipped when matching.
|
||||
const TYPE_REGULAR = 0x30
|
||||
const TYPE_LEGACY = 0x00
|
||||
|
||||
// Hard ceiling on a single extracted entry. frp's `frpc` is ~14 MB; 256 MB rejects a corrupt/hostile
|
||||
// size field before it can drive a huge allocation or an out-of-bounds read.
|
||||
const MAX_ENTRY_BYTES = 256 * 1024 * 1024
|
||||
|
||||
const FRPC_BASENAME = 'frpc'
|
||||
|
||||
/** A tar/gzip extraction failure (corrupt gzip, malformed/truncated tar, unsafe or missing entry). */
|
||||
export class TarExtractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'TarExtractError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `name` could escape a destination directory: contains a NUL, is an absolute path, or has
|
||||
* any `..` path segment. Both `/` and `\` are treated as separators (defense in depth).
|
||||
*/
|
||||
function isUnsafeEntryName(name: string): boolean {
|
||||
if (name.length === 0) return true
|
||||
if (name.includes('\0')) return true
|
||||
if (name.startsWith('/') || name.startsWith('\\')) return true
|
||||
return name.split(/[/\\]/).some((segment) => segment === '..')
|
||||
}
|
||||
|
||||
/** Last `/`- or `\`-separated segment of a tar entry name. */
|
||||
function basename(name: string): string {
|
||||
const parts = name.split(/[/\\]/)
|
||||
return parts[parts.length - 1] ?? name
|
||||
}
|
||||
|
||||
/** Decode the NUL-terminated USTAR name field of the header block at `off`. */
|
||||
function readName(tar: Uint8Array, off: number): string {
|
||||
const raw = tar.subarray(off + NAME_OFFSET, off + NAME_OFFSET + NAME_LEN)
|
||||
const nul = raw.indexOf(0)
|
||||
return new TextDecoder().decode(raw.subarray(0, nul < 0 ? NAME_LEN : nul))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a NUL/space-terminated octal numeric field. Leading spaces are tolerated (GNU pads them);
|
||||
* any non-octal digit yields `NaN` so the caller can reject a corrupt header.
|
||||
*/
|
||||
function readOctal(tar: Uint8Array, start: number, len: number): number {
|
||||
let value = 0
|
||||
let seenDigit = false
|
||||
for (let i = start; i < start + len; i++) {
|
||||
const c = tar[i]
|
||||
if (c === undefined || c === 0x00 || c === 0x20) {
|
||||
if (seenDigit) break
|
||||
continue
|
||||
}
|
||||
if (c < 0x30 || c > 0x37) return Number.NaN
|
||||
value = value * 8 + (c - 0x30)
|
||||
seenDigit = true
|
||||
}
|
||||
return seenDigit ? value : 0
|
||||
}
|
||||
|
||||
/** True if the 512-byte header block at `off` is entirely zero (the end-of-archive marker). */
|
||||
function isZeroBlock(tar: Uint8Array, off: number): boolean {
|
||||
for (let i = off; i < off + TAR_BLOCK; i++) {
|
||||
if (tar[i] !== 0) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the bytes of the FIRST regular-file entry whose safe basename equals `targetBasename`.
|
||||
*
|
||||
* Selection is by basename only — the archive's directory structure is never mapped to a path. A
|
||||
* candidate whose name is unsafe (absolute / NUL / `..`) throws rather than being used. Missing
|
||||
* entry, a truncated/corrupt tar, or an over-size entry all throw `TarExtractError` (place nothing).
|
||||
*/
|
||||
export function extractTarFileByBasename(tar: Uint8Array, targetBasename: string): Uint8Array {
|
||||
let off = 0
|
||||
while (off + TAR_BLOCK <= tar.length) {
|
||||
if (isZeroBlock(tar, off)) break // end-of-archive
|
||||
|
||||
const size = readOctal(tar, off + SIZE_OFFSET, SIZE_LEN)
|
||||
if (!Number.isInteger(size) || size < 0) {
|
||||
throw new TarExtractError('corrupt tar: invalid entry size field')
|
||||
}
|
||||
if (size > MAX_ENTRY_BYTES) {
|
||||
throw new TarExtractError(`tar entry exceeds max size (${size} > ${MAX_ENTRY_BYTES})`)
|
||||
}
|
||||
|
||||
const contentStart = off + TAR_BLOCK
|
||||
const contentEnd = contentStart + size
|
||||
if (contentEnd > tar.length) {
|
||||
throw new TarExtractError('corrupt tar: entry content is truncated')
|
||||
}
|
||||
|
||||
const typeflag = tar[off + TYPEFLAG_OFFSET]
|
||||
const isRegular = typeflag === TYPE_REGULAR || typeflag === TYPE_LEGACY
|
||||
if (isRegular) {
|
||||
const name = readName(tar, off)
|
||||
if (basename(name) === targetBasename) {
|
||||
if (isUnsafeEntryName(name)) {
|
||||
throw new TarExtractError(`refusing unsafe tar entry name: ${JSON.stringify(name)}`)
|
||||
}
|
||||
return tar.slice(contentStart, contentEnd) // copy — never a view into the archive buffer
|
||||
}
|
||||
}
|
||||
|
||||
// Advance past this entry's content, padded up to the next 512-byte boundary.
|
||||
off = contentEnd + ((TAR_BLOCK - (size % TAR_BLOCK)) % TAR_BLOCK)
|
||||
}
|
||||
throw new TarExtractError(`no "${targetBasename}" file entry found in tar archive`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gunzip a verified frp `.tar.gz` and return the inner `frpc` binary's bytes. `gunzipSync` is used
|
||||
* on already-hash-verified input (the pin gate runs first), so there is no attacker-controlled
|
||||
* decompression-bomb surface here; a corrupt/non-gzip payload throws `TarExtractError`.
|
||||
*/
|
||||
export function extractFrpcBinary(archiveGz: Uint8Array): Uint8Array {
|
||||
return extractTarFileByBasename(gunzip(archiveGz), FRPC_BASENAME)
|
||||
}
|
||||
|
||||
function gunzip(archiveGz: Uint8Array): Uint8Array {
|
||||
try {
|
||||
return new Uint8Array(gunzipSync(archiveGz))
|
||||
} catch (err: unknown) {
|
||||
const detail = err instanceof Error ? err.message : 'not a gzip stream'
|
||||
throw new TarExtractError(`corrupt frpc archive: gunzip failed (${detail})`)
|
||||
}
|
||||
}
|
||||
279
agent/src/service/install.ts
Normal file
279
agent/src/service/install.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Service install dispatcher — PLAN_RELAY_AGENT T17, re-targeted for the native tunnel
|
||||
* (PLAN_TUNNEL_AUTOMATION B5). Detects the platform and emits TWO durable units — the base-app and
|
||||
* the agent — then loads/enables both. REFUSES to install as root (EXPLORE §4d least privilege).
|
||||
* All IO is injected so the logic is unit-testable without touching the real system.
|
||||
*
|
||||
* Two safety controls are load-bearing here:
|
||||
* - FIX C-host-1 (S-GATE, CRITICAL): the base-app unit MUST bind loopback. A non-loopback
|
||||
* `BIND_HOST` (e.g. `0.0.0.0`) is REJECTED — the throw happens before any file is written, so a
|
||||
* rejected install emits nothing. An absent value is normalized to `127.0.0.1`.
|
||||
* - FIX M-host-2service: base-app env (BIND_HOST/ALLOWED_ORIGINS/PORT/…) is routed to the
|
||||
* base-app unit ONLY; the agent unit (which supervises frpc) never carries it.
|
||||
*/
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import {
|
||||
agentLabel,
|
||||
baseAppLabel,
|
||||
buildLaunchdPlist,
|
||||
launchdLoadCommand,
|
||||
launchdPlistPath,
|
||||
launchdUnloadCommand,
|
||||
type ServiceEnv,
|
||||
} from './launchd.js'
|
||||
import {
|
||||
agentUnitName,
|
||||
baseAppUnitName,
|
||||
buildSystemdUnit,
|
||||
systemdDisableCommand,
|
||||
systemdEnableCommand,
|
||||
systemdUnitPath,
|
||||
type SystemdUnitOptions,
|
||||
} from './systemd.js'
|
||||
import { DEFAULT_ORIGIN_ZONE, mergeOrigins, subdomainOrigin } from './originConfig.js'
|
||||
import { isLoopbackHostLiteral } from '../net/loopbackLiteral.js'
|
||||
|
||||
export type ServicePlatform = 'launchd' | 'systemd'
|
||||
|
||||
/**
|
||||
* Per-host packaging inputs threaded to the unit writers (PLAN_NATIVE_TUNNEL S2). All optional so
|
||||
* existing callers (and relay callers) are unaffected. `env` is the BASE-APP env (routed to the
|
||||
* base-app unit only); `baseAppExec` overrides the base-app `ExecStart` argv.
|
||||
*/
|
||||
export interface InstallOptions {
|
||||
/** Base-app env injected into the base-app unit (BIND_HOST, ALLOWED_ORIGINS, PORT, …). */
|
||||
readonly env?: ServiceEnv
|
||||
/** systemd `EnvironmentFile=` path (preferred over inline for values best kept off the unit). */
|
||||
readonly envFile?: string
|
||||
/** DNS zone label for the tunnel origin (`terminal` for native-tunnel hosts, default `term`). */
|
||||
readonly zone?: string
|
||||
/** Parent domain (e.g. `yaojia.wang`); with `cfg.subdomain` derives the tunnel ALLOWED_ORIGINS. */
|
||||
readonly domain?: string
|
||||
/** Base-app process argv (default `['node','dist/server.js']`). */
|
||||
readonly baseAppExec?: readonly string[]
|
||||
}
|
||||
|
||||
/** S0 base-app env vars the install CLI bakes into the base-app unit, passed through verbatim. */
|
||||
const BASE_APP_ENV_KEYS = [
|
||||
'ALLOWED_ORIGINS',
|
||||
'PORT',
|
||||
'SHELL_PATH',
|
||||
'IDLE_TTL',
|
||||
'USE_TMUX',
|
||||
'SCROLLBACK_BYTES',
|
||||
'MAX_PAYLOAD_BYTES',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Tunnel hosts MUST bind loopback. At the relay the device-cert mTLS is the ONLY auth gate, so a
|
||||
* default `0.0.0.0` bind (`src/config.ts`) would serve an unauth'd shell on the LAN, bypassing mTLS
|
||||
* entirely (PLAN_NATIVE_TUNNEL S0/R2, FIX C-host-1). This is the normalized loopback default.
|
||||
*/
|
||||
export const TUNNEL_DEFAULT_BIND_HOST = '127.0.0.1'
|
||||
|
||||
/** Native-tunnel origin zone → `https://<subdomain>.terminal.<domain>`; overridable via TUNNEL_ZONE. */
|
||||
export const TUNNEL_ORIGIN_ZONE = 'terminal'
|
||||
|
||||
/** Native-tunnel origin zone label; native installs MUST use this (FIX L-host-zone). */
|
||||
export const NATIVE_ORIGIN_ZONE = 'terminal'
|
||||
|
||||
/** Base-app process argv when the caller does not override it. */
|
||||
const DEFAULT_BASE_APP_EXEC: readonly string[] = ['node', 'dist/server.js']
|
||||
|
||||
/** A base-app BIND_HOST that is not loopback — the S-GATE fail-closed error (FIX C-host-1). */
|
||||
export class BindHostError extends Error {
|
||||
constructor(value: string) {
|
||||
super(
|
||||
`refusing to install: BIND_HOST="${value}" is not loopback. A tunnel host MUST bind ` +
|
||||
'127.0.0.1/::1/localhost — the device-cert mTLS at the relay is the only auth gate, so a ' +
|
||||
'0.0.0.0 (or LAN-IP) bind would serve an unauth\'d shell on the LAN. [FIX C-host-1 S-GATE]',
|
||||
)
|
||||
this.name = 'BindHostError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff `value` is a loopback bind address (a well-formed 127.0.0.0/8 IPv4 literal, ::1, or
|
||||
* localhost). Delegates to the shared strict check so a suffixed hostname such as
|
||||
* `127.0.0.1.attacker.example.com` — which Node would DNS-resolve before bind() — is REJECTED,
|
||||
* not treated as loopback (FIX C-host-1 S-GATE).
|
||||
*/
|
||||
function isLoopbackBindHost(value: string): boolean {
|
||||
return isLoopbackHostLiteral(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* S-GATE (FIX C-host-1): normalize an absent/empty BIND_HOST to loopback; REJECT any non-loopback
|
||||
* value (throws `BindHostError`). The emitted base-app unit can therefore never bind `0.0.0.0`.
|
||||
*/
|
||||
export function normalizeBindHost(value: string | undefined): string {
|
||||
if (value === undefined || value.length === 0) return TUNNEL_DEFAULT_BIND_HOST
|
||||
if (!isLoopbackBindHost(value)) throw new BindHostError(value)
|
||||
return value
|
||||
}
|
||||
|
||||
/** Assert a native-tunnel install uses the `terminal` zone (FIX L-host-zone). Throws otherwise. */
|
||||
export function assertNativeZone(zone: string | undefined): void {
|
||||
if (zone !== NATIVE_ORIGIN_ZONE) {
|
||||
throw new Error(
|
||||
`native tunnel install requires zone="${NATIVE_ORIGIN_ZONE}" (got "${zone ?? '(default term)'}")` +
|
||||
' — the base-app origin must be https://<sub>.terminal.<domain> [FIX L-host-zone]',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the per-host `InstallOptions` from the process environment (PLAN_NATIVE_TUNNEL S0/S2).
|
||||
* Sources the S0 base-app env — normalizing/gating `BIND_HOST` to loopback (S-GATE: throws on a
|
||||
* non-loopback value so no install can ever emit a LAN-exposed unit) — plus the tunnel-origin
|
||||
* `domain`/`zone` used to derive ALLOWED_ORIGINS. Pure/immutable (env is a parameter).
|
||||
*/
|
||||
export function buildInstallOptions(env: NodeJS.ProcessEnv): InstallOptions {
|
||||
const passthrough = Object.fromEntries(
|
||||
BASE_APP_ENV_KEYS.map((key) => [key, env[key]] as [string, string | undefined]).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].length > 0,
|
||||
),
|
||||
)
|
||||
// S-GATE at env-read time: a non-loopback BIND_HOST fails closed here (before any install).
|
||||
const serviceEnv: ServiceEnv = { BIND_HOST: normalizeBindHost(env.BIND_HOST), ...passthrough }
|
||||
const domain = env.TUNNEL_DOMAIN
|
||||
const envFile = env.AGENT_ENV_FILE
|
||||
const baseAppEntry = env.BASE_APP_ENTRY
|
||||
return {
|
||||
env: serviceEnv,
|
||||
...(domain ? { domain, zone: env.TUNNEL_ZONE || TUNNEL_ORIGIN_ZONE } : {}),
|
||||
...(envFile ? { envFile } : {}),
|
||||
...(baseAppEntry ? { baseAppExec: ['node', baseAppEntry] } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export class RootRefusedError extends Error {
|
||||
constructor() {
|
||||
super('refusing to install the agent service as root — run as the logged-in user (least privilege)')
|
||||
this.name = 'RootRefusedError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface InstallDeps {
|
||||
writeFile(path: string, content: string): void
|
||||
runCommand(cmd: string, args: readonly string[]): Promise<void>
|
||||
getuid(): number
|
||||
homedir(): string
|
||||
username(): string
|
||||
binPath(): string
|
||||
}
|
||||
|
||||
/** Map a Node platform to its service manager, or null if unsupported. */
|
||||
export function detectPlatform(os: NodeJS.Platform): ServicePlatform | null {
|
||||
if (os === 'darwin') return 'launchd'
|
||||
if (os === 'linux') return 'systemd'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the BASE-APP env injected into the base-app unit. Runs the S-GATE on `BIND_HOST` (throws
|
||||
* `BindHostError` on a non-loopback value, before any write) and, when a `domain` (+ `cfg.subdomain`)
|
||||
* is supplied, merges the tunnel origin `https://<subdomain>.<zone>.<domain>` into ALLOWED_ORIGINS —
|
||||
* never weakening an origin the caller already provided. Immutable.
|
||||
*/
|
||||
function resolveBaseAppEnv(cfg: AgentConfig, options: InstallOptions): ServiceEnv {
|
||||
const base = options.env ?? {}
|
||||
const bindHost = normalizeBindHost(base.BIND_HOST) // S-GATE — throws on non-loopback
|
||||
const withBind: ServiceEnv = { ...base, BIND_HOST: bindHost }
|
||||
if (!options.domain || !cfg.subdomain) return withBind
|
||||
const origin = subdomainOrigin(cfg.subdomain, options.domain, options.zone ?? DEFAULT_ORIGIN_ZONE)
|
||||
return { ...withBind, ALLOWED_ORIGINS: mergeOrigins(withBind.ALLOWED_ORIGINS, origin) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Write + load BOTH service units for `platform`: the base-app (`node dist/server.js` + base-app
|
||||
* env) and the agent (`<bin> run`, supervises frpc — no base-app env). Throws RootRefusedError if
|
||||
* running as root, or BindHostError (S-GATE) BEFORE any write if the base-app BIND_HOST is not
|
||||
* loopback (so a rejected install emits nothing).
|
||||
*/
|
||||
export async function installService(
|
||||
cfg: AgentConfig,
|
||||
platform: ServicePlatform,
|
||||
deps: InstallDeps,
|
||||
options: InstallOptions = {},
|
||||
): Promise<void> {
|
||||
if (deps.getuid() === 0) throw new RootRefusedError()
|
||||
// Resolve (and S-GATE) the base-app env BEFORE any IO — a non-loopback BIND_HOST throws here,
|
||||
// so nothing is ever written for a rejected install.
|
||||
const baseAppEnv = resolveBaseAppEnv(cfg, options)
|
||||
const bin = deps.binPath()
|
||||
const rawExec = options.baseAppExec ?? DEFAULT_BASE_APP_EXEC
|
||||
// launchd/systemd start with a minimal PATH that excludes /usr/local/bin (where a nvm/brew `node`
|
||||
// symlink usually lives), so a bare `node` program dies with EX_CONFIG(78). Use the absolute node
|
||||
// (process.execPath) and export a PATH so the units — and the base app's tmux/node-pty/frpc
|
||||
// subprocesses — resolve their tools.
|
||||
const nodePath = process.execPath
|
||||
const unitPath = `${dirname(nodePath)}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin`
|
||||
const baseAppExec = rawExec[0] === 'node' ? [nodePath, ...rawExec.slice(1)] : rawExec
|
||||
const baseAppEnvWithPath = { ...baseAppEnv, PATH: unitPath }
|
||||
// The agent unit runs `run`, which loadConfig()-validates ENROLL_URL(https)/RELAY_URL(wss) up front
|
||||
// (fail-fast). Without these in the unit env the supervisor exits 1 on every launch — so inject the
|
||||
// agent's own runtime config (NOT the base-app env) alongside PATH.
|
||||
const agentEnv: Record<string, string> = {
|
||||
PATH: unitPath,
|
||||
ENROLL_URL: cfg.enrollUrl,
|
||||
RELAY_URL: cfg.relayUrl,
|
||||
STATE_DIR: cfg.stateDir,
|
||||
LOCAL_TARGET_URL: cfg.localTargetUrl,
|
||||
}
|
||||
|
||||
if (platform === 'launchd') {
|
||||
const baseAppPath = launchdPlistPath(deps.homedir(), baseAppLabel())
|
||||
deps.writeFile(
|
||||
baseAppPath,
|
||||
buildLaunchdPlist(baseAppExec, baseAppEnvWithPath, baseAppLabel(), join(cfg.stateDir, 'base-app.log')),
|
||||
)
|
||||
const agentPath = launchdPlistPath(deps.homedir(), agentLabel())
|
||||
deps.writeFile(
|
||||
agentPath,
|
||||
buildLaunchdPlist([nodePath, bin, 'run'], agentEnv, agentLabel(), join(cfg.stateDir, 'agent.log')),
|
||||
)
|
||||
for (const path of [baseAppPath, agentPath]) {
|
||||
const { cmd, args } = launchdLoadCommand(path)
|
||||
await deps.runCommand(cmd, args)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const baseAppOptions: SystemdUnitOptions = options.envFile
|
||||
? { env: baseAppEnvWithPath, envFile: options.envFile }
|
||||
: { env: baseAppEnvWithPath }
|
||||
const baseAppPath = systemdUnitPath(deps.homedir(), baseAppUnitName())
|
||||
deps.writeFile(
|
||||
baseAppPath,
|
||||
buildSystemdUnit(baseAppExec.join(' '), deps.username(), baseAppOptions, 'web-terminal base app (loopback)'),
|
||||
)
|
||||
const agentPath = systemdUnitPath(deps.homedir(), agentUnitName())
|
||||
deps.writeFile(
|
||||
agentPath,
|
||||
buildSystemdUnit(`${nodePath} ${bin} run`, deps.username(), { env: agentEnv }, 'web-terminal host agent (frpc supervisor)'),
|
||||
)
|
||||
for (const unit of [baseAppUnitName(), agentUnitName()]) {
|
||||
const { cmd, args } = systemdEnableCommand(unit)
|
||||
await deps.runCommand(cmd, args)
|
||||
}
|
||||
}
|
||||
|
||||
/** Unload/disable BOTH service units for `platform` (agent first, then base-app). */
|
||||
export async function uninstallService(
|
||||
platform: ServicePlatform,
|
||||
deps: Pick<InstallDeps, 'runCommand' | 'homedir'>,
|
||||
): Promise<void> {
|
||||
if (platform === 'launchd') {
|
||||
for (const label of [agentLabel(), baseAppLabel()]) {
|
||||
const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir(), label))
|
||||
await deps.runCommand(cmd, args)
|
||||
}
|
||||
return
|
||||
}
|
||||
for (const unit of [agentUnitName(), baseAppUnitName()]) {
|
||||
const { cmd, args } = systemdDisableCommand(unit)
|
||||
await deps.runCommand(cmd, args)
|
||||
}
|
||||
}
|
||||
117
agent/src/service/launchd.ts
Normal file
117
agent/src/service/launchd.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* macOS launchd plist writer — PLAN_RELAY_AGENT T17. The service runs as the LOGGED-IN USER, not
|
||||
* root (EXPLORE §4d least privilege); no secrets in the plist (key/cert stay in the keystore).
|
||||
*
|
||||
* PLAN_NATIVE_TUNNEL S2: the plist can inject a caller-supplied per-host env map
|
||||
* (BIND_HOST, ALLOWED_ORIGINS, PORT, SHELL_PATH, IDLE_TTL, USE_TMUX, …) as a launchd
|
||||
* `<key>EnvironmentVariables</key><dict>…</dict>` block. Values are XML-escaped and keys are
|
||||
* sorted for deterministic, immutable output.
|
||||
*
|
||||
* PLAN_TUNNEL_AUTOMATION B5 (FIX M-host-2service): the writer is PARAMETERIZED on `label` +
|
||||
* `programArguments`, so a native-tunnel install emits TWO distinct plists — the base-app
|
||||
* (`node dist/server.js`, carrying the base-app env) and the agent (`<bin> run`, which supervises
|
||||
* frpc). Base-app env is routed to the base-app plist ONLY by the caller (`install.ts`).
|
||||
*/
|
||||
|
||||
/** Shared env-map shape for the durable-service writers (launchd + systemd). Immutable. */
|
||||
export type ServiceEnv = Readonly<Record<string, string>>
|
||||
|
||||
/** The native-tunnel agent unit (supervises frpc). */
|
||||
const AGENT_LABEL = 'com.web-terminal.agent'
|
||||
/** The base-app unit (`node dist/server.js`, loopback-bound). */
|
||||
const BASE_APP_LABEL = 'com.web-terminal.base-app'
|
||||
|
||||
export function agentLabel(): string {
|
||||
return AGENT_LABEL
|
||||
}
|
||||
export function baseAppLabel(): string {
|
||||
return BASE_APP_LABEL
|
||||
}
|
||||
/** Back-compat alias for the agent label. */
|
||||
export function launchdLabel(): string {
|
||||
return AGENT_LABEL
|
||||
}
|
||||
|
||||
export function launchdPlistPath(homedir: string, label: string = AGENT_LABEL): string {
|
||||
return `${homedir}/Library/LaunchAgents/${label}.plist`
|
||||
}
|
||||
|
||||
/** Escape the five XML-significant characters so env keys/values/paths are plist-safe. */
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/** Build the `<key>EnvironmentVariables</key><dict>…</dict>` lines (empty array when env is empty). */
|
||||
function environmentVariablesBlock(env: ServiceEnv): readonly string[] {
|
||||
const entries = Object.entries(env).sort(([a], [b]) => a.localeCompare(b))
|
||||
if (entries.length === 0) return []
|
||||
const lines = [' <key>EnvironmentVariables</key>', ' <dict>']
|
||||
for (const [key, value] of entries) {
|
||||
lines.push(` <key>${escapeXml(key)}</key>`)
|
||||
lines.push(` <string>${escapeXml(value)}</string>`)
|
||||
}
|
||||
lines.push(' </dict>')
|
||||
return lines
|
||||
}
|
||||
|
||||
/** Build the `<key>ProgramArguments</key><array>…</array>` lines. */
|
||||
function programArgumentsBlock(programArguments: readonly string[]): readonly string[] {
|
||||
const lines = [' <key>ProgramArguments</key>', ' <array>']
|
||||
for (const arg of programArguments) {
|
||||
lines.push(` <string>${escapeXml(arg)}</string>`)
|
||||
}
|
||||
lines.push(' </array>')
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a plist for `label` running `programArguments` (e.g. `[bin, 'run']` or `[node, serverJs]`);
|
||||
* RunAtLoad + KeepAlive (restart on failure). When `env` is non-empty, a launchd
|
||||
* `EnvironmentVariables` dict is injected. Pure/immutable — returns a fresh string.
|
||||
*/
|
||||
export function buildLaunchdPlist(
|
||||
programArguments: readonly string[],
|
||||
env: ServiceEnv = {},
|
||||
label: string = AGENT_LABEL,
|
||||
logPath?: string,
|
||||
): string {
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
||||
'<plist version="1.0">',
|
||||
'<dict>',
|
||||
' <key>Label</key>',
|
||||
` <string>${escapeXml(label)}</string>`,
|
||||
...programArgumentsBlock(programArguments),
|
||||
' <key>RunAtLoad</key>',
|
||||
' <true/>',
|
||||
' <key>KeepAlive</key>',
|
||||
' <true/>',
|
||||
// launchd's default has no log sink (unlike systemd's journald), so a crashing unit is silent.
|
||||
// Route stdout+stderr to a file so `pair --install` failures are diagnosable out of the box.
|
||||
...(logPath !== undefined
|
||||
? [
|
||||
' <key>StandardOutPath</key>',
|
||||
` <string>${escapeXml(logPath)}</string>`,
|
||||
' <key>StandardErrorPath</key>',
|
||||
` <string>${escapeXml(logPath)}</string>`,
|
||||
]
|
||||
: []),
|
||||
...environmentVariablesBlock(env),
|
||||
'</dict>',
|
||||
'</plist>',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function launchdLoadCommand(plistPath: string): { cmd: string; args: readonly string[] } {
|
||||
return { cmd: 'launchctl', args: ['load', plistPath] }
|
||||
}
|
||||
export function launchdUnloadCommand(plistPath: string): { cmd: string; args: readonly string[] } {
|
||||
return { cmd: 'launchctl', args: ['unload', plistPath] }
|
||||
}
|
||||
81
agent/src/service/originConfig.ts
Normal file
81
agent/src/service/originConfig.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* The ONE base-app touch-point — PLAN_RELAY_AGENT T17 (INDEX §0, EXPLORE §3 "Zero code change").
|
||||
* APPENDS `https://<subdomain>.<zone>.<domain>` to the base app's ALLOWED_ORIGINS env (idempotent),
|
||||
* as CONFIG — NO `src/` code edit. AUGMENTS, never weakens, the Origin/CSWSH check: existing
|
||||
* origins are always preserved (EXPLORE §3 "do not weaken the check").
|
||||
*
|
||||
* PLAN_NATIVE_TUNNEL S2: the DNS zone label is PARAMETERIZED. Relay callers keep the historical
|
||||
* `term` zone (default), while native-tunnel hosts pass `terminal` so the base app trusts
|
||||
* `https://<name>.terminal.<domain>`. The default is preserved so existing callers/tests are
|
||||
* unaffected — the zone is opt-in per call, never hard-flipped.
|
||||
*/
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
|
||||
export interface OriginFsDeps {
|
||||
exists(path: string): boolean
|
||||
read(path: string): string
|
||||
write(path: string, content: string): void
|
||||
}
|
||||
|
||||
const defaultFs: OriginFsDeps = {
|
||||
exists: existsSync,
|
||||
read: (p) => readFileSync(p, 'utf8'),
|
||||
write: (p, c) => writeFileSync(p, c),
|
||||
}
|
||||
|
||||
/** Default DNS zone label (relay hosts). Native-tunnel hosts pass `terminal`. */
|
||||
export const DEFAULT_ORIGIN_ZONE = 'term'
|
||||
|
||||
const KEY = 'ALLOWED_ORIGINS'
|
||||
|
||||
/** Compose the subdomain origin the base app must trust: `https://<subdomain>.<zone>.<domain>`. */
|
||||
export function subdomainOrigin(
|
||||
subdomain: string,
|
||||
domain: string,
|
||||
zone: string = DEFAULT_ORIGIN_ZONE,
|
||||
): string {
|
||||
return `https://${subdomain}.${zone}.${domain}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `origin` into a comma-separated ALLOWED_ORIGINS value, preserving every existing origin and
|
||||
* de-duplicating. Returns the merged CSV; never removes an origin. Pure/immutable.
|
||||
*/
|
||||
export function mergeOrigins(current: string | undefined, origin: string): string {
|
||||
const origins = (current ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
if (origins.includes(origin)) return origins.join(',')
|
||||
return [...origins, origin].join(',')
|
||||
}
|
||||
|
||||
function upsertOriginLine(content: string, origin: string): string {
|
||||
const lines = content.length === 0 ? [] : content.split('\n')
|
||||
let found = false
|
||||
const next = lines.map((line) => {
|
||||
if (!line.startsWith(`${KEY}=`)) return line
|
||||
found = true
|
||||
return `${KEY}=${mergeOrigins(line.slice(KEY.length + 1), origin)}`
|
||||
})
|
||||
if (!found) next.push(`${KEY}=${origin}`)
|
||||
return next.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently append the subdomain origin to ALLOWED_ORIGINS in `baseAppEnvPath`. Never removes an
|
||||
* existing origin. Creates the file/line if absent. `zone` selects the DNS zone label (default
|
||||
* preserves relay callers).
|
||||
*/
|
||||
export function ensureAllowedOrigin(
|
||||
baseAppEnvPath: string,
|
||||
subdomain: string,
|
||||
domain: string,
|
||||
fs: OriginFsDeps = defaultFs,
|
||||
zone: string = DEFAULT_ORIGIN_ZONE,
|
||||
): void {
|
||||
const origin = subdomainOrigin(subdomain, domain, zone)
|
||||
const existing = fs.exists(baseAppEnvPath) ? fs.read(baseAppEnvPath) : ''
|
||||
const updated = upsertOriginLine(existing, origin)
|
||||
fs.write(baseAppEnvPath, updated.endsWith('\n') ? updated : `${updated}\n`)
|
||||
}
|
||||
143
agent/src/service/systemd.ts
Normal file
143
agent/src/service/systemd.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Linux systemd unit writer — PLAN_RELAY_AGENT T17. Runs as the LOGGED-IN USER (never root,
|
||||
* EXPLORE §4d), restart-on-failure; no secrets in the unit (key/cert stay in the keystore).
|
||||
*
|
||||
* PLAN_NATIVE_TUNNEL S2: the unit can inject the per-host tunnel env via an `EnvironmentFile=`
|
||||
* line (preferred — keeps values out of the world-readable unit) and/or inline `Environment=`
|
||||
* lines from a caller-supplied env map. Inline `Environment=` is emitted after `EnvironmentFile=`
|
||||
* so an explicit value overrides the file on conflict.
|
||||
*
|
||||
* PLAN_TUNNEL_AUTOMATION B5 (FIX M-host-2service): the writer is PARAMETERIZED on the full
|
||||
* `ExecStart` command + `Description`, so a native-tunnel install emits TWO distinct units — the
|
||||
* base-app (`node dist/server.js`, carrying the base-app env) and the agent (`<bin> run`, which
|
||||
* supervises frpc). Base-app env is routed to the base-app unit ONLY by the caller (`install.ts`).
|
||||
*/
|
||||
import type { ServiceEnv } from './launchd.js'
|
||||
|
||||
/** The native-tunnel agent unit (supervises frpc). */
|
||||
const AGENT_UNIT = 'web-terminal-agent.service'
|
||||
/** The base-app unit (`node dist/server.js`, loopback-bound). */
|
||||
const BASE_APP_UNIT = 'web-terminal-base-app.service'
|
||||
|
||||
/** DEL (0x7F) and everything below the printable ASCII range are rejected in env values. */
|
||||
const FIRST_PRINTABLE_ASCII = 0x20
|
||||
const DEL_CODE = 0x7f
|
||||
|
||||
export interface SystemdUnitOptions {
|
||||
/** Inline env map → one `Environment="KEY=value"` line each (keys sorted, values escaped). */
|
||||
readonly env?: ServiceEnv
|
||||
/** Path referenced by a single `EnvironmentFile=` line (preferred over inline for secrets). */
|
||||
readonly envFile?: string
|
||||
}
|
||||
|
||||
export function agentUnitName(): string {
|
||||
return AGENT_UNIT
|
||||
}
|
||||
export function baseAppUnitName(): string {
|
||||
return BASE_APP_UNIT
|
||||
}
|
||||
/** Back-compat alias for the agent unit name. */
|
||||
export function systemdUnitName(): string {
|
||||
return AGENT_UNIT
|
||||
}
|
||||
|
||||
export function systemdUnitPath(homedir: string, unitName: string = AGENT_UNIT): string {
|
||||
return `${homedir}/.config/systemd/user/${unitName}`
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `value` contains any control character (C0 range below 0x20, or DEL 0x7F). A raw
|
||||
* newline/CR would terminate the current line and let the remainder inject arbitrary directives into
|
||||
* the `[Service]` section — so such values are rejected rather than escaped.
|
||||
*/
|
||||
function hasControlChar(value: string): boolean {
|
||||
for (const char of value) {
|
||||
const code = char.codePointAt(0)
|
||||
if (code === undefined) continue
|
||||
if (code < FIRST_PRINTABLE_ASCII || code === DEL_CODE) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject ANY field interpolated into the unit that carries a control character. EVERY value written
|
||||
* into the unit (ExecStart, User, Description, EnvironmentFile path, and each Environment key+value)
|
||||
* flows through this one guard, so no field can smuggle a newline that starts a new `[Service]`
|
||||
* directive. Fail loud rather than escape — these fields are never legitimately multi-line.
|
||||
*/
|
||||
function assertNoControlChar(fieldName: string, value: string): void {
|
||||
if (hasControlChar(value)) {
|
||||
throw new Error(
|
||||
`refusing to write systemd unit: ${fieldName} contains a control character ` +
|
||||
'(newline/CR/etc.) that could corrupt the [Service] section',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Quote a systemd `Environment=` value: reject control chars (key AND value), then escape `\` and `"`. */
|
||||
function quoteEnvAssignment(key: string, value: string): string {
|
||||
assertNoControlChar(`Environment key '${key}'`, key)
|
||||
assertNoControlChar(`Environment value for '${key}'`, value)
|
||||
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
return `"${key}=${escaped}"`
|
||||
}
|
||||
|
||||
/** Build the `EnvironmentFile=` / `Environment=` lines (empty array when neither is supplied). */
|
||||
function environmentLines(options: SystemdUnitOptions): readonly string[] {
|
||||
const lines: string[] = []
|
||||
if (options.envFile) {
|
||||
assertNoControlChar('EnvironmentFile path', options.envFile)
|
||||
lines.push(`EnvironmentFile=${options.envFile}`)
|
||||
}
|
||||
const entries = Object.entries(options.env ?? {}).sort(([a], [b]) => a.localeCompare(b))
|
||||
for (const [key, value] of entries) {
|
||||
lines.push(`Environment=${quoteEnvAssignment(key, value)}`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a unit whose `ExecStart` is `execStart` (a full command line, e.g. `<bin> run` or
|
||||
* `node dist/server.js`); Restart=on-failure; User=<user> (never root). When `options.env`/
|
||||
* `options.envFile` are supplied, the per-host tunnel env is injected. Pure/immutable.
|
||||
*/
|
||||
export function buildSystemdUnit(
|
||||
execStart: string,
|
||||
user: string,
|
||||
options: SystemdUnitOptions = {},
|
||||
description: string = 'web-terminal host agent',
|
||||
): string {
|
||||
// Guard every non-env field the same way env values are guarded — a newline in ExecStart/User/
|
||||
// Description would otherwise inject a `[Service]` directive on the next line.
|
||||
assertNoControlChar('ExecStart', execStart)
|
||||
assertNoControlChar('User', user)
|
||||
assertNoControlChar('Description', description)
|
||||
return [
|
||||
'[Unit]',
|
||||
`Description=${description}`,
|
||||
'After=network-online.target',
|
||||
'',
|
||||
'[Service]',
|
||||
'Type=simple',
|
||||
`ExecStart=${execStart}`,
|
||||
'Restart=on-failure',
|
||||
'RestartSec=1',
|
||||
`User=${user}`,
|
||||
...environmentLines(options),
|
||||
'',
|
||||
'[Install]',
|
||||
'WantedBy=default.target',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function systemdEnableCommand(
|
||||
unitName: string = AGENT_UNIT,
|
||||
): { cmd: string; args: readonly string[] } {
|
||||
return { cmd: 'systemctl', args: ['--user', 'enable', '--now', unitName] }
|
||||
}
|
||||
export function systemdDisableCommand(
|
||||
unitName: string = AGENT_UNIT,
|
||||
): { cmd: string; args: readonly string[] } {
|
||||
return { cmd: 'systemctl', args: ['--user', 'disable', '--now', unitName] }
|
||||
}
|
||||
63
agent/src/transport/backoff.ts
Normal file
63
agent/src/transport/backoff.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Reconnection / backoff — PLAN_RELAY_AGENT T10. REUSES the base app's 1/2/4…cap-30s policy
|
||||
* (EXPLORE §3). `reconnectLoop` only CONSUMES an injected `isRevoked()` (the W0 seam) — a revoked
|
||||
* host short-circuits and never reconnects (INV12). No task edge to T14.
|
||||
*/
|
||||
import type { Tunnel } from './tunnel.js'
|
||||
|
||||
export const BACKOFF_BASE_MS = 1_000
|
||||
export const BACKOFF_CAP_MS = 30_000
|
||||
|
||||
export interface BackoffPolicy {
|
||||
nextDelayMs(): number
|
||||
reset(): void
|
||||
}
|
||||
|
||||
/** Exponential backoff 1s,2s,4s…capped at 30s, optional [0.5×,1×] jitter. */
|
||||
export function createBackoff(
|
||||
opts: { baseMs?: number; capMs?: number; jitter?: boolean; rng?: () => number } = {},
|
||||
): BackoffPolicy {
|
||||
const baseMs = opts.baseMs ?? BACKOFF_BASE_MS
|
||||
const capMs = opts.capMs ?? BACKOFF_CAP_MS
|
||||
const jitter = opts.jitter ?? false
|
||||
const rng = opts.rng ?? Math.random
|
||||
let attempt = 0
|
||||
return {
|
||||
nextDelayMs(): number {
|
||||
const raw = Math.min(baseMs * 2 ** attempt, capMs)
|
||||
attempt += 1
|
||||
if (!jitter) return raw
|
||||
return Math.round(raw * (0.5 + rng() * 0.5)) // [0.5×, 1×]
|
||||
},
|
||||
reset(): void {
|
||||
attempt = 0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type Sleep = (ms: number) => Promise<void>
|
||||
const realSleep: Sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
/**
|
||||
* Dial with backoff until success (resolves the connected Tunnel) or the host is revoked
|
||||
* (resolves null — never reconnect). Each dial failure waits `backoff.nextDelayMs()`; a success
|
||||
* resets the backoff.
|
||||
*/
|
||||
export async function reconnectLoop(
|
||||
dial: () => Promise<Tunnel>,
|
||||
backoff: BackoffPolicy,
|
||||
isRevoked: () => boolean,
|
||||
sleep: Sleep = realSleep,
|
||||
): Promise<Tunnel | null> {
|
||||
while (!isRevoked()) {
|
||||
try {
|
||||
const tunnel = await dial()
|
||||
backoff.reset()
|
||||
return tunnel
|
||||
} catch {
|
||||
if (isRevoked()) return null
|
||||
await sleep(backoff.nextDelayMs())
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
109
agent/src/transport/dial.ts
Normal file
109
agent/src/transport/dial.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Outbound mTLS dial — PLAN_RELAY_AGENT T12 (INV14/INV4). Builds a wss:// client authenticated by
|
||||
* the client cert + IN-PROCESS Ed25519 key + pinned CA chain, `rejectUnauthorized: true`, and NO
|
||||
* bearer/agent token (mTLS IS the auth). Absent/expired cert ⇒ fail-fast, no dial.
|
||||
*/
|
||||
import { X509Certificate } from 'node:crypto'
|
||||
import type { Keystore } from '../keys/keystore.js'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import type { WsLike } from './seams.js'
|
||||
|
||||
export class NotEnrolledError extends Error {
|
||||
constructor() {
|
||||
super('agent is not enrolled (no identity/cert in keystore)')
|
||||
this.name = 'NotEnrolledError'
|
||||
}
|
||||
}
|
||||
export class CertExpiredError extends Error {
|
||||
constructor() {
|
||||
super('client certificate has expired; renew before dialling')
|
||||
this.name = 'CertExpiredError'
|
||||
}
|
||||
}
|
||||
|
||||
/** TLS material for the wss client. NOTE: `rejectUnauthorized` is ALWAYS true (anti-MITM). */
|
||||
export interface TlsClientOptions {
|
||||
readonly cert: string
|
||||
readonly key: string
|
||||
/**
|
||||
* CA(s) to verify the SERVER cert against. Set to the private tunnel CA for the relay dial; left
|
||||
* undefined for a request whose server is publicly trusted (the LE-fronted control-plane /renew) so
|
||||
* Node verifies against the system roots — pinning the private CA there fails with "unable to get
|
||||
* local issuer certificate".
|
||||
*/
|
||||
readonly ca?: string
|
||||
readonly rejectUnauthorized: true
|
||||
}
|
||||
|
||||
export interface CertInfo {
|
||||
readonly validTo: Date
|
||||
}
|
||||
export type CertParser = (certPem: string) => CertInfo
|
||||
const defaultCertParser: CertParser = (pem) => ({ validTo: new Date(new X509Certificate(pem).validTo) })
|
||||
|
||||
/**
|
||||
* Assemble the mTLS options from the keystore. Throws NotEnrolledError if key/cert are missing,
|
||||
* CertExpiredError if the cert is past `validTo`. There is NO token field by construction (INV4).
|
||||
*/
|
||||
export function buildTlsOptions(
|
||||
ks: Keystore,
|
||||
opts: { now?: Date; certParser?: CertParser } = {},
|
||||
): TlsClientOptions {
|
||||
const id = ks.loadIdentity()
|
||||
const certs = ks.loadCert()
|
||||
if (id === null || certs === null) throw new NotEnrolledError()
|
||||
const parse = opts.certParser ?? defaultCertParser
|
||||
const now = opts.now ?? new Date()
|
||||
if (parse(certs.certPem).validTo.getTime() < now.getTime()) throw new CertExpiredError()
|
||||
return {
|
||||
cert: certs.certPem,
|
||||
key: id.exportPrivatePkcs8Pem(),
|
||||
ca: certs.caChainPem,
|
||||
rejectUnauthorized: true,
|
||||
}
|
||||
}
|
||||
|
||||
export interface RawTlsWs {
|
||||
send(data: Uint8Array): void
|
||||
close(): void
|
||||
on(event: string, cb: (...args: unknown[]) => void): void
|
||||
once(event: string, cb: (...args: unknown[]) => void): void
|
||||
}
|
||||
export type TlsWsConstructor = new (url: string, opts: TlsClientOptions) => RawTlsWs
|
||||
|
||||
function toU8(data: unknown): Uint8Array | null {
|
||||
if (data instanceof Uint8Array) return data
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||
return null
|
||||
}
|
||||
|
||||
function adapt(raw: RawTlsWs): WsLike {
|
||||
return {
|
||||
send: (d) => raw.send(d),
|
||||
on(ev, cb) {
|
||||
if (ev === 'message') {
|
||||
raw.on('message', (data: unknown) => {
|
||||
const bytes = toU8(data)
|
||||
if (bytes !== null) cb(bytes)
|
||||
})
|
||||
} else {
|
||||
raw.on(ev, cb)
|
||||
}
|
||||
},
|
||||
close: () => raw.close(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Dial the relay's /agent endpoint over mTLS wss. Resolves the connected WsLike on open. */
|
||||
export function dialRelay(
|
||||
cfg: AgentConfig,
|
||||
ks: Keystore,
|
||||
opts: { Ctor: TlsWsConstructor; now?: Date; certParser?: CertParser },
|
||||
): Promise<WsLike> {
|
||||
const tls = buildTlsOptions(ks, { ...(opts.now ? { now: opts.now } : {}), ...(opts.certParser ? { certParser: opts.certParser } : {}) })
|
||||
return new Promise<WsLike>((resolve, reject) => {
|
||||
const raw = new opts.Ctor(cfg.relayUrl, tls)
|
||||
raw.once('open', () => resolve(adapt(raw)))
|
||||
raw.once('error', (err: unknown) => reject(err instanceof Error ? err : new Error(String(err))))
|
||||
})
|
||||
}
|
||||
41
agent/src/transport/flowControl.ts
Normal file
41
agent/src/transport/flowControl.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Per-stream flow control — PLAN_RELAY_AGENT T11. CONSUMES the §4.1 WINDOW_UPDATE credit protocol
|
||||
* (P1 owns the protocol). Per-stream credit means one heavy vim/top redraw can't starve another
|
||||
* stream. streamId 0 is the connection-level window applied to the whole link.
|
||||
*/
|
||||
export interface FlowController {
|
||||
consume(streamId: number, bytes: number): boolean
|
||||
grant(streamId: number, credit: number): void
|
||||
initWindow(streamId: number, initialCredit: number): void
|
||||
}
|
||||
|
||||
const CONNECTION_STREAM_ID = 0
|
||||
|
||||
export function createFlowController(): FlowController {
|
||||
const windows = new Map<number, number>()
|
||||
// Connection-level window is unbounded until explicitly initialized.
|
||||
windows.set(CONNECTION_STREAM_ID, Number.POSITIVE_INFINITY)
|
||||
|
||||
function remaining(streamId: number): number {
|
||||
return windows.get(streamId) ?? 0
|
||||
}
|
||||
|
||||
return {
|
||||
initWindow(streamId: number, initialCredit: number): void {
|
||||
windows.set(streamId, initialCredit)
|
||||
},
|
||||
grant(streamId: number, credit: number): void {
|
||||
windows.set(streamId, remaining(streamId) + credit)
|
||||
},
|
||||
consume(streamId: number, bytes: number): boolean {
|
||||
const conn = remaining(CONNECTION_STREAM_ID)
|
||||
const stream = remaining(streamId)
|
||||
if (stream < bytes || conn < bytes) return false // credit exhausted → pause
|
||||
windows.set(streamId, stream - bytes)
|
||||
if (conn !== Number.POSITIVE_INFINITY) {
|
||||
windows.set(CONNECTION_STREAM_ID, conn - bytes)
|
||||
}
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
72
agent/src/transport/frpScaffold.ts
Normal file
72
agent/src/transport/frpScaffold.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* v0.8 frpc-wrap stepping-stone — PLAN_RELAY_AGENT T6. Fastest path to the café demo: wraps a
|
||||
* child `frpc` presenting the shared v0.8 `agentToken`, registering the subdomain, forwarding to
|
||||
* 127.0.0.1:3000. EXPLICITLY a stepping-stone — the native mux (T7–T11) replaces it at v0.9.
|
||||
*
|
||||
* Forwards ONLY to loopback (anti-SSRF). Retired once EnrollMode==='ed25519' (guard below).
|
||||
*/
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import { isLoopbackWsUrl } from '../config/agentConfig.js'
|
||||
import type { EnrollMode } from '../enroll/pair.js'
|
||||
|
||||
export interface FrpScaffold {
|
||||
start(): Promise<void>
|
||||
stop(): Promise<void>
|
||||
onExit(cb: (code: number) => void): void
|
||||
}
|
||||
|
||||
/** Minimal child-process seam so tests inject a fake spawn (no real frpc needed). */
|
||||
export interface ChildLike {
|
||||
on(ev: 'exit', cb: (code: number | null) => void): void
|
||||
kill(): void
|
||||
}
|
||||
export type SpawnImpl = (cmd: string, args: readonly string[]) => ChildLike
|
||||
|
||||
const LOOPBACK_IP = '127.0.0.1'
|
||||
const LOCAL_PORT = 3000
|
||||
|
||||
/** Build the frpc.toml. local_ip is ALWAYS loopback; tls is enabled. */
|
||||
export function buildFrpcToml(cfg: AgentConfig): string {
|
||||
if (!isLoopbackWsUrl(cfg.localTargetUrl)) {
|
||||
throw new Error('frpScaffold refuses a non-loopback localTargetUrl (anti-SSRF)')
|
||||
}
|
||||
const subdomain = cfg.subdomain ?? ''
|
||||
return [
|
||||
'[common]',
|
||||
'tls_enable = true',
|
||||
'',
|
||||
'[web-terminal]',
|
||||
'type = "tcp"',
|
||||
`local_ip = "${LOOPBACK_IP}"`,
|
||||
`local_port = ${LOCAL_PORT}`,
|
||||
`subdomain = "${subdomain}"`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** True once the native Ed25519 substrate is active — `run` must NOT wire frpc then. */
|
||||
export function isFrpRetired(mode: EnrollMode): boolean {
|
||||
return mode === 'ed25519'
|
||||
}
|
||||
|
||||
/** Spawn (a mockable) frpc child with a generated config. */
|
||||
export function spawnFrpc(cfg: AgentConfig, frpcPath: string, spawnImpl: SpawnImpl): FrpScaffold {
|
||||
const toml = buildFrpcToml(cfg) // validates loopback before spawning
|
||||
void toml
|
||||
let child: ChildLike | null = null
|
||||
const exitCbs: Array<(code: number) => void> = []
|
||||
return {
|
||||
async start(): Promise<void> {
|
||||
child = spawnImpl(frpcPath, ['-c', 'frpc.toml'])
|
||||
child.on('exit', (code) => {
|
||||
for (const cb of exitCbs) cb(code ?? 0)
|
||||
})
|
||||
},
|
||||
async stop(): Promise<void> {
|
||||
child?.kill()
|
||||
},
|
||||
onExit(cb: (code: number) => void): void {
|
||||
exitCbs.push(cb)
|
||||
},
|
||||
}
|
||||
}
|
||||
209
agent/src/transport/frpSupervise.ts
Normal file
209
agent/src/transport/frpSupervise.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Native-tunnel frpc supervisor — TASK B4/H4 (PLAN_TUNNEL_AUTOMATION §3.2).
|
||||
*
|
||||
* Spawns the pinned `frpc` child with the written `frpc.toml` and keeps it running: on every exit
|
||||
* it restarts the child after an exponential backoff (REUSES `transport/backoff.ts` — the same
|
||||
* 1s/2s/4s…cap-30s policy as the relay reconnect). A child that ran longer than a stability window
|
||||
* resets the backoff, so a healthy long-lived tunnel that finally dies restarts fast, while a
|
||||
* crash-loop stays capped at 30s. All IO — spawn, sleep, clock — is injected so the loop is
|
||||
* fully offline-testable (no real frpc / no real timers). No `console.log` (INV9 redacting logger).
|
||||
*
|
||||
* SCOPE (deferral #11): the real `frpc` binary run is pending B3 tar.gz extraction; the default
|
||||
* `spawn` shells out to the provisioned binary, but tests inject a fake child so nothing executes.
|
||||
*
|
||||
* LOG CAPTURE (B4/H4 wiring fix): when a `logFile` is supplied, the default spawn tees the frpc
|
||||
* child's stdout+stderr into that file (truncated per (re)spawn) so the health probe's log scanner
|
||||
* (`readFrpcLog` → `frpcProxyStarted`) has real content to match "start proxy success" against.
|
||||
* Truncate-per-spawn keeps the file bounded to the CURRENT child and free of a stale success line
|
||||
* from a dead one — a freshly connected frpc always re-logs the success line.
|
||||
*/
|
||||
import { spawn as nodeSpawn } from 'node:child_process'
|
||||
import { createWriteStream, mkdirSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import { createBackoff, type BackoffPolicy, type Sleep } from './backoff.js'
|
||||
import { createLogger, type Logger } from '../log/logger.js'
|
||||
|
||||
/** A child process seam the supervisor drives (a fake child in tests; a real frpc in prod). */
|
||||
export interface FrpcChild {
|
||||
/** Register a one-shot exit handler (`null` code ⇒ killed by signal or spawn error). */
|
||||
onExit(cb: (code: number | null) => void): void
|
||||
/** Whether the child is still running (feeds the health probe's `isFrpcAlive`). */
|
||||
isAlive(): boolean
|
||||
/** Request termination. */
|
||||
kill(): void
|
||||
}
|
||||
|
||||
/** Spawn a frpc child running `frpc -c <tomlPath>`. */
|
||||
export type SpawnFrpc = (binPath: string, tomlPath: string) => FrpcChild
|
||||
|
||||
/** Injectable seams for the supervisor; unset fields default to real spawn/sleep/clock/logger. */
|
||||
export interface FrpSuperviseDeps {
|
||||
spawn: SpawnFrpc
|
||||
backoff: BackoffPolicy
|
||||
sleep: Sleep
|
||||
logger: Logger
|
||||
now: () => number
|
||||
}
|
||||
|
||||
/**
|
||||
* Supervisor overrides: any `FrpSuperviseDeps` seam plus an optional `logFile`. When `logFile` is
|
||||
* set and no explicit `spawn` is given, the default spawn tees frpc's stdout/stderr into that file
|
||||
* so the health probe can scan it. An explicit `spawn` (tests) always wins over `logFile`.
|
||||
*/
|
||||
export interface FrpSuperviseOptions extends Partial<FrpSuperviseDeps> {
|
||||
readonly logFile?: string
|
||||
}
|
||||
|
||||
/** Handle returned by `superviseFrpc`: stop the loop, or await its terminal exit code. */
|
||||
export interface FrpSuperviseHandle {
|
||||
/** Request graceful shutdown (kills the child); resolves once the loop has fully stopped. */
|
||||
stop(): Promise<void>
|
||||
/** Resolves with an exit code (always 0 — a stopped supervisor is a clean shutdown). */
|
||||
readonly done: Promise<number>
|
||||
/** True while a frpc child is currently running (for the health probe). */
|
||||
isChildAlive(): boolean
|
||||
/**
|
||||
* Kill the current child WITHOUT stopping the supervisor (A5): the restart-on-exit loop respawns a
|
||||
* fresh frpc that re-reads the (now rotated) cert/key/CA files. A no-op once `stop()` was called —
|
||||
* it must never resurrect a supervisor that is shutting down.
|
||||
*/
|
||||
restartChild(): void
|
||||
}
|
||||
|
||||
/** A frpc run lasting at least this long is "stable" ⇒ reset the restart backoff. */
|
||||
export const STABLE_RUN_MS = 60_000
|
||||
|
||||
const realSleep: Sleep = (ms) => new Promise<void>((r) => setTimeout(r, ms))
|
||||
|
||||
/**
|
||||
* Default spawn (no log capture): launch the provisioned frpc binary, discarding its stdout/stderr.
|
||||
* `stdio: 'ignore'` (not `'pipe'`) is deliberate — an unconsumed pipe fills its OS buffer (~64KB) and
|
||||
* then BLOCKS the child. Log capture is opt-in via `createFileLoggingSpawn` (see `logFile`).
|
||||
*/
|
||||
const realSpawn: SpawnFrpc = (binPath, tomlPath) => {
|
||||
const cp = nodeSpawn(binPath, ['-c', tomlPath], { stdio: ['ignore', 'ignore', 'ignore'] })
|
||||
let alive = true
|
||||
return {
|
||||
onExit(cb: (code: number | null) => void): void {
|
||||
cp.once('exit', (code) => {
|
||||
alive = false
|
||||
cb(code)
|
||||
})
|
||||
cp.once('error', () => {
|
||||
alive = false
|
||||
cb(null)
|
||||
})
|
||||
},
|
||||
isAlive: () => alive,
|
||||
kill: () => {
|
||||
cp.kill()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn frpc and TEE its stdout+stderr into `logFile` (truncated per spawn) so the health probe's
|
||||
* `readFrpcLog` scanner has real content. A log-write failure is swallowed — persisting the log for
|
||||
* the probe must never crash the supervised tunnel.
|
||||
*/
|
||||
export function createFileLoggingSpawn(logFile: string): SpawnFrpc {
|
||||
return (binPath, tomlPath) => {
|
||||
mkdirSync(dirname(logFile), { recursive: true })
|
||||
const cp = nodeSpawn(binPath, ['-c', tomlPath], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
// flags:'w' truncates so the file reflects only the current child (no stale success line).
|
||||
const log = createWriteStream(logFile, { flags: 'w' })
|
||||
log.on('error', () => {
|
||||
/* a log-write failure is non-fatal to the tunnel; the probe just sees no success line */
|
||||
})
|
||||
cp.stdout?.pipe(log, { end: false })
|
||||
cp.stderr?.pipe(log, { end: false })
|
||||
let alive = true
|
||||
const closeLog = (): void => {
|
||||
log.end()
|
||||
}
|
||||
return {
|
||||
onExit(cb: (code: number | null) => void): void {
|
||||
cp.once('exit', (code) => {
|
||||
alive = false
|
||||
closeLog()
|
||||
cb(code)
|
||||
})
|
||||
cp.once('error', () => {
|
||||
alive = false
|
||||
closeLog()
|
||||
cb(null)
|
||||
})
|
||||
},
|
||||
isAlive: () => alive,
|
||||
kill: () => {
|
||||
cp.kill()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDeps(o?: FrpSuperviseOptions): FrpSuperviseDeps {
|
||||
const defaultSpawn = o?.logFile !== undefined ? createFileLoggingSpawn(o.logFile) : realSpawn
|
||||
return {
|
||||
spawn: o?.spawn ?? defaultSpawn,
|
||||
backoff: o?.backoff ?? createBackoff({ jitter: true }),
|
||||
sleep: o?.sleep ?? realSleep,
|
||||
logger: o?.logger ?? createLogger('info'),
|
||||
now: o?.now ?? Date.now,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start supervising frpc. Returns immediately with a handle; the restart loop runs in the
|
||||
* background. `stop()` sets the stop flag, kills the live child, and awaits loop termination.
|
||||
*/
|
||||
export function superviseFrpc(
|
||||
binPath: string,
|
||||
tomlPath: string,
|
||||
overrides?: FrpSuperviseOptions,
|
||||
): FrpSuperviseHandle {
|
||||
const { spawn, backoff, sleep, logger, now } = resolveDeps(overrides)
|
||||
|
||||
let stopped = false
|
||||
let child: FrpcChild | null = null
|
||||
|
||||
/** Spawn one frpc child and resolve when it exits. */
|
||||
function runOnce(): Promise<number | null> {
|
||||
return new Promise<number | null>((resolve) => {
|
||||
const c = spawn(binPath, tomlPath)
|
||||
child = c
|
||||
c.onExit((code) => {
|
||||
child = null
|
||||
resolve(code)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function loop(): Promise<number> {
|
||||
while (!stopped) {
|
||||
const startedAt = now()
|
||||
const exitCode = await runOnce()
|
||||
if (stopped) break
|
||||
if (now() - startedAt >= STABLE_RUN_MS) backoff.reset()
|
||||
const delayMs = backoff.nextDelayMs()
|
||||
logger.log('warn', 'frpc exited — restarting after backoff', { exitCode, delayMs })
|
||||
await sleep(delayMs)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const done = loop()
|
||||
|
||||
return {
|
||||
async stop(): Promise<void> {
|
||||
stopped = true
|
||||
child?.kill()
|
||||
await done
|
||||
},
|
||||
done,
|
||||
isChildAlive: () => child?.isAlive() ?? false,
|
||||
restartChild(): void {
|
||||
if (!stopped) child?.kill()
|
||||
},
|
||||
}
|
||||
}
|
||||
155
agent/src/transport/frpcToml.ts
Normal file
155
agent/src/transport/frpcToml.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Native-tunnel frpc.toml writer — TASK B2h (PLAN_TUNNEL_AUTOMATION §3.2 / PLAN_NATIVE_TUNNEL §4).
|
||||
*
|
||||
* Emits the frp v0.61 TOML that a host's `frpc` presents to the VPS:
|
||||
* - dials `serverAddr:443`, SNI-routed by nginx `ssl_preread` to frps :7000;
|
||||
* - control-channel mTLS (frp-client cert/key + trusted CA) + shared token;
|
||||
* - one `[[proxies]]` of `type = "http"` exposing the loopback base app as `<sub>.terminal...`.
|
||||
*
|
||||
* SUPERSEDES the retired v0.8 `[common]/tls_enable` shape in `frpScaffold.ts` (do not reuse that
|
||||
* grammar — this is the native-tunnel writer).
|
||||
*
|
||||
* ANTI-SSRF (hard invariant): `localIP` MUST be loopback. frpc forwards ONLY to the local base app,
|
||||
* never an arbitrary target — a non-loopback `localIP` would turn the tunnel into an open proxy.
|
||||
* All inputs are validated at this boundary (fail-fast, clear messages); no `console.log`.
|
||||
*/
|
||||
|
||||
const DEFAULT_SERVER_ADDR = '8.138.1.192'
|
||||
const SERVER_PORT = 443
|
||||
const TLS_SERVER_NAME = 'frp.terminal.yaojia.wang'
|
||||
const DEFAULT_LOCAL_IP = '127.0.0.1'
|
||||
const MIN_PORT = 1
|
||||
const MAX_PORT = 65535
|
||||
const MAX_LABEL_LEN = 63
|
||||
const DEL_CHAR_CODE = 0x7f
|
||||
const FIRST_PRINTABLE_CODE = 0x20
|
||||
|
||||
/** Loopback forms accepted for `localIP` (anti-SSRF allowlist). */
|
||||
const LOOPBACK_IPS: readonly string[] = ['127.0.0.1', '::1', 'localhost']
|
||||
|
||||
/** RFC 1035 DNS label: 1-63 chars, alnum, internal hyphens only (no leading/trailing hyphen). */
|
||||
const LABEL_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
|
||||
|
||||
export interface NativeFrpcOptions {
|
||||
/** Subdomain label -> reachable at `https://<subdomain>.terminal.yaojia.wang`. Label-safe. */
|
||||
readonly subdomain: string
|
||||
/** Loopback port of the local base app frpc forwards to (1-65535). */
|
||||
readonly localPort: number
|
||||
/** Shared frps auth token (kept out of logs; the file itself is chmod 600 by the caller). */
|
||||
readonly authToken: string
|
||||
/** Keystore path to this host's frp-client leaf cert (control-channel mTLS). */
|
||||
readonly certFile: string
|
||||
/** Keystore path to the matching private key. */
|
||||
readonly keyFile: string
|
||||
/** Keystore path to the CA that signed the frps control cert (server verification). */
|
||||
readonly trustedCaFile: string
|
||||
/** VPS address; defaults to the deployed relay `8.138.1.192`. */
|
||||
readonly serverAddr?: string
|
||||
/** Local forward target IP; MUST be loopback. Defaults to `127.0.0.1`. */
|
||||
readonly localIP?: string
|
||||
}
|
||||
|
||||
/** A validation failure at the frpc.toml boundary. */
|
||||
export class FrpcTomlError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'FrpcTomlError'
|
||||
}
|
||||
}
|
||||
|
||||
/** True iff `value` contains an ASCII control character (C0 range or DEL). */
|
||||
function hasControlChar(value: string): boolean {
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
const code = value.charCodeAt(i)
|
||||
if (code < FIRST_PRINTABLE_CODE || code === DEL_CHAR_CODE) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Non-empty, control-char-free path/secret at the boundary. */
|
||||
function assertPresent(field: string, value: string): void {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new FrpcTomlError(`${field} is required`)
|
||||
}
|
||||
if (hasControlChar(value)) {
|
||||
throw new FrpcTomlError(`${field} contains control characters`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Escape a value for a TOML basic (double-quoted) string: backslash + quote (Windows paths). */
|
||||
function tomlBasicString(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
}
|
||||
|
||||
function assertLoopback(localIP: string): void {
|
||||
if (!LOOPBACK_IPS.includes(localIP)) {
|
||||
throw new FrpcTomlError(
|
||||
`localIP "${localIP}" is not loopback — frpc must forward only to the local base app (anti-SSRF)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertSubdomain(subdomain: string): void {
|
||||
if (typeof subdomain !== 'string' || subdomain.length === 0) {
|
||||
throw new FrpcTomlError('subdomain is required')
|
||||
}
|
||||
if (subdomain.length > MAX_LABEL_LEN || !LABEL_RE.test(subdomain)) {
|
||||
throw new FrpcTomlError(
|
||||
`subdomain "${subdomain}" is not a valid DNS label (alnum + internal hyphens, 1-63 chars)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertLocalPort(localPort: number): void {
|
||||
if (!Number.isInteger(localPort) || localPort < MIN_PORT || localPort > MAX_PORT) {
|
||||
throw new FrpcTomlError(
|
||||
`localPort must be an integer in ${MIN_PORT}-${MAX_PORT}, got ${localPort}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a validated frp v0.61 `frpc.toml` for the native mTLS tunnel. Throws `FrpcTomlError` on any
|
||||
* invalid input (fail-fast). The returned string is the full config file contents.
|
||||
*/
|
||||
export function buildNativeFrpcToml(opts: NativeFrpcOptions): string {
|
||||
assertSubdomain(opts.subdomain)
|
||||
assertLocalPort(opts.localPort)
|
||||
assertPresent('authToken', opts.authToken)
|
||||
assertPresent('certFile', opts.certFile)
|
||||
assertPresent('keyFile', opts.keyFile)
|
||||
assertPresent('trustedCaFile', opts.trustedCaFile)
|
||||
|
||||
const serverAddr = opts.serverAddr ?? DEFAULT_SERVER_ADDR
|
||||
assertPresent('serverAddr', serverAddr)
|
||||
|
||||
const localIP = opts.localIP ?? DEFAULT_LOCAL_IP
|
||||
assertLoopback(localIP)
|
||||
|
||||
const lines: readonly string[] = [
|
||||
`serverAddr = "${tomlBasicString(serverAddr)}"`,
|
||||
`serverPort = ${SERVER_PORT}`,
|
||||
'',
|
||||
'auth.method = "token"',
|
||||
`auth.token = "${tomlBasicString(opts.authToken)}"`,
|
||||
'',
|
||||
'# control-channel mTLS: present this host frp-client cert; verify the frps control cert',
|
||||
'transport.tls.enable = true',
|
||||
`transport.tls.serverName = "${TLS_SERVER_NAME}"`,
|
||||
'transport.tls.disableCustomTLSFirstByte = true',
|
||||
`transport.tls.certFile = "${tomlBasicString(opts.certFile)}"`,
|
||||
`transport.tls.keyFile = "${tomlBasicString(opts.keyFile)}"`,
|
||||
`transport.tls.trustedCaFile = "${tomlBasicString(opts.trustedCaFile)}"`,
|
||||
'',
|
||||
'loginFailExit = false',
|
||||
'',
|
||||
'[[proxies]]',
|
||||
`name = "${opts.subdomain}"`,
|
||||
'type = "http"',
|
||||
`localIP = "${localIP}"`,
|
||||
`localPort = ${opts.localPort}`,
|
||||
`subdomain = "${opts.subdomain}"`,
|
||||
'',
|
||||
]
|
||||
return lines.join('\n')
|
||||
}
|
||||
84
agent/src/transport/heartbeat.ts
Normal file
84
agent/src/transport/heartbeat.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* §4.1 heartbeat — PLAN_RELAY_AGENT T9. PING every 15s on streamId 0; a missed PONG within the
|
||||
* interval ⇒ the tunnel is dead (⇒ T10 reconnect). Also replies PONG (echoing the 8-byte token)
|
||||
* to an inbound PING. Timers are injectable (TimerLike) for deterministic fake-timer tests.
|
||||
*/
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import type { TimerLike } from './seams.js'
|
||||
import { pingHeader, pongHeader, type Tunnel } from './tunnel.js'
|
||||
|
||||
export const HEARTBEAT_INTERVAL_MS = 15_000
|
||||
|
||||
export interface Heartbeat {
|
||||
onPing(token: Uint8Array): void
|
||||
onPong(token: Uint8Array): void
|
||||
start(): void
|
||||
stop(): void
|
||||
onDead(cb: () => void): void
|
||||
}
|
||||
|
||||
const realTimer: TimerLike = {
|
||||
setTimeout: (cb, ms) => setTimeout(cb, ms),
|
||||
clearTimeout: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
|
||||
setInterval: (cb, ms) => setInterval(cb, ms),
|
||||
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
|
||||
}
|
||||
|
||||
export function createHeartbeat(
|
||||
tunnel: Tunnel,
|
||||
opts: { intervalMs?: number; timer?: TimerLike; genToken?: () => Uint8Array } = {},
|
||||
): Heartbeat {
|
||||
const intervalMs = opts.intervalMs ?? HEARTBEAT_INTERVAL_MS
|
||||
const timer = opts.timer ?? realTimer
|
||||
const genToken = opts.genToken ?? (() => new Uint8Array(randomBytes(8)))
|
||||
|
||||
let interval: unknown = null
|
||||
let deadline: unknown = null
|
||||
let pending = false
|
||||
let deadCb: (() => void) | null = null
|
||||
let dead = false
|
||||
|
||||
function fireDead(): void {
|
||||
if (dead) return
|
||||
dead = true
|
||||
stop()
|
||||
deadCb?.()
|
||||
}
|
||||
|
||||
function sendPing(): void {
|
||||
pending = true
|
||||
tunnel.send(pingHeader(), genToken())
|
||||
deadline = timer.setTimeout(() => {
|
||||
if (pending) fireDead()
|
||||
}, intervalMs)
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (interval !== null) timer.clearInterval(interval)
|
||||
if (deadline !== null) timer.clearTimeout(deadline)
|
||||
interval = null
|
||||
deadline = null
|
||||
}
|
||||
|
||||
return {
|
||||
onPing(token: Uint8Array): void {
|
||||
tunnel.send(pongHeader(), token) // echo the token byte-exact
|
||||
},
|
||||
onPong(): void {
|
||||
pending = false
|
||||
if (deadline !== null) {
|
||||
timer.clearTimeout(deadline)
|
||||
deadline = null
|
||||
}
|
||||
},
|
||||
start(): void {
|
||||
dead = false
|
||||
sendPing()
|
||||
interval = timer.setInterval(sendPing, intervalMs)
|
||||
},
|
||||
stop,
|
||||
onDead(cb: () => void): void {
|
||||
deadCb = cb
|
||||
},
|
||||
}
|
||||
}
|
||||
71
agent/src/transport/loopback.ts
Normal file
71
agent/src/transport/loopback.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Loopback forwarder — PLAN_RELAY_AGENT T8. One OPEN ⇒ one fresh ws://127.0.0.1:3000<path>
|
||||
* socket, REPLAYING the real browser `Origin` (§4.1 MuxOpen.originHeader) so the UNCHANGED base
|
||||
* app's Origin check still passes end-to-end (CSWSH protection preserved — EXPLORE §3).
|
||||
*
|
||||
* The raw `ws` constructor is injectable so the URL/Origin wiring is unit-testable without a real
|
||||
* socket. The target is always loopback (validated upstream in config).
|
||||
*/
|
||||
import type { WsLike } from './seams.js'
|
||||
|
||||
export type DialLoopback = (path: string, origin: string) => Promise<WsLike>
|
||||
|
||||
/** Minimal surface of a raw `ws` client the adapter needs. */
|
||||
export interface RawWs {
|
||||
send(data: Uint8Array): void
|
||||
close(): void
|
||||
on(event: string, cb: (...args: unknown[]) => void): void
|
||||
once(event: string, cb: (...args: unknown[]) => void): void
|
||||
}
|
||||
export type WsConstructor = new (
|
||||
url: string,
|
||||
opts?: { headers?: Record<string, string> },
|
||||
) => RawWs
|
||||
|
||||
/** Join the loopback target with the request path (avoids a double slash). */
|
||||
export function buildLoopbackUrl(target: string, path: string): string {
|
||||
const base = target.endsWith('/') ? target.slice(0, -1) : target
|
||||
const suffix = path.startsWith('/') ? path : `/${path}`
|
||||
return `${base}${suffix}`
|
||||
}
|
||||
|
||||
function toU8(data: unknown): Uint8Array | null {
|
||||
if (data instanceof Uint8Array) return data
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||
return null
|
||||
}
|
||||
|
||||
function adapt(raw: RawWs): WsLike {
|
||||
return {
|
||||
send(d: Uint8Array): void {
|
||||
raw.send(d)
|
||||
},
|
||||
on(ev: 'message' | 'close' | 'error', cb: (...a: unknown[]) => void): void {
|
||||
if (ev === 'message') {
|
||||
raw.on('message', (data: unknown) => {
|
||||
const bytes = toU8(data)
|
||||
if (bytes !== null) cb(bytes)
|
||||
})
|
||||
} else {
|
||||
raw.on(ev, cb)
|
||||
}
|
||||
},
|
||||
close(): void {
|
||||
raw.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a DialLoopback bound to `target`. Resolves once the loopback socket is open; rejects on
|
||||
* a pre-open error (so a down base app surfaces as a typed dial failure, not a silent hang).
|
||||
*/
|
||||
export function dialLoopback(target: string, Ctor: WsConstructor): DialLoopback {
|
||||
return (path: string, origin: string): Promise<WsLike> =>
|
||||
new Promise<WsLike>((resolve, reject) => {
|
||||
const url = buildLoopbackUrl(target, path)
|
||||
const raw = new Ctor(url, { headers: { Origin: origin } })
|
||||
raw.once('open', () => resolve(adapt(raw)))
|
||||
raw.once('error', (err: unknown) => reject(err instanceof Error ? err : new Error(String(err))))
|
||||
})
|
||||
}
|
||||
152
agent/src/transport/runTunnel.ts
Normal file
152
agent/src/transport/runTunnel.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Long-running tunnel supervisor — PLAN_RELAY_PHASE1 C2. Ports the PROVEN cafeDemo assembly
|
||||
* (dialRelay → holdTunnel → createStreamRouter → dialLoopback, plus heartbeat) into a supervised
|
||||
* loop that survives disconnects: exponential backoff reconnect (T10 policy), heartbeat liveness
|
||||
* (T9), and GOAWAY/revocation-aware teardown (T14, INV12 — a revoked host NEVER reconnects).
|
||||
*
|
||||
* All IO is injectable via `RunTunnelDeps` so the loop is unit-testable with fakes (see cafeDemo);
|
||||
* the two-arg `runTunnel(cfg, ks)` default path wires the real `ws` sockets. INV2 is preserved: the
|
||||
* router splices OPAQUE bytes (identityTransform) — no terminal parsing happens here.
|
||||
*/
|
||||
import { WebSocket } from 'ws'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import type { Keystore } from '../keys/keystore.js'
|
||||
import { createLogger, type Logger } from '../log/logger.js'
|
||||
import { createRevocationState, applyGoAway } from '../lifecycle/revocation.js'
|
||||
import { dialRelay, type TlsWsConstructor } from './dial.js'
|
||||
import { dialLoopback, type DialLoopback, type WsConstructor } from './loopback.js'
|
||||
import { holdTunnel, type Tunnel } from './tunnel.js'
|
||||
import { createStreamRouter, identityTransform } from './streamRouter.js'
|
||||
import { createHeartbeat } from './heartbeat.js'
|
||||
import { createBackoff, reconnectLoop, type BackoffPolicy, type Sleep } from './backoff.js'
|
||||
import type { TimerLike, WsLike } from './seams.js'
|
||||
|
||||
/** Handle returned by `runTunnel`: stop the supervisor, or await its terminal exit code. */
|
||||
export interface TunnelHandle {
|
||||
/** Request graceful shutdown; resolves once the supervisor loop has fully stopped. */
|
||||
stop(): Promise<void>
|
||||
/** Resolves with a process exit code when the loop ends (stopped or host revoked ⇒ 0). */
|
||||
readonly done: Promise<number>
|
||||
}
|
||||
|
||||
/** Injectable seams for the supervisor. All optional; unset fields default to real `ws` IO. */
|
||||
export interface RunTunnelDeps {
|
||||
connectRelay(): Promise<WsLike>
|
||||
dialLoopback: DialLoopback
|
||||
logger: Logger
|
||||
timer: TimerLike
|
||||
sleep: Sleep
|
||||
backoff: BackoffPolicy
|
||||
}
|
||||
|
||||
const realTimer: TimerLike = {
|
||||
setTimeout: (cb, ms) => setTimeout(cb, ms),
|
||||
clearTimeout: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
|
||||
setInterval: (cb, ms) => setInterval(cb, ms),
|
||||
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
|
||||
}
|
||||
const realSleep: Sleep = (ms) => new Promise<void>((r) => setTimeout(r, ms))
|
||||
|
||||
function resolveDeps(cfg: AgentConfig, ks: Keystore, o?: Partial<RunTunnelDeps>): RunTunnelDeps {
|
||||
return {
|
||||
connectRelay:
|
||||
o?.connectRelay ?? (() => dialRelay(cfg, ks, { Ctor: WebSocket as unknown as TlsWsConstructor })),
|
||||
dialLoopback: o?.dialLoopback ?? dialLoopback(cfg.localTargetUrl, WebSocket as unknown as WsConstructor),
|
||||
logger: o?.logger ?? createLogger('info'),
|
||||
timer: o?.timer ?? realTimer,
|
||||
sleep: o?.sleep ?? realSleep,
|
||||
backoff: o?.backoff ?? createBackoff({ jitter: true }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the supervised tunnel. Returns immediately with a handle; the reconnect loop runs in the
|
||||
* background. Never awaits the first connection (an unreachable relay would otherwise hang the
|
||||
* caller with no way to `stop()`).
|
||||
*/
|
||||
export async function runTunnel(
|
||||
cfg: AgentConfig,
|
||||
ks: Keystore,
|
||||
overrides?: Partial<RunTunnelDeps>,
|
||||
): Promise<TunnelHandle> {
|
||||
const { connectRelay, dialLoopback: dialLb, logger, timer, sleep, backoff } = resolveDeps(cfg, ks, overrides)
|
||||
|
||||
let stopped = false
|
||||
let currentTunnel: Tunnel | null = null
|
||||
let currentSocket: WsLike | null = null
|
||||
|
||||
// INV12: revocation tears the live tunnel down immediately and suppresses all reconnects.
|
||||
const revocation = createRevocationState(() => currentTunnel?.close())
|
||||
const shouldStop = (): boolean => stopped || revocation.isRevoked()
|
||||
|
||||
async function dialTunnel(): Promise<Tunnel> {
|
||||
const socket = await connectRelay()
|
||||
currentSocket = socket
|
||||
return holdTunnel(socket)
|
||||
}
|
||||
|
||||
/** Run ONE tunnel session; resolves when this session ends (dead heartbeat / close / GOAWAY). */
|
||||
function runSession(tunnel: Tunnel, socket: WsLike): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
const router = createStreamRouter(cfg, tunnel, dialLb, identityTransform, logger)
|
||||
const heartbeat = createHeartbeat(tunnel, { timer })
|
||||
tunnel.dispatchTo(router, heartbeat)
|
||||
|
||||
let settled = false
|
||||
const endSession = (): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
heartbeat.stop()
|
||||
resolve()
|
||||
}
|
||||
|
||||
heartbeat.onDead(() => {
|
||||
logger.log('warn', 'heartbeat missed — tunnel presumed down, will reconnect')
|
||||
tunnel.close()
|
||||
endSession()
|
||||
})
|
||||
socket.on('close', () => endSession())
|
||||
socket.on('error', () => {
|
||||
tunnel.close()
|
||||
endSession()
|
||||
})
|
||||
tunnel.onGoAway((reason) => {
|
||||
const action = applyGoAway(reason, revocation) // 'revoked' ⇒ no reconnect (INV12)
|
||||
logger.log('info', 'received GOAWAY', { action })
|
||||
tunnel.close()
|
||||
endSession()
|
||||
})
|
||||
|
||||
heartbeat.start()
|
||||
})
|
||||
}
|
||||
|
||||
async function supervise(): Promise<number> {
|
||||
while (!shouldStop()) {
|
||||
const tunnel = await reconnectLoop(dialTunnel, backoff, shouldStop, sleep)
|
||||
if (tunnel === null || currentSocket === null) break
|
||||
if (shouldStop()) {
|
||||
// stop()/revoke raced with the in-flight dial — discard the fresh tunnel.
|
||||
tunnel.close()
|
||||
break
|
||||
}
|
||||
currentTunnel = tunnel
|
||||
logger.log('info', 'relay tunnel established')
|
||||
await runSession(tunnel, currentSocket)
|
||||
currentTunnel = null
|
||||
currentSocket = null
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const done = supervise()
|
||||
|
||||
return {
|
||||
async stop(): Promise<void> {
|
||||
stopped = true
|
||||
currentTunnel?.close()
|
||||
await done
|
||||
},
|
||||
done,
|
||||
}
|
||||
}
|
||||
43
agent/src/transport/seams.ts
Normal file
43
agent/src/transport/seams.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* W0 shared injection seams (DEPENDENCY-CYCLE BREAKER) — PLAN_RELAY_AGENT §2 / T2.
|
||||
*
|
||||
* These are intra-`agent/` seam *types only*. NO cross-plan frozen contract lives here —
|
||||
* those stay in `relay-contracts/`. Declaring them once at W0 lets W2/W3 transport tasks
|
||||
* (T7/T10/T12/T14) consume a stable type WITHOUT a task-level cycle (see §3 T10↔T14 note).
|
||||
*
|
||||
* This module MUST remain side-effect-free and runtime-dependency-free (type-only): a test
|
||||
* asserts importing it pulls in no `ws`/crypto runtime, so W2/W3 can depend on it freely.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Minimal WS surface the transport layer needs. dial/tunnel/backoff/loopback share it so no
|
||||
* per-task redeclare and no drift. Adapters wrap the real `ws` socket into this shape.
|
||||
*/
|
||||
export interface WsLike {
|
||||
send(d: Uint8Array): void
|
||||
on(ev: 'message' | 'close' | 'error', cb: (...a: unknown[]) => void): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
/** Why a host stopped tunnelling. `renewal-refused`/`goaway-revoked` ⇒ do NOT reconnect. */
|
||||
export type RevokeReason = 'renewal-refused' | 'goaway-revoked' | 'operator'
|
||||
|
||||
/**
|
||||
* Revocation seam — T14 IMPLEMENTS it; T10's reconnectLoop only CONSUMES `isRevoked()`.
|
||||
* One-way edge (T14 → wires T10's loop), so there is no task cycle.
|
||||
*/
|
||||
export interface RevocationState {
|
||||
isRevoked(): boolean
|
||||
markRevoked(reason: RevokeReason): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal timer seam so heartbeat (T9) / cert rotation (T13) are testable with fake timers
|
||||
* without depending on Node's global timer types leaking into the transport surface.
|
||||
*/
|
||||
export interface TimerLike {
|
||||
setTimeout(cb: () => void, ms: number): unknown
|
||||
clearTimeout(handle: unknown): void
|
||||
setInterval(cb: () => void, ms: number): unknown
|
||||
clearInterval(handle: unknown): void
|
||||
}
|
||||
148
agent/src/transport/streamRouter.ts
Normal file
148
agent/src/transport/streamRouter.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Stream router — PLAN_RELAY_AGENT T8. Maps §4.1 streamId ⇄ a loopback socket. Each OPEN gets a
|
||||
* FRESH per-stream allocation (socket + transform state); there are NO global mutable buffers, so
|
||||
* cross-tenant/cross-stream buffer bleed is structurally impossible (EXPLORE §4b failure #3).
|
||||
*
|
||||
* MANDATORY INV1 defense-in-depth: the FIRST thing handleOpen does — before any allocation or
|
||||
* dial — is compare MuxOpen.subdomain (§4.1) against this agent's enrolled subdomain. A mismatch
|
||||
* is RST and NEVER dialed (metadata-only audit log, INV10). Belt-and-suspenders to relay authz.
|
||||
*/
|
||||
import type { MuxOpen } from 'relay-contracts'
|
||||
import { MuxOpenSchema } from 'relay-contracts'
|
||||
import type { AgentConfig } from '../config/agentConfig.js'
|
||||
import type { Logger } from '../log/logger.js'
|
||||
import type { WsLike } from './seams.js'
|
||||
import type { DialLoopback } from './loopback.js'
|
||||
import { closeHeader, dataHeader, type Tunnel } from './tunnel.js'
|
||||
|
||||
/**
|
||||
* Per-stream cipher transform. Identity in v0.9 (plaintext passthrough); replaced by the E2E
|
||||
* codec in v0.10 (T15). `takeControlFrames` lets an E2E transform emit host→client control frames
|
||||
* (e.g. HostHello) that the router forwards upstream — no-op for the identity transform.
|
||||
*/
|
||||
export interface FrameTransform {
|
||||
inbound(streamId: number, cipher: Uint8Array): Uint8Array | null
|
||||
outbound(streamId: number, plain: Uint8Array): Uint8Array
|
||||
openStream(streamId: number): void
|
||||
closeStream(streamId: number): void
|
||||
takeControlFrames?(streamId: number): Uint8Array[]
|
||||
}
|
||||
|
||||
export const identityTransform: FrameTransform = {
|
||||
inbound: (_s, cipher) => cipher,
|
||||
outbound: (_s, plain) => plain,
|
||||
openStream: () => {},
|
||||
closeStream: () => {},
|
||||
}
|
||||
|
||||
export interface StreamRouter {
|
||||
handleOpen(open: MuxOpen): void
|
||||
handleData(streamId: number, payload: Uint8Array): void
|
||||
handleClose(streamId: number, rst: boolean): void
|
||||
activeStreamCount(): number
|
||||
}
|
||||
|
||||
interface StreamState {
|
||||
socket: WsLike | null
|
||||
readonly pending: Uint8Array[] // inbound bytes buffered until the loopback socket is open
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
export function createStreamRouter(
|
||||
cfg: AgentConfig,
|
||||
tunnel: Tunnel,
|
||||
dial: DialLoopback,
|
||||
transform: FrameTransform,
|
||||
logger: Logger,
|
||||
): StreamRouter {
|
||||
const streams = new Map<number, StreamState>()
|
||||
|
||||
function flushControlFrames(streamId: number): void {
|
||||
const frames = transform.takeControlFrames?.(streamId) ?? []
|
||||
for (const frame of frames) {
|
||||
tunnel.send(dataHeader(streamId, frame.length), frame)
|
||||
}
|
||||
}
|
||||
|
||||
function teardown(streamId: number, rst: boolean): void {
|
||||
const state = streams.get(streamId)
|
||||
if (state === undefined) return
|
||||
// Delete FIRST so a synchronous socket 'close' event can't re-enter this teardown.
|
||||
streams.delete(streamId)
|
||||
state.closed = true
|
||||
transform.closeStream(streamId)
|
||||
tunnel.send(closeHeader(streamId, rst), new Uint8Array(0))
|
||||
state.socket?.close()
|
||||
}
|
||||
|
||||
return {
|
||||
handleOpen(open: MuxOpen): void {
|
||||
// INV1 defense-in-depth — FIRST statement, before any allocation or dial.
|
||||
if (open.subdomain !== cfg.subdomain) {
|
||||
tunnel.sendRst(open.streamId)
|
||||
logger.log('error', 'open.subdomain mismatch — refusing to dial', { streamId: open.streamId })
|
||||
return
|
||||
}
|
||||
if (!MuxOpenSchema.safeParse(open).success) {
|
||||
tunnel.sendRst(open.streamId)
|
||||
return
|
||||
}
|
||||
if (streams.has(open.streamId)) {
|
||||
tunnel.sendRst(open.streamId) // duplicate OPEN for a live stream
|
||||
return
|
||||
}
|
||||
|
||||
const state: StreamState = { socket: null, pending: [], closed: false }
|
||||
streams.set(open.streamId, state)
|
||||
transform.openStream(open.streamId)
|
||||
|
||||
dial(open.requestPath, open.originHeader)
|
||||
.then((socket) => {
|
||||
if (state.closed) {
|
||||
socket.close()
|
||||
return
|
||||
}
|
||||
state.socket = socket
|
||||
// loopback output → transform.outbound → tunnel DATA
|
||||
socket.on('message', (data: unknown) => {
|
||||
if (!(data instanceof Uint8Array)) return
|
||||
const cipher = transform.outbound(open.streamId, data)
|
||||
tunnel.send(dataHeader(open.streamId, cipher.length), cipher)
|
||||
})
|
||||
socket.on('close', () => teardown(open.streamId, false))
|
||||
// flush anything buffered before the socket opened
|
||||
for (const buffered of state.pending) socket.send(buffered)
|
||||
state.pending.length = 0
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
logger.log('error', 'loopback dial failed', { streamId: open.streamId })
|
||||
void err
|
||||
teardown(open.streamId, true)
|
||||
})
|
||||
},
|
||||
|
||||
handleData(streamId: number, payload: Uint8Array): void {
|
||||
const state = streams.get(streamId)
|
||||
if (state === undefined) {
|
||||
tunnel.sendRst(streamId) // DATA before OPEN / after CLOSE / unknown stream → RST
|
||||
return
|
||||
}
|
||||
const plain = transform.inbound(streamId, payload)
|
||||
flushControlFrames(streamId) // E2E: emit HostHello etc. (no-op in v0.9)
|
||||
if (plain === null) return // consumed (handshake), nothing to forward
|
||||
if (state.socket === null) {
|
||||
state.pending.push(plain)
|
||||
return
|
||||
}
|
||||
state.socket.send(plain)
|
||||
},
|
||||
|
||||
handleClose(streamId: number, rst: boolean): void {
|
||||
teardown(streamId, rst)
|
||||
},
|
||||
|
||||
activeStreamCount(): number {
|
||||
return streams.size
|
||||
},
|
||||
}
|
||||
}
|
||||
161
agent/src/transport/tunnel.ts
Normal file
161
agent/src/transport/tunnel.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* §4.1 tunnel holder — PLAN_RELAY_AGENT T7. Holds ONE physical mux over a WsLike socket:
|
||||
* encodes outbound frames, decodes inbound frames (via the FROZEN relay-contracts codec — never
|
||||
* re-implemented), and dispatches by type to the router (OPEN/DATA/CLOSE) or heartbeat
|
||||
* (PING/PONG) or connection-level control (GOAWAY/WINDOW_UPDATE, streamId 0).
|
||||
*
|
||||
* INV11: payloads are OPAQUE — no ANSI/terminal parsing here. Malformed frames RST the affected
|
||||
* stream (never the whole tunnel). After an inbound GOAWAY the tunnel DRAINS: no new OPEN.
|
||||
*
|
||||
* Design note (vs plan `holdTunnel(socket, router, heartbeat)`): to keep the construction graph
|
||||
* ACYCLIC (router/heartbeat are built WITH the tunnel), wiring is a two-phase `dispatchTo()`
|
||||
* call rather than constructor args. Same behavior, no task cycle. Recorded as a deviation.
|
||||
*/
|
||||
import {
|
||||
decodeGoaway,
|
||||
decodeMuxFrame,
|
||||
decodeOpen,
|
||||
encodeGoaway,
|
||||
encodeMuxFrame,
|
||||
} from 'relay-contracts'
|
||||
import type { GoAwayReason, MuxFrameHeader, MuxOpen } from 'relay-contracts'
|
||||
import type { WsLike } from './seams.js'
|
||||
|
||||
const EMPTY = new Uint8Array(0)
|
||||
|
||||
/** Handlers the tunnel dispatches decoded frames to (wired post-construction). */
|
||||
export interface StreamHandlers {
|
||||
handleOpen(open: MuxOpen): void
|
||||
handleData(streamId: number, payload: Uint8Array): void
|
||||
handleClose(streamId: number, rst: boolean): void
|
||||
}
|
||||
export interface HeartbeatSink {
|
||||
onPing(token: Uint8Array): void
|
||||
onPong(token: Uint8Array): void
|
||||
}
|
||||
|
||||
export interface Tunnel {
|
||||
send(header: MuxFrameHeader, payload: Uint8Array): void
|
||||
onFrame(cb: (h: MuxFrameHeader, payload: Uint8Array) => void): void
|
||||
onGoAway(cb: (reason: GoAwayReason) => void): void
|
||||
goAway(lastStreamId: number, reason: GoAwayReason): void
|
||||
sendRst(streamId: number): void
|
||||
dispatchTo(router: StreamHandlers, heartbeat: HeartbeatSink): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
// --- frame-header builders (shared across the transport layer) --------------------------------
|
||||
|
||||
export function dataHeader(streamId: number, payloadLen: number): MuxFrameHeader {
|
||||
return { version: 1, type: 'data', fin: false, rst: false, streamId, payloadLen }
|
||||
}
|
||||
export function closeHeader(streamId: number, rst: boolean): MuxFrameHeader {
|
||||
return { version: 1, type: 'close', fin: !rst, rst, streamId, payloadLen: 0 }
|
||||
}
|
||||
export function rstHeader(streamId: number): MuxFrameHeader {
|
||||
return closeHeader(streamId, true)
|
||||
}
|
||||
export function pingHeader(): MuxFrameHeader {
|
||||
return { version: 1, type: 'ping', fin: false, rst: false, streamId: 0, payloadLen: 8 }
|
||||
}
|
||||
export function pongHeader(): MuxFrameHeader {
|
||||
return { version: 1, type: 'pong', fin: false, rst: false, streamId: 0, payloadLen: 8 }
|
||||
}
|
||||
|
||||
function toU8(data: unknown): Uint8Array | null {
|
||||
if (data instanceof Uint8Array) return data
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||
if (Array.isArray(data) && data[0] instanceof Uint8Array) return data[0] as Uint8Array
|
||||
return null
|
||||
}
|
||||
|
||||
export function holdTunnel(socket: WsLike): Tunnel {
|
||||
let frameCb: ((h: MuxFrameHeader, payload: Uint8Array) => void) | null = null
|
||||
let goAwayCb: ((reason: GoAwayReason) => void) | null = null
|
||||
let handlers: StreamHandlers | null = null
|
||||
let heartbeat: HeartbeatSink | null = null
|
||||
let draining = false
|
||||
|
||||
function send(header: MuxFrameHeader, payload: Uint8Array): void {
|
||||
socket.send(encodeMuxFrame(header, payload))
|
||||
}
|
||||
function sendRst(streamId: number): void {
|
||||
send(rstHeader(streamId), EMPTY)
|
||||
}
|
||||
|
||||
function dispatch(header: MuxFrameHeader, payload: Uint8Array): void {
|
||||
frameCb?.(header, payload)
|
||||
switch (header.type) {
|
||||
case 'ping':
|
||||
heartbeat?.onPing(payload)
|
||||
return
|
||||
case 'pong':
|
||||
heartbeat?.onPong(payload)
|
||||
return
|
||||
case 'goaway': {
|
||||
const { reason } = decodeGoaway(payload)
|
||||
draining = true
|
||||
goAwayCb?.(reason)
|
||||
return
|
||||
}
|
||||
case 'open': {
|
||||
if (draining) {
|
||||
sendRst(header.streamId) // drain: refuse new streams
|
||||
return
|
||||
}
|
||||
let open: MuxOpen
|
||||
try {
|
||||
open = decodeOpen(payload)
|
||||
} catch {
|
||||
sendRst(header.streamId) // malformed OPEN → RST that stream, tunnel stays up
|
||||
return
|
||||
}
|
||||
handlers?.handleOpen(open)
|
||||
return
|
||||
}
|
||||
case 'data':
|
||||
handlers?.handleData(header.streamId, payload)
|
||||
return
|
||||
case 'close':
|
||||
handlers?.handleClose(header.streamId, header.rst)
|
||||
return
|
||||
case 'windowUpdate':
|
||||
return // consumed by the flow controller at the wiring layer (T11)
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('message', (...args: unknown[]) => {
|
||||
const bytes = toU8(args[0])
|
||||
if (bytes === null) return
|
||||
let decoded: { header: MuxFrameHeader; payload: Uint8Array }
|
||||
try {
|
||||
decoded = decodeMuxFrame(bytes)
|
||||
} catch {
|
||||
return // malformed framing: drop the frame, keep the tunnel alive (robust framing)
|
||||
}
|
||||
dispatch(decoded.header, decoded.payload)
|
||||
})
|
||||
|
||||
return {
|
||||
send,
|
||||
sendRst,
|
||||
onFrame(cb): void {
|
||||
frameCb = cb
|
||||
},
|
||||
onGoAway(cb): void {
|
||||
goAwayCb = cb
|
||||
},
|
||||
goAway(lastStreamId: number, reason: GoAwayReason): void {
|
||||
draining = true
|
||||
const payload = encodeGoaway(lastStreamId, reason)
|
||||
send({ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: payload.length }, payload)
|
||||
},
|
||||
dispatchTo(router: StreamHandlers, hb: HeartbeatSink): void {
|
||||
handlers = router
|
||||
heartbeat = hb
|
||||
},
|
||||
close(): void {
|
||||
socket.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
64
agent/test/acceptance/cafeDemo.test.ts
Normal file
64
agent/test/acceptance/cafeDemo.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { decodeMuxFrame, encodeMuxFrame, encodeOpen, type MuxOpen } from 'relay-contracts'
|
||||
import type { AgentConfig } from '../../src/config/agentConfig.js'
|
||||
import { createLogger } from '../../src/log/logger.js'
|
||||
import { holdTunnel, dataHeader } from '../../src/transport/tunnel.js'
|
||||
import { createStreamRouter, identityTransform } from '../../src/transport/streamRouter.js'
|
||||
import { FakeWs } from '../fixtures/fakes.js'
|
||||
|
||||
/**
|
||||
* T18 café-demo agent slice: pair (implied) → dial (mock) → OPEN → loopback splice against a stub
|
||||
* ws://127.0.0.1:3000 echo server → bytes flow BOTH ways (EXPLORE §5 demo, agent portion).
|
||||
*/
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://x/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
const OPEN: MuxOpen = {
|
||||
streamId: 5,
|
||||
subdomain: 'host-42',
|
||||
requestPath: '/term?join=abc',
|
||||
originHeader: 'https://host-42.term.example.com',
|
||||
remoteAddrHash: 'x',
|
||||
capabilityTokenRef: 'jti',
|
||||
}
|
||||
const flush = () => new Promise((r) => setImmediate(r))
|
||||
|
||||
describe('café-demo agent slice (T18)', () => {
|
||||
it('splices bytes both ways through the loopback echo', async () => {
|
||||
const upstream = new FakeWs()
|
||||
const tunnel = holdTunnel(upstream)
|
||||
const loopback = new FakeWs()
|
||||
const dial = vi.fn(async () => loopback)
|
||||
const router = createStreamRouter(CFG, tunnel, dial as never, identityTransform, createLogger('error', () => {}))
|
||||
tunnel.dispatchTo(router, { onPing: () => {}, onPong: () => {} })
|
||||
|
||||
// relay → agent: OPEN + a keystroke
|
||||
ws_emitOpen(upstream, OPEN)
|
||||
await flush()
|
||||
expect(dial).toHaveBeenCalledWith('/term?join=abc', 'https://host-42.term.example.com')
|
||||
|
||||
upstream.emitMessage(encodeMuxFrame(dataHeader(5, 3), new Uint8Array([104, 105, 10]))) // "hi\n"
|
||||
expect(loopback.sent.at(-1)).toEqual(new Uint8Array([104, 105, 10]))
|
||||
|
||||
// agent ← base app: echo output flows back as DATA upstream
|
||||
loopback.emit('message', new Uint8Array([79, 75])) // "OK"
|
||||
const last = decodeMuxFrame(upstream.sent.at(-1)!)
|
||||
expect(last.header.type).toBe('data')
|
||||
expect([...last.payload]).toEqual([79, 75])
|
||||
})
|
||||
})
|
||||
|
||||
function ws_emitOpen(upstream: FakeWs, open: MuxOpen): void {
|
||||
const payload = encodeOpen(open)
|
||||
upstream.emitMessage(
|
||||
encodeMuxFrame(
|
||||
{ version: 1, type: 'open', fin: false, rst: false, streamId: open.streamId, payloadLen: payload.length },
|
||||
payload,
|
||||
),
|
||||
)
|
||||
}
|
||||
63
agent/test/agentConfig.test.ts
Normal file
63
agent/test/agentConfig.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AgentConfigSchema, isLoopbackWsUrl, loadAgentConfig } from '../src/config/agentConfig.js'
|
||||
|
||||
const base = {
|
||||
relayUrl: 'wss://relay.example.com/agent',
|
||||
enrollUrl: 'https://example.com/enroll',
|
||||
stateDir: '/tmp/wta',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: null,
|
||||
hostId: null,
|
||||
}
|
||||
|
||||
describe('AgentConfig validation', () => {
|
||||
it('parses a valid config', () => {
|
||||
expect(() => AgentConfigSchema.parse(base)).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a non-wss relayUrl', () => {
|
||||
expect(() => AgentConfigSchema.parse({ ...base, relayUrl: 'http://relay.example.com' })).toThrow()
|
||||
expect(() => AgentConfigSchema.parse({ ...base, relayUrl: 'ws://relay.example.com' })).toThrow()
|
||||
})
|
||||
|
||||
it('rejects a non-https enrollUrl', () => {
|
||||
expect(() => AgentConfigSchema.parse({ ...base, enrollUrl: 'http://example.com/enroll' })).toThrow()
|
||||
})
|
||||
|
||||
it('rejects a non-loopback localTargetUrl (anti-SSRF)', () => {
|
||||
expect(() => AgentConfigSchema.parse({ ...base, localTargetUrl: 'ws://10.0.0.5:3000' })).toThrow()
|
||||
expect(() => AgentConfigSchema.parse({ ...base, localTargetUrl: 'ws://evil.example.com:3000' })).toThrow()
|
||||
})
|
||||
|
||||
it('accepts loopback localTargetUrl variants', () => {
|
||||
expect(isLoopbackWsUrl('ws://127.0.0.1:3000')).toBe(true)
|
||||
expect(isLoopbackWsUrl('ws://localhost:3000')).toBe(true)
|
||||
expect(isLoopbackWsUrl('ws://127.5.5.5:3000')).toBe(true)
|
||||
expect(isLoopbackWsUrl('ws://[::1]:3000')).toBe(true)
|
||||
expect(isLoopbackWsUrl('ws://10.0.0.5:3000')).toBe(false)
|
||||
expect(isLoopbackWsUrl('wss://127.0.0.1:3000')).toBe(false)
|
||||
})
|
||||
|
||||
it('REGRESSION: rejects a crafted suffixed-hostname target (anti-SSRF bypass)', () => {
|
||||
// Hostname, not a loopback literal — the outbound dial would DNS-resolve and connect out.
|
||||
expect(isLoopbackWsUrl('ws://127.0.0.1.attacker.example.com:3000/x')).toBe(false)
|
||||
expect(isLoopbackWsUrl('ws://127.evil.net:3000')).toBe(false)
|
||||
expect(() =>
|
||||
AgentConfigSchema.parse({ ...base, localTargetUrl: 'ws://127.0.0.1.attacker.example.com:3000' }),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('loadAgentConfig fails fast on a missing relayUrl', () => {
|
||||
expect(() => loadAgentConfig({} as NodeJS.ProcessEnv, {})).toThrow()
|
||||
})
|
||||
|
||||
it('loadAgentConfig reads env and lets argv override', () => {
|
||||
const cfg = loadAgentConfig(
|
||||
{ RELAY_URL: 'wss://a/agent', ENROLL_URL: 'https://a/enroll' } as unknown as NodeJS.ProcessEnv,
|
||||
{ subdomain: 'host-42' },
|
||||
)
|
||||
expect(cfg.relayUrl).toBe('wss://a/agent')
|
||||
expect(cfg.subdomain).toBe('host-42')
|
||||
expect(cfg.localTargetUrl).toBe('ws://127.0.0.1:3000')
|
||||
})
|
||||
})
|
||||
62
agent/test/backoff.test.ts
Normal file
62
agent/test/backoff.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createBackoff, reconnectLoop, BACKOFF_CAP_MS } from '../src/transport/backoff.js'
|
||||
import type { Tunnel } from '../src/transport/tunnel.js'
|
||||
|
||||
describe('createBackoff (T10)', () => {
|
||||
it('follows 1s,2s,4s…cap 30s', () => {
|
||||
const b = createBackoff()
|
||||
const seq = [b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs()]
|
||||
expect(seq).toEqual([1000, 2000, 4000, 8000, 16000, 30000, 30000])
|
||||
expect(BACKOFF_CAP_MS).toBe(30_000)
|
||||
})
|
||||
|
||||
it('reset() returns to 1s', () => {
|
||||
const b = createBackoff()
|
||||
b.nextDelayMs()
|
||||
b.nextDelayMs()
|
||||
b.reset()
|
||||
expect(b.nextDelayMs()).toBe(1000)
|
||||
})
|
||||
|
||||
it('jitter stays within [0.5×, 1×]', () => {
|
||||
const b = createBackoff({ jitter: true, rng: () => 0 })
|
||||
expect(b.nextDelayMs()).toBe(500) // 1000 * 0.5
|
||||
const b2 = createBackoff({ jitter: true, rng: () => 1 })
|
||||
expect(b2.nextDelayMs()).toBe(1000) // 1000 * 1.0
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconnectLoop (T10, INV12)', () => {
|
||||
const fakeTunnel = {} as Tunnel
|
||||
|
||||
it('resolves on the first successful dial and resets backoff', async () => {
|
||||
const dial = vi.fn().mockRejectedValueOnce(new Error('down')).mockResolvedValueOnce(fakeTunnel)
|
||||
const backoff = createBackoff()
|
||||
const reset = vi.spyOn(backoff, 'reset')
|
||||
const sleeps: number[] = []
|
||||
const result = await reconnectLoop(dial, backoff, () => false, async (ms) => {
|
||||
sleeps.push(ms)
|
||||
})
|
||||
expect(result).toBe(fakeTunnel)
|
||||
expect(sleeps).toEqual([1000])
|
||||
expect(reset).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a revoked host does not reconnect (INV12)', async () => {
|
||||
const dial = vi.fn().mockResolvedValue(fakeTunnel)
|
||||
const result = await reconnectLoop(dial, createBackoff(), () => true, async () => {})
|
||||
expect(dial).not.toHaveBeenCalled()
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('stops retrying once revoked mid-loop', async () => {
|
||||
let revoked = false
|
||||
const dial = vi.fn(async () => {
|
||||
revoked = true
|
||||
throw new Error('down')
|
||||
})
|
||||
const result = await reconnectLoop(dial, createBackoff(), () => revoked, async () => {})
|
||||
expect(result).toBeNull()
|
||||
expect(dial).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
27
agent/test/buildBinary.test.ts
Normal file
27
agent/test/buildBinary.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BINARY_TARGETS, buildBinaryConfig } from '../src/dist/buildBinary.js'
|
||||
|
||||
describe('buildBinaryConfig (T16)', () => {
|
||||
it('produces a bun --compile spec per target with cli.ts entry', () => {
|
||||
for (const target of BINARY_TARGETS) {
|
||||
const spec = buildBinaryConfig(target)
|
||||
expect(spec.tool).toBe('bun')
|
||||
expect(spec.entry).toBe('src/cli.ts')
|
||||
expect(spec.target).toBe(target)
|
||||
expect(spec.bunTarget).toContain('bun-')
|
||||
expect(spec.outfile).toContain(target)
|
||||
}
|
||||
})
|
||||
|
||||
it('carries the INV11 forbidden-dep tripwire (no terminal parser in the bundle)', () => {
|
||||
const spec = buildBinaryConfig('darwin-arm64')
|
||||
expect(spec.forbiddenDeps).toContain('xterm')
|
||||
expect(spec.forbiddenDeps).toContain('ansi')
|
||||
})
|
||||
|
||||
it('covers the four supported triples', () => {
|
||||
expect([...BINARY_TARGETS].sort()).toEqual(
|
||||
['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64'],
|
||||
)
|
||||
})
|
||||
})
|
||||
253
agent/test/cli.test.ts
Normal file
253
agent/test/cli.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import type { Keystore } from '../src/keys/keystore.js'
|
||||
import type { AgentIdentity } from '../src/keys/identity.js'
|
||||
import type { EnrollResult } from 'relay-contracts'
|
||||
import { CliUsageError, parseArgs, runCli, type CliDeps } from '../src/cli.js'
|
||||
import type { InstallOptions } from '../src/service/install.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://x/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
|
||||
function fakeIdentity(alg: AgentIdentity['alg'] = 'ed25519'): AgentIdentity {
|
||||
return {
|
||||
alg,
|
||||
publicKey: new Uint8Array(32),
|
||||
enrollFpr: 'fpr',
|
||||
sign: () => new Uint8Array(64),
|
||||
exportPrivatePkcs8Pem: () => 'PEM',
|
||||
privateKeyObject: () => ({}) as never,
|
||||
}
|
||||
}
|
||||
|
||||
function fakeKeystore(enrolled: boolean, alg: AgentIdentity['alg'] = 'ed25519'): Keystore {
|
||||
return {
|
||||
saveIdentity: vi.fn(),
|
||||
loadIdentity: () => (enrolled ? fakeIdentity(alg) : null),
|
||||
saveCert: vi.fn(),
|
||||
loadCert: () => (enrolled ? { certPem: 'C', caChainPem: 'CA' } : null),
|
||||
saveContentSecret: vi.fn(),
|
||||
loadContentSecret: () => null,
|
||||
}
|
||||
}
|
||||
|
||||
const NATIVE_OPTIONS: InstallOptions = {
|
||||
env: { BIND_HOST: '127.0.0.1', PORT: '3000' },
|
||||
domain: 'yaojia.wang',
|
||||
zone: 'terminal',
|
||||
}
|
||||
|
||||
function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps; out: string[] } {
|
||||
const out: string[] = []
|
||||
const d: CliDeps = {
|
||||
loadConfig: () => CFG,
|
||||
openKeystore: () => fakeKeystore(enrolled),
|
||||
generateIdentity: () => fakeIdentity('ed25519'),
|
||||
generateP256Identity: () => fakeIdentity('p256'),
|
||||
redeem: async (): Promise<EnrollResult> => ({
|
||||
hostId: 'h-1',
|
||||
subdomain: 'host-42',
|
||||
cert: 'C',
|
||||
caChain: 'CA',
|
||||
hostContentSecret: new Uint8Array([1]),
|
||||
}),
|
||||
enrollNative: async () => ({ hostId: 'h-1', subdomain: 'host-42' }),
|
||||
provisionFrpc: async () => '/opt/frpc',
|
||||
writeFrpcConfig: vi.fn(),
|
||||
nativeConfigExists: () => false,
|
||||
runTunnel: async () => 0,
|
||||
superviseFrpc: async () => 0,
|
||||
resolveInstallOptions: () => NATIVE_OPTIONS,
|
||||
installService: vi.fn(async () => {}),
|
||||
uninstallService: vi.fn(async () => {}),
|
||||
print: (l) => out.push(l),
|
||||
...overrides,
|
||||
}
|
||||
return { d, out }
|
||||
}
|
||||
|
||||
describe('parseArgs (T5)', () => {
|
||||
it('parses `pair ABCD-1234`', () => {
|
||||
expect(parseArgs(['pair', 'ABCD-1234'])).toEqual({ command: 'pair', code: 'ABCD-1234', flags: {} })
|
||||
})
|
||||
|
||||
it('rejects an unknown command', () => {
|
||||
expect(() => parseArgs(['frobnicate'])).toThrow(CliUsageError)
|
||||
})
|
||||
|
||||
it('requires a code on pair', () => {
|
||||
expect(() => parseArgs(['pair'])).toThrow(CliUsageError)
|
||||
})
|
||||
|
||||
it('collects flags', () => {
|
||||
expect(parseArgs(['pair', 'X', '--install'])).toEqual({
|
||||
command: 'pair',
|
||||
code: 'X',
|
||||
flags: { install: true },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('runCli — legacy relay pair (no --install)', () => {
|
||||
it('pair happy path calls redeem and prints no secrets', async () => {
|
||||
const { d, out } = deps()
|
||||
const code = await runCli(parseArgs(['pair', 'ABCD']), d)
|
||||
expect(code).toBe(0)
|
||||
expect(out.join('\n')).toContain('host-42')
|
||||
expect(out.join('\n')).not.toContain('PEM')
|
||||
})
|
||||
})
|
||||
|
||||
describe('runCli — native pair --install onboard (B5)', () => {
|
||||
it('wires keygen(P-256)→enroll→provisionFrpc→write toml+env→install both→print URL, in order', async () => {
|
||||
const calls: string[] = []
|
||||
const enrolledIds: AgentIdentity[] = []
|
||||
const generateP256Identity = vi.fn(() => {
|
||||
calls.push('keygen')
|
||||
return fakeIdentity('p256')
|
||||
})
|
||||
const enrollNative = vi.fn(async (_cfg: AgentConfig, _code: string, id: AgentIdentity) => {
|
||||
calls.push('enroll')
|
||||
enrolledIds.push(id)
|
||||
return { hostId: 'h-1', subdomain: 'host-42' }
|
||||
})
|
||||
const provisionFrpc = vi.fn(async () => {
|
||||
calls.push('provision')
|
||||
return '/opt/frpc'
|
||||
})
|
||||
const writeFrpcConfig = vi.fn(() => {
|
||||
calls.push('writeConfig')
|
||||
})
|
||||
const installService = vi.fn(async () => {
|
||||
calls.push('install')
|
||||
})
|
||||
const { d, out } = deps({
|
||||
generateP256Identity,
|
||||
enrollNative,
|
||||
provisionFrpc,
|
||||
writeFrpcConfig,
|
||||
installService,
|
||||
})
|
||||
|
||||
const code = await runCli(parseArgs(['pair', 'ABCD-1234', '--install']), d)
|
||||
|
||||
expect(code).toBe(0)
|
||||
expect(calls).toEqual(['keygen', 'enroll', 'provision', 'writeConfig', 'install'])
|
||||
// CSR is built from a P-256 identity (FIX H-host-2)
|
||||
expect(enrolledIds[0]!.alg).toBe('p256')
|
||||
// enroll seam received the pairing code
|
||||
expect(enrollNative).toHaveBeenCalledWith(CFG, 'ABCD-1234', expect.anything(), expect.anything())
|
||||
// both units installed with the resolved options (env routed to base-app by installService)
|
||||
expect(installService).toHaveBeenCalledWith(CFG, NATIVE_OPTIONS)
|
||||
// frpc.toml written for the returned subdomain
|
||||
expect(writeFrpcConfig).toHaveBeenCalledWith(CFG, 'host-42')
|
||||
// prints the final tunnel URL
|
||||
expect(out.join('\n')).toContain('https://host-42.terminal.yaojia.wang')
|
||||
})
|
||||
|
||||
it('does NOT use the legacy relay redeem path on --install', async () => {
|
||||
const redeem = vi.fn()
|
||||
const { d } = deps({ redeem: redeem as unknown as CliDeps['redeem'] })
|
||||
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
|
||||
expect(redeem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves the freshly generated P-256 identity before enrolling', async () => {
|
||||
const ks = fakeKeystore(false)
|
||||
const { d } = deps({ openKeystore: () => ks })
|
||||
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
|
||||
expect(ks.saveIdentity).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a native install whose zone is not `terminal` (FIX L-host-zone)', async () => {
|
||||
const { d } = deps({
|
||||
resolveInstallOptions: () => ({ env: { BIND_HOST: '127.0.0.1' }, domain: 'yaojia.wang', zone: 'term' }),
|
||||
})
|
||||
await expect(runCli(parseArgs(['pair', 'ABCD', '--install']), d)).rejects.toThrow(/terminal/)
|
||||
})
|
||||
|
||||
it('rejects a native install with no TUNNEL_DOMAIN (cannot form the origin)', async () => {
|
||||
const { d } = deps({ resolveInstallOptions: () => ({ env: { BIND_HOST: '127.0.0.1' } }) })
|
||||
await expect(runCli(parseArgs(['pair', 'ABCD', '--install']), d)).rejects.toBeInstanceOf(CliUsageError)
|
||||
})
|
||||
|
||||
it('prints no key/cert material during --install (INV9)', async () => {
|
||||
const { d, out } = deps()
|
||||
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
|
||||
const joined = out.join('\n')
|
||||
expect(joined).not.toContain('PEM')
|
||||
expect(joined).not.toContain('CA')
|
||||
})
|
||||
})
|
||||
|
||||
describe('runCli — install / run / status', () => {
|
||||
it('install threads the resolved InstallOptions into installService (S2 env injection)', async () => {
|
||||
const install = vi.fn(async () => {})
|
||||
const { d } = deps({ resolveInstallOptions: () => NATIVE_OPTIONS, installService: install })
|
||||
const code = await runCli(parseArgs(['install']), d)
|
||||
expect(code).toBe(0)
|
||||
expect(install).toHaveBeenCalledWith(CFG, NATIVE_OPTIONS)
|
||||
})
|
||||
|
||||
it('run before pairing fails fast', async () => {
|
||||
const { d } = deps({}, false)
|
||||
await expect(runCli({ command: 'run', flags: {} }, d)).rejects.toBeInstanceOf(CliUsageError)
|
||||
})
|
||||
|
||||
it('legacy run (Ed25519 identity, no frpc.toml) drives runTunnel — not frpc supervision', async () => {
|
||||
const runTunnel = vi.fn(async () => 0)
|
||||
const superviseFrpc = vi.fn(async () => 0)
|
||||
const { d } = deps(
|
||||
{ runTunnel, superviseFrpc, nativeConfigExists: () => false },
|
||||
true, // enrolled with the default Ed25519 identity
|
||||
)
|
||||
const code = await runCli({ command: 'run', flags: {} }, d)
|
||||
expect(code).toBe(0)
|
||||
expect(runTunnel).toHaveBeenCalledWith(CFG, expect.anything())
|
||||
expect(superviseFrpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('native run (P-256 identity + frpc.toml) supervises frpc — not the legacy relay', async () => {
|
||||
const runTunnel = vi.fn(async () => 0)
|
||||
const superviseFrpc = vi.fn(async () => 0)
|
||||
const { d } = deps({
|
||||
openKeystore: () => fakeKeystore(true, 'p256'),
|
||||
nativeConfigExists: () => true,
|
||||
runTunnel,
|
||||
superviseFrpc,
|
||||
})
|
||||
const code = await runCli({ command: 'run', flags: {} }, d)
|
||||
expect(code).toBe(0)
|
||||
expect(superviseFrpc).toHaveBeenCalledWith(CFG, expect.anything())
|
||||
expect(runTunnel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('native identity but missing frpc.toml falls back to the legacy relay path', async () => {
|
||||
const runTunnel = vi.fn(async () => 0)
|
||||
const superviseFrpc = vi.fn(async () => 0)
|
||||
const { d } = deps({
|
||||
openKeystore: () => fakeKeystore(true, 'p256'),
|
||||
nativeConfigExists: () => false,
|
||||
runTunnel,
|
||||
superviseFrpc,
|
||||
})
|
||||
await runCli({ command: 'run', flags: {} }, d)
|
||||
expect(runTunnel).toHaveBeenCalled()
|
||||
expect(superviseFrpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('status prints no key/cert material (INV9)', async () => {
|
||||
const { d, out } = deps({}, true)
|
||||
await runCli({ command: 'status', flags: {} }, d)
|
||||
const joined = out.join('\n')
|
||||
expect(joined).toContain('subdomain: host-42')
|
||||
expect(joined).not.toContain('PEM')
|
||||
expect(joined).not.toContain('CA')
|
||||
})
|
||||
})
|
||||
123
agent/test/csr.test.ts
Normal file
123
agent/test/csr.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { X509Certificate, createPublicKey, verify } from 'node:crypto'
|
||||
import { generateIdentity, generateP256Identity } from '../src/keys/identity.js'
|
||||
import { buildCsr } from '../src/enroll/csr.js'
|
||||
|
||||
// --- minimal DER reader (test-only) — walks the PKCS#10 outer SEQUENCE into its 3 children -------
|
||||
interface Tlv {
|
||||
readonly tag: number
|
||||
/** the full tag+length+value bytes (what was signed, for the CertificationRequestInfo). */
|
||||
readonly tlv: Uint8Array
|
||||
readonly content: Uint8Array
|
||||
}
|
||||
|
||||
function readTlv(buf: Uint8Array, off: number): { node: Tlv; next: number } {
|
||||
const tag = buf[off]!
|
||||
let i = off + 1
|
||||
const first = buf[i]!
|
||||
let len: number
|
||||
if (first < 0x80) {
|
||||
len = first
|
||||
i += 1
|
||||
} else {
|
||||
const n = first & 0x7f
|
||||
len = 0
|
||||
for (let k = 0; k < n; k++) len = (len << 8) | buf[i + 1 + k]!
|
||||
i += 1 + n
|
||||
}
|
||||
return { node: { tag, tlv: buf.subarray(off, i + len), content: buf.subarray(i, i + len) }, next: i + len }
|
||||
}
|
||||
|
||||
function pemToDer(pem: string): Uint8Array {
|
||||
const b64 = pem.replace(/-----[A-Z ]+-----/g, '').replace(/\s+/g, '')
|
||||
return new Uint8Array(Buffer.from(b64, 'base64'))
|
||||
}
|
||||
|
||||
/** The three children of the PKCS#10 outer SEQUENCE: [certificationRequestInfo, sigAlg, signature]. */
|
||||
function csrChildren(pem: string): readonly Tlv[] {
|
||||
const der = pemToDer(pem)
|
||||
const outer = readTlv(der, 0).node
|
||||
const children: Tlv[] = []
|
||||
let p = 0
|
||||
while (p < outer.content.length) {
|
||||
const { node, next } = readTlv(outer.content, p)
|
||||
children.push(node)
|
||||
p = next
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
describe('PKCS#10 CSR (T4)', () => {
|
||||
it('emits a PEM CERTIFICATE REQUEST', () => {
|
||||
const csr = buildCsr(generateIdentity(), 'host-42.term.example.com')
|
||||
expect(csr).toContain('-----BEGIN CERTIFICATE REQUEST-----')
|
||||
expect(csr).toContain('-----END CERTIFICATE REQUEST-----')
|
||||
})
|
||||
|
||||
it('does not contain private-key material (INV4)', () => {
|
||||
const id = generateIdentity()
|
||||
const csr = buildCsr(id, 'host-42')
|
||||
expect(csr).not.toContain('PRIVATE KEY')
|
||||
expect(csr).not.toContain(id.exportPrivatePkcs8Pem().split('\n')[1]!)
|
||||
})
|
||||
|
||||
it('produces a syntactically decodable DER structure', () => {
|
||||
// Node can't parse a CSR directly, but the base64 body must be valid DER (a SEQUENCE).
|
||||
const csr = buildCsr(generateIdentity(), 'host-42')
|
||||
const b64 = csr
|
||||
.replace(/-----[A-Z ]+-----/g, '')
|
||||
.replace(/\s+/g, '')
|
||||
const der = Buffer.from(b64, 'base64')
|
||||
expect(der[0]).toBe(0x30) // outer SEQUENCE tag
|
||||
expect(der.length).toBeGreaterThan(64)
|
||||
// sanity: X509Certificate exists (crypto available) — unrelated smoke to keep import used
|
||||
expect(typeof X509Certificate).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('P-256 PKCS#10 CSR (FIX H-host-2)', () => {
|
||||
it('emits a PEM CERTIFICATE REQUEST for a P-256 identity', () => {
|
||||
const csr = buildCsr(generateP256Identity(), 'alice.terminal.yaojia.wang')
|
||||
expect(csr).toContain('-----BEGIN CERTIFICATE REQUEST-----')
|
||||
expect(csr).toContain('-----END CERTIFICATE REQUEST-----')
|
||||
expect(csr).not.toContain('PRIVATE KEY')
|
||||
})
|
||||
|
||||
it('round-trips as a valid PKCS#10: signatureAlgorithm is ecdsa-with-SHA256', () => {
|
||||
const csr = buildCsr(generateP256Identity(), 'alice.terminal.yaojia.wang')
|
||||
const [, sigAlg] = csrChildren(csr)
|
||||
// sigAlg = SEQUENCE { OID 1.2.840.10045.4.3.2 } (no parameters, RFC 5758 §3.2)
|
||||
const oidTlv = readTlv(sigAlg!.content, 0).node
|
||||
expect(Array.from(oidTlv.content)).toEqual([0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x02])
|
||||
// no parameters: the sigAlg SEQUENCE holds ONLY the OID.
|
||||
expect(oidTlv.tlv.length).toBe(sigAlg!.content.length)
|
||||
})
|
||||
|
||||
it('self-signature verifies over the CertificationRequestInfo (verifyCsrPoPEc semantics)', () => {
|
||||
const id = generateP256Identity()
|
||||
const csr = buildCsr(id, 'alice.terminal.yaojia.wang')
|
||||
const [reqInfo, , sigVal] = csrChildren(csr)
|
||||
// signatureValue BIT STRING content = 0x00 (unused bits) || DER ECDSA-Sig-Value.
|
||||
expect(sigVal!.tag).toBe(0x03)
|
||||
const signature = sigVal!.content.subarray(1)
|
||||
const pub = createPublicKey({ key: Buffer.from(id.publicKey), format: 'der', type: 'spki' })
|
||||
// Verify over the EXACT CertificationRequestInfo bytes that buildCsr signed.
|
||||
expect(verify('sha256', reqInfo!.tlv, pub, signature)).toBe(true)
|
||||
// Tampering with the signed body breaks PoP.
|
||||
const tampered = Uint8Array.from(reqInfo!.tlv)
|
||||
tampered[tampered.length - 1] = tampered[tampered.length - 1]! ^ 0xff
|
||||
expect(verify('sha256', tampered, pub, signature)).toBe(false)
|
||||
})
|
||||
|
||||
it('embeds the identity EC SPKI verbatim as the CSR subjectPublicKeyInfo', () => {
|
||||
const id = generateP256Identity()
|
||||
const csr = buildCsr(id, 'alice.terminal.yaojia.wang')
|
||||
const [reqInfo] = csrChildren(csr)
|
||||
// requestInfo = SEQUENCE { version, name, spki, [0] attributes }; the spki is the 3rd child.
|
||||
const inner = reqInfo!.content
|
||||
const version = readTlv(inner, 0)
|
||||
const name = readTlv(inner, version.next)
|
||||
const spki = readTlv(inner, name.next).node
|
||||
expect(Buffer.from(spki.tlv).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||
})
|
||||
})
|
||||
103
agent/test/deps.test.ts
Normal file
103
agent/test/deps.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* B4/H4 wiring test — closes the frpc-log capture loop that `HealthReport.healthy` depends on.
|
||||
*
|
||||
* Regression guarded: nothing used to WRITE `<stateDir>/frpc.log`, so `readFrpcLog` always returned
|
||||
* '' and `frpcProxyStarted` was permanently false — health could never be true. This exercises the
|
||||
* real production path end-to-end: `createFileLoggingSpawn` (the spawn `superviseNative` injects)
|
||||
* tees a child's stdout into the exact file `readFrpcLog` scans, and `frpcProxyStarted` detects the
|
||||
* "start proxy success" line. Writer path and reader path share `frpcLogPath`, so they can't diverge.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createFileLoggingSpawn, type FrpcChild } from '../src/transport/frpSupervise.js'
|
||||
import { frpcLogPath, readFrpcLog } from '../src/cli/deps.js'
|
||||
import { frpcProxyStarted } from '../src/health/probe.js'
|
||||
|
||||
const SUCCESS_LINE = '[web-terminal] start proxy success'
|
||||
|
||||
/** Poll `predicate` until true or `timeoutMs` elapses (real IO flush is async). */
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return true
|
||||
await new Promise((r) => setTimeout(r, 25))
|
||||
}
|
||||
return predicate()
|
||||
}
|
||||
|
||||
/** Write an executable fake `frpc` (node shebang) that repeatedly prints the success line to stdout. */
|
||||
function writeFakeFrpc(dir: string): string {
|
||||
const bin = join(dir, 'fake-frpc.mjs')
|
||||
const script =
|
||||
`#!${process.execPath}\n` +
|
||||
`process.stdout.write(${JSON.stringify(`${SUCCESS_LINE}\n`)})\n` +
|
||||
`setInterval(() => process.stdout.write(${JSON.stringify(`${SUCCESS_LINE}\n`)}), 100)\n`
|
||||
writeFileSync(bin, script, { mode: 0o755 })
|
||||
return bin
|
||||
}
|
||||
|
||||
describe('B4/H4 frpc-log capture wiring (createFileLoggingSpawn ↔ readFrpcLog)', () => {
|
||||
const dirs: string[] = []
|
||||
let child: FrpcChild | null = null
|
||||
|
||||
afterEach(() => {
|
||||
child?.kill()
|
||||
child = null
|
||||
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('tees the frpc child stdout into the file readFrpcLog scans → proxyStarted becomes true', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'frpclog-'))
|
||||
dirs.push(dir)
|
||||
|
||||
// Before any child runs, the log is empty and the proxy is not started.
|
||||
expect(readFrpcLog(dir)).toBe('')
|
||||
expect(frpcProxyStarted(readFrpcLog(dir))).toBe(false)
|
||||
|
||||
// Spawn via the SAME factory superviseNative injects, pointed at the SAME path it reads.
|
||||
const bin = writeFakeFrpc(dir)
|
||||
const spawn = createFileLoggingSpawn(frpcLogPath(dir))
|
||||
child = spawn(bin, join(dir, 'frpc.toml'))
|
||||
|
||||
const detected = await waitFor(() => frpcProxyStarted(readFrpcLog(dir)))
|
||||
expect(detected).toBe(true)
|
||||
expect(readFrpcLog(dir)).toContain('start proxy success')
|
||||
})
|
||||
|
||||
it('createFileLoggingSpawn truncates a stale log so a dead child’s success line is not reused', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'frpclog-'))
|
||||
dirs.push(dir)
|
||||
|
||||
// Simulate a leftover log from a previous (now dead) frpc that had succeeded.
|
||||
writeFileSync(frpcLogPath(dir), `${SUCCESS_LINE}\n`)
|
||||
expect(frpcProxyStarted(readFrpcLog(dir))).toBe(true)
|
||||
|
||||
// A fresh spawn whose child never prints the line must NOT keep reporting the stale success.
|
||||
const bin = join(dir, 'silent-frpc.mjs')
|
||||
writeFileSync(bin, `#!${process.execPath}\nsetInterval(() => {}, 100)\n`, { mode: 0o755 })
|
||||
const spawn = createFileLoggingSpawn(frpcLogPath(dir))
|
||||
child = spawn(bin, join(dir, 'frpc.toml'))
|
||||
|
||||
// Truncate-on-spawn clears the stale line; the silent child adds none.
|
||||
const cleared = await waitFor(() => readFrpcLog(dir) === '')
|
||||
expect(cleared).toBe(true)
|
||||
expect(frpcProxyStarted(readFrpcLog(dir))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('frpcLogPath / readFrpcLog (deterministic)', () => {
|
||||
it('frpcLogPath joins frpc.log under the state dir', () => {
|
||||
expect(frpcLogPath('/state/x')).toBe(join('/state/x', 'frpc.log'))
|
||||
})
|
||||
|
||||
it('readFrpcLog returns "" when the log file does not exist', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'frpclog-'))
|
||||
try {
|
||||
expect(readFrpcLog(dir)).toBe('')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
112
agent/test/dial.test.ts
Normal file
112
agent/test/dial.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import { generateIdentity } from '../src/keys/identity.js'
|
||||
import { openKeystore } from '../src/keys/keystore.js'
|
||||
import {
|
||||
CertExpiredError,
|
||||
NotEnrolledError,
|
||||
buildTlsOptions,
|
||||
dialRelay,
|
||||
type RawTlsWs,
|
||||
type TlsWsConstructor,
|
||||
} from '../src/transport/dial.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay.example.com/agent',
|
||||
enrollUrl: 'https://example.com/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
|
||||
function enrolledKs() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wta-dial-'))
|
||||
const ks = openKeystore(dir)
|
||||
ks.saveIdentity(generateIdentity())
|
||||
ks.saveCert('CERTPEM', 'CAPEM')
|
||||
return { dir, ks }
|
||||
}
|
||||
|
||||
const future: () => { validTo: Date } = () => ({ validTo: new Date(Date.now() + 86_400_000) })
|
||||
const past: () => { validTo: Date } = () => ({ validTo: new Date(Date.now() - 1000) })
|
||||
|
||||
describe('buildTlsOptions (T12, INV14/INV4)', () => {
|
||||
it('wires cert + key + CA and forces rejectUnauthorized true', () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const tls = buildTlsOptions(ks, { certParser: future })
|
||||
expect(tls.cert).toBe('CERTPEM')
|
||||
expect(tls.ca).toBe('CAPEM')
|
||||
expect(tls.key).toContain('PRIVATE KEY') // in-process key PEM
|
||||
expect(tls.rejectUnauthorized).toBe(true)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('carries NO bearer/agent token (mTLS is the auth)', () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const tls = buildTlsOptions(ks, { certParser: future })
|
||||
expect(JSON.stringify(tls)).not.toMatch(/token|authorization|bearer/i)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('missing cert → NotEnrolledError (fail-fast)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wta-dial-'))
|
||||
expect(() => buildTlsOptions(openKeystore(dir))).toThrow(NotEnrolledError)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('expired cert → CertExpiredError', () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
expect(() => buildTlsOptions(ks, { certParser: past })).toThrow(CertExpiredError)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('dialRelay (T12)', () => {
|
||||
it('constructs the wss client with the TLS options and resolves on open', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
let capturedUrl = ''
|
||||
let capturedOpts: unknown
|
||||
class FakeTlsWs implements RawTlsWs {
|
||||
constructor(url: string, opts: unknown) {
|
||||
capturedUrl = url
|
||||
capturedOpts = opts
|
||||
queueMicrotask(() => this.openCb?.())
|
||||
}
|
||||
private openCb?: () => void
|
||||
send(): void {}
|
||||
close(): void {}
|
||||
on(): void {}
|
||||
once(ev: string, cb: () => void): void {
|
||||
if (ev === 'open') this.openCb = cb
|
||||
}
|
||||
}
|
||||
const ws = await dialRelay(CFG, ks, { Ctor: FakeTlsWs as unknown as TlsWsConstructor, certParser: future })
|
||||
expect(capturedUrl).toBe('wss://relay.example.com/agent')
|
||||
expect((capturedOpts as { rejectUnauthorized: boolean }).rejectUnauthorized).toBe(true)
|
||||
expect(ws).toBeDefined()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('rejects when the server errors before open (bad chain / MITM)', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
class FakeTlsWs implements RawTlsWs {
|
||||
private errCb?: (e: unknown) => void
|
||||
constructor() {
|
||||
queueMicrotask(() => this.errCb?.(new Error('unable to verify leaf signature')))
|
||||
}
|
||||
send(): void {}
|
||||
close(): void {}
|
||||
on(): void {}
|
||||
once(ev: string, cb: (e: unknown) => void): void {
|
||||
if (ev === 'error') this.errCb = cb
|
||||
}
|
||||
}
|
||||
const p = dialRelay(CFG, ks, { Ctor: FakeTlsWs as unknown as TlsWsConstructor, certParser: future })
|
||||
await expect(p).rejects.toThrow(/verify/)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
63
agent/test/fixtures/fakes.ts
vendored
Normal file
63
agent/test/fixtures/fakes.ts
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { WsLike, TimerLike } from '../../src/transport/seams.js'
|
||||
|
||||
/** In-memory WsLike double: records sent frames, lets tests emit inbound events. */
|
||||
export class FakeWs implements WsLike {
|
||||
readonly sent: Uint8Array[] = []
|
||||
closed = false
|
||||
private readonly listeners = new Map<string, Array<(...a: unknown[]) => void>>()
|
||||
|
||||
send(d: Uint8Array): void {
|
||||
this.sent.push(d)
|
||||
}
|
||||
on(ev: 'message' | 'close' | 'error', cb: (...a: unknown[]) => void): void {
|
||||
const arr = this.listeners.get(ev) ?? []
|
||||
arr.push(cb)
|
||||
this.listeners.set(ev, arr)
|
||||
}
|
||||
close(): void {
|
||||
this.closed = true
|
||||
this.emit('close')
|
||||
}
|
||||
emit(ev: string, ...args: unknown[]): void {
|
||||
for (const cb of this.listeners.get(ev) ?? []) cb(...args)
|
||||
}
|
||||
emitMessage(bytes: Uint8Array): void {
|
||||
this.emit('message', bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/** Deterministic controllable timer for heartbeat/rotation tests. */
|
||||
export class FakeTimer implements TimerLike {
|
||||
private seq = 0
|
||||
private readonly timeouts = new Map<number, { cb: () => void; ms: number }>()
|
||||
private readonly intervals = new Map<number, { cb: () => void; ms: number }>()
|
||||
|
||||
setTimeout(cb: () => void, ms: number): unknown {
|
||||
const id = this.seq++
|
||||
this.timeouts.set(id, { cb, ms })
|
||||
return id
|
||||
}
|
||||
clearTimeout(handle: unknown): void {
|
||||
this.timeouts.delete(handle as number)
|
||||
}
|
||||
setInterval(cb: () => void, ms: number): unknown {
|
||||
const id = this.seq++
|
||||
this.intervals.set(id, { cb, ms })
|
||||
return id
|
||||
}
|
||||
clearInterval(handle: unknown): void {
|
||||
this.intervals.delete(handle as number)
|
||||
}
|
||||
/** Fire every armed timeout whose delay is ≤ ms (single shot) and every interval once. */
|
||||
advance(ms: number): void {
|
||||
for (const [id, t] of [...this.timeouts]) {
|
||||
if (t.ms <= ms) {
|
||||
this.timeouts.delete(id)
|
||||
t.cb()
|
||||
}
|
||||
}
|
||||
for (const t of [...this.intervals.values()]) {
|
||||
if (t.ms <= ms) t.cb()
|
||||
}
|
||||
}
|
||||
}
|
||||
37
agent/test/flowControl.test.ts
Normal file
37
agent/test/flowControl.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createFlowController } from '../src/transport/flowControl.js'
|
||||
|
||||
describe('FlowController (T11)', () => {
|
||||
it('pauses at zero credit and resumes on grant', () => {
|
||||
const fc = createFlowController()
|
||||
fc.initWindow(1, 10)
|
||||
expect(fc.consume(1, 8)).toBe(true)
|
||||
expect(fc.consume(1, 8)).toBe(false) // only 2 credit left → pause
|
||||
fc.grant(1, 16)
|
||||
expect(fc.consume(1, 8)).toBe(true)
|
||||
})
|
||||
|
||||
it('credit is per-stream: exhausting A does not pause B (starvation guard)', () => {
|
||||
const fc = createFlowController()
|
||||
fc.initWindow(1, 4)
|
||||
fc.initWindow(2, 100)
|
||||
expect(fc.consume(1, 4)).toBe(true)
|
||||
expect(fc.consume(1, 1)).toBe(false) // A exhausted
|
||||
expect(fc.consume(2, 50)).toBe(true) // B unaffected
|
||||
})
|
||||
|
||||
it('connection-level (streamId 0) credit caps the whole link', () => {
|
||||
const fc = createFlowController()
|
||||
fc.initWindow(0, 5) // connection window
|
||||
fc.initWindow(1, 1000)
|
||||
expect(fc.consume(1, 5)).toBe(true)
|
||||
expect(fc.consume(1, 1)).toBe(false) // connection window exhausted despite stream credit
|
||||
fc.grant(0, 10)
|
||||
expect(fc.consume(1, 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('an uninitialized stream has no credit', () => {
|
||||
const fc = createFlowController()
|
||||
expect(fc.consume(42, 1)).toBe(false)
|
||||
})
|
||||
})
|
||||
53
agent/test/frpScaffold.test.ts
Normal file
53
agent/test/frpScaffold.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import {
|
||||
buildFrpcToml,
|
||||
isFrpRetired,
|
||||
spawnFrpc,
|
||||
type ChildLike,
|
||||
} from '../src/transport/frpScaffold.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://x/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: null,
|
||||
}
|
||||
|
||||
describe('frpScaffold (T6, v0.8 only)', () => {
|
||||
it('generates a loopback-only tls frpc.toml', () => {
|
||||
const toml = buildFrpcToml(CFG)
|
||||
expect(toml).toContain('local_ip = "127.0.0.1"')
|
||||
expect(toml).toContain('local_port = 3000')
|
||||
expect(toml).toContain('subdomain = "host-42"')
|
||||
expect(toml).toContain('tls_enable = true')
|
||||
expect(toml).not.toMatch(/local_ip = "(?!127\.0\.0\.1)/)
|
||||
})
|
||||
|
||||
it('refuses a non-loopback local target (anti-SSRF)', () => {
|
||||
expect(() => buildFrpcToml({ ...CFG, localTargetUrl: 'ws://10.0.0.5:3000' })).toThrow()
|
||||
})
|
||||
|
||||
it('propagates child exit to onExit', async () => {
|
||||
let exitHandler: ((code: number | null) => void) | null = null
|
||||
const child: ChildLike = {
|
||||
on: (_ev, cb) => {
|
||||
exitHandler = cb
|
||||
},
|
||||
kill: vi.fn(),
|
||||
}
|
||||
const scaffold = spawnFrpc(CFG, '/usr/bin/frpc', () => child)
|
||||
const seen: number[] = []
|
||||
scaffold.onExit((c) => seen.push(c))
|
||||
await scaffold.start()
|
||||
exitHandler!(7)
|
||||
expect(seen).toEqual([7])
|
||||
})
|
||||
|
||||
it('retirement guard: retired once ed25519', () => {
|
||||
expect(isFrpRetired('ed25519')).toBe(true)
|
||||
expect(isFrpRetired('token')).toBe(false)
|
||||
})
|
||||
})
|
||||
188
agent/test/frpSupervise.test.ts
Normal file
188
agent/test/frpSupervise.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createLogger } from '../src/log/logger.js'
|
||||
import { createBackoff } from '../src/transport/backoff.js'
|
||||
import {
|
||||
STABLE_RUN_MS,
|
||||
superviseFrpc,
|
||||
type FrpcChild,
|
||||
type SpawnFrpc,
|
||||
} from '../src/transport/frpSupervise.js'
|
||||
|
||||
const silentLogger = createLogger('error', () => {})
|
||||
|
||||
/**
|
||||
* A fake frpc child whose exit is driven from the test. `kill()` models a real process: it dies,
|
||||
* firing the exit handler (so the supervisor's `stop()` — which kills the live child — can unblock).
|
||||
* exit/kill fire the handler at most once.
|
||||
*/
|
||||
function makeChild(): { child: FrpcChild; exit: (code: number | null) => void; killed: boolean } {
|
||||
let onExit: ((code: number | null) => void) | null = null
|
||||
let alive = true
|
||||
const state = { killed: false }
|
||||
const fire = (code: number | null): void => {
|
||||
if (!alive) return
|
||||
alive = false
|
||||
onExit?.(code)
|
||||
}
|
||||
return {
|
||||
child: {
|
||||
onExit: (cb) => {
|
||||
onExit = cb
|
||||
},
|
||||
isAlive: () => alive,
|
||||
kill: () => {
|
||||
state.killed = true
|
||||
fire(null)
|
||||
},
|
||||
},
|
||||
exit: (code) => fire(code),
|
||||
get killed() {
|
||||
return state.killed
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('superviseFrpc (B4/H4 — restart-on-exit backoff)', () => {
|
||||
it('spawns the frpc binary with -c <toml> via the child seam', async () => {
|
||||
const spawn: SpawnFrpc = vi.fn(() => makeChild().child)
|
||||
superviseFrpc('/opt/agent/bin/frpc', '/state/frpc.toml', {
|
||||
spawn,
|
||||
sleep: async () => {},
|
||||
logger: silentLogger,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(spawn).toHaveBeenCalledWith('/opt/agent/bin/frpc', '/state/frpc.toml')
|
||||
})
|
||||
|
||||
it('restarts the child on exit, backing off 1s → 2s → 4s', async () => {
|
||||
const children = [makeChild(), makeChild(), makeChild(), makeChild()]
|
||||
let n = 0
|
||||
const spawn: SpawnFrpc = () => children[n++]!.child
|
||||
const sleeps: number[] = []
|
||||
// now() fixed so no run counts as "stable" ⇒ backoff monotonically increases.
|
||||
const handle = superviseFrpc('/frpc', '/toml', {
|
||||
spawn,
|
||||
backoff: createBackoff(),
|
||||
sleep: async (ms) => {
|
||||
sleeps.push(ms)
|
||||
},
|
||||
logger: silentLogger,
|
||||
now: () => 1000,
|
||||
})
|
||||
|
||||
// Crash three times; each crash schedules the next spawn after the growing backoff.
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
children[i]!.exit(1)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
expect(sleeps).toEqual([1000, 2000, 4000])
|
||||
|
||||
await handle.stop()
|
||||
})
|
||||
|
||||
it('resets the backoff after a run that stayed up past the stability window', async () => {
|
||||
const children = [makeChild(), makeChild(), makeChild()]
|
||||
let n = 0
|
||||
const spawn: SpawnFrpc = () => children[n++]!.child
|
||||
const sleeps: number[] = []
|
||||
let clock = 0
|
||||
const handle = superviseFrpc('/frpc', '/toml', {
|
||||
spawn,
|
||||
backoff: createBackoff(),
|
||||
sleep: async (ms) => {
|
||||
sleeps.push(ms)
|
||||
},
|
||||
logger: silentLogger,
|
||||
now: () => clock,
|
||||
})
|
||||
|
||||
// First run crashes instantly ⇒ backoff 1s.
|
||||
children[0]!.exit(1)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
// Second run stays up past STABLE_RUN_MS before dying ⇒ backoff resets to 1s (not 2s).
|
||||
clock += STABLE_RUN_MS + 1
|
||||
children[1]!.exit(1)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(sleeps).toEqual([1000, 1000])
|
||||
await handle.stop()
|
||||
})
|
||||
|
||||
it('stop() halts the loop and kills the live child; done resolves 0', async () => {
|
||||
const c = makeChild()
|
||||
const spawn: SpawnFrpc = () => c.child
|
||||
const handle = superviseFrpc('/frpc', '/toml', {
|
||||
spawn,
|
||||
sleep: async () => {},
|
||||
logger: silentLogger,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(handle.isChildAlive()).toBe(true)
|
||||
|
||||
// stop() kills the child; that fires the exit handler and the loop observes `stopped` → breaks.
|
||||
const stopping = handle.stop()
|
||||
c.exit(null)
|
||||
await expect(stopping).resolves.toBeUndefined()
|
||||
await expect(handle.done).resolves.toBe(0)
|
||||
expect(c.killed).toBe(true)
|
||||
})
|
||||
|
||||
it('does not restart after stop (no spawn past shutdown)', async () => {
|
||||
const first = makeChild()
|
||||
let n = 0
|
||||
const spawn: SpawnFrpc = vi.fn(() => (n++ === 0 ? first.child : makeChild().child))
|
||||
const handle = superviseFrpc('/frpc', '/toml', {
|
||||
spawn,
|
||||
sleep: async () => {},
|
||||
logger: silentLogger,
|
||||
})
|
||||
await Promise.resolve()
|
||||
const stopping = handle.stop()
|
||||
first.exit(0)
|
||||
await stopping
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('restartChild() kills the running child so the loop respawns onto the fresh cert files', async () => {
|
||||
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
|
||||
const children = [makeChild(), makeChild()]
|
||||
let n = 0
|
||||
const spawn: SpawnFrpc = () => children[n++]!.child
|
||||
const handle = superviseFrpc('/frpc', '/toml', {
|
||||
spawn,
|
||||
sleep: async () => {},
|
||||
logger: silentLogger,
|
||||
now: () => 1_000_000, // fixed clock: run counts as unstable, but sleep is a no-op → respawn is immediate
|
||||
})
|
||||
await flush()
|
||||
expect(handle.isChildAlive()).toBe(true) // child[0]
|
||||
|
||||
handle.restartChild() // A5: rotator calls this after a cert rotation to reload the new leaf
|
||||
expect(children[0]!.killed).toBe(true)
|
||||
|
||||
await flush()
|
||||
expect(n).toBe(2) // child[1] spawned — frpc now reads the rotated cert on disk
|
||||
expect(handle.isChildAlive()).toBe(true)
|
||||
await handle.stop()
|
||||
})
|
||||
|
||||
it('restartChild() after stop is a no-op (no spawn past shutdown)', async () => {
|
||||
const first = makeChild()
|
||||
let n = 0
|
||||
const spawn: SpawnFrpc = vi.fn(() => (n++ === 0 ? first.child : makeChild().child))
|
||||
const handle = superviseFrpc('/frpc', '/toml', {
|
||||
spawn,
|
||||
sleep: async () => {},
|
||||
logger: silentLogger,
|
||||
})
|
||||
await Promise.resolve()
|
||||
const stopping = handle.stop()
|
||||
first.exit(0)
|
||||
await stopping
|
||||
handle.restartChild() // must not resurrect the supervisor
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
416
agent/test/frpcBinary.test.ts
Normal file
416
agent/test/frpcBinary.test.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { gzipSync } from 'node:zlib'
|
||||
import {
|
||||
detectFrpcPlatform,
|
||||
FRPC_PLATFORMS,
|
||||
FRPC_RELEASES,
|
||||
provisionFrpc,
|
||||
type FrpcPlatform,
|
||||
type FrpcReleaseRef,
|
||||
type ProvisionFrpcDeps,
|
||||
} from '../src/provision/frpcBinary.js'
|
||||
import {
|
||||
extractTarFileByBasename,
|
||||
extractFrpcBinary,
|
||||
TarExtractError,
|
||||
} from '../src/provision/untar.js'
|
||||
|
||||
function sha256Hex(data: Uint8Array): string {
|
||||
return createHash('sha256').update(data).digest('hex')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-test tar/gzip fixture builder — hand-builds real USTAR blocks so the
|
||||
// extractor is exercised against the SAME on-disk layout frp ships (dir entry
|
||||
// + regular files), with zero network access.
|
||||
// ---------------------------------------------------------------------------
|
||||
const TAR_BLOCK = 512
|
||||
const TYPE_FILE = '0'
|
||||
const TYPE_DIR = '5'
|
||||
|
||||
interface TarEntrySpec {
|
||||
readonly name: string
|
||||
readonly content: Uint8Array
|
||||
readonly typeflag?: string
|
||||
}
|
||||
|
||||
function writeOctalField(block: Uint8Array, offset: number, len: number, value: number): void {
|
||||
const s = value.toString(8).padStart(len - 1, '0')
|
||||
block.set(new TextEncoder().encode(s), offset)
|
||||
block[offset + len - 1] = 0 // NUL terminator
|
||||
}
|
||||
|
||||
function tarHeader(name: string, size: number, typeflag: string): Uint8Array {
|
||||
const block = new Uint8Array(TAR_BLOCK)
|
||||
const enc = new TextEncoder()
|
||||
block.set(enc.encode(name).subarray(0, 100), 0)
|
||||
writeOctalField(block, 100, 8, 0o755) // mode
|
||||
writeOctalField(block, 108, 8, 0) // uid
|
||||
writeOctalField(block, 116, 8, 0) // gid
|
||||
writeOctalField(block, 124, 12, size) // size
|
||||
writeOctalField(block, 136, 12, 0) // mtime
|
||||
block[156] = typeflag.charCodeAt(0)
|
||||
block.set(enc.encode('ustar'), 257) // magic "ustar\0"
|
||||
block[263] = 0x30 // version "00"
|
||||
block[264] = 0x30
|
||||
// checksum: fields spaces during compute, then written as 6 octal + NUL + space
|
||||
for (let i = 148; i < 156; i++) block[i] = 0x20
|
||||
let sum = 0
|
||||
for (let i = 0; i < TAR_BLOCK; i++) sum += block[i] ?? 0
|
||||
block.set(enc.encode(sum.toString(8).padStart(6, '0')), 148)
|
||||
block[154] = 0
|
||||
block[155] = 0x20
|
||||
return block
|
||||
}
|
||||
|
||||
function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
|
||||
const total = parts.reduce((n, p) => n + p.length, 0)
|
||||
const out = new Uint8Array(total)
|
||||
let off = 0
|
||||
for (const p of parts) {
|
||||
out.set(p, off)
|
||||
off += p.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function makeTar(entries: readonly TarEntrySpec[]): Uint8Array {
|
||||
const parts: Uint8Array[] = []
|
||||
for (const e of entries) {
|
||||
parts.push(tarHeader(e.name, e.content.length, e.typeflag ?? TYPE_FILE))
|
||||
parts.push(e.content)
|
||||
const pad = (TAR_BLOCK - (e.content.length % TAR_BLOCK)) % TAR_BLOCK
|
||||
if (pad > 0) parts.push(new Uint8Array(pad))
|
||||
}
|
||||
parts.push(new Uint8Array(TAR_BLOCK * 2)) // end-of-archive: two zero blocks
|
||||
return concatBytes(parts)
|
||||
}
|
||||
|
||||
function makeTarGz(entries: readonly TarEntrySpec[]): Uint8Array {
|
||||
return new Uint8Array(gzipSync(makeTar(entries)))
|
||||
}
|
||||
|
||||
const FRPC_BYTES = new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 0xde, 0xad, 0xbe, 0xef]) // fake "ELF" frpc
|
||||
const FRPS_BYTES = new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x11, 0x22, 0x33]) // decoy frps
|
||||
|
||||
/** A realistic frp archive layout: dir entry + frpc + decoy frps + LICENSE. */
|
||||
function realisticFrpTarGz(prefix = 'frp_0.61.1_darwin_arm64'): Uint8Array {
|
||||
return makeTarGz([
|
||||
{ name: `${prefix}/`, content: new Uint8Array(0), typeflag: TYPE_DIR },
|
||||
{ name: `${prefix}/frps`, content: FRPS_BYTES },
|
||||
{ name: `${prefix}/frpc`, content: FRPC_BYTES },
|
||||
{ name: `${prefix}/LICENSE`, content: new TextEncoder().encode('MIT') },
|
||||
])
|
||||
}
|
||||
|
||||
interface FakeFsState {
|
||||
writes: Map<string, Uint8Array>
|
||||
renames: Array<{ from: string; to: string }>
|
||||
removed: string[]
|
||||
chmods: Array<{ path: string; mode: number }>
|
||||
mkdirs: string[]
|
||||
}
|
||||
|
||||
function makeFakeDeps(bytesByUrl: Record<string, Uint8Array>): {
|
||||
deps: ProvisionFrpcDeps
|
||||
state: FakeFsState
|
||||
fetchedUrls: string[]
|
||||
} {
|
||||
const state: FakeFsState = {
|
||||
writes: new Map(),
|
||||
renames: [],
|
||||
removed: [],
|
||||
chmods: [],
|
||||
mkdirs: [],
|
||||
}
|
||||
const fetchedUrls: string[] = []
|
||||
const deps: ProvisionFrpcDeps = {
|
||||
fetch: async (url) => {
|
||||
fetchedUrls.push(url)
|
||||
const bytes = bytesByUrl[url]
|
||||
if (!bytes) throw new Error(`test: no fixture bytes for ${url}`)
|
||||
return bytes
|
||||
},
|
||||
fs: {
|
||||
mkdir: async (dir) => {
|
||||
state.mkdirs.push(dir)
|
||||
},
|
||||
writeFile: async (path, data) => {
|
||||
state.writes.set(path, data)
|
||||
},
|
||||
rename: async (from, to) => {
|
||||
state.renames.push({ from, to })
|
||||
const data = state.writes.get(from)
|
||||
if (data) {
|
||||
state.writes.delete(from)
|
||||
state.writes.set(to, data)
|
||||
}
|
||||
},
|
||||
chmod: async (path, mode) => {
|
||||
state.chmods.push({ path, mode })
|
||||
},
|
||||
rm: async (path) => {
|
||||
state.removed.push(path)
|
||||
state.writes.delete(path)
|
||||
},
|
||||
},
|
||||
}
|
||||
return { deps, state, fetchedUrls }
|
||||
}
|
||||
|
||||
function releaseOverride(
|
||||
platform: FrpcPlatform,
|
||||
ref: FrpcReleaseRef,
|
||||
): Record<FrpcPlatform, FrpcReleaseRef> {
|
||||
return { ...FRPC_RELEASES, [platform]: ref }
|
||||
}
|
||||
|
||||
const BIN_DIR = '/opt/wt/bin'
|
||||
const BIN_PATH = '/opt/wt/bin/frpc'
|
||||
|
||||
describe('detectFrpcPlatform (B3)', () => {
|
||||
it('maps darwin/linux × arm64/amd64 (node x64 → amd64)', () => {
|
||||
expect(detectFrpcPlatform('darwin', 'arm64')).toBe('darwin-arm64')
|
||||
expect(detectFrpcPlatform('darwin', 'x64')).toBe('darwin-amd64')
|
||||
expect(detectFrpcPlatform('linux', 'arm64')).toBe('linux-arm64')
|
||||
expect(detectFrpcPlatform('linux', 'x64')).toBe('linux-amd64')
|
||||
})
|
||||
|
||||
it('returns null for unsupported platform/arch', () => {
|
||||
expect(detectFrpcPlatform('win32', 'x64')).toBeNull()
|
||||
expect(detectFrpcPlatform('linux', 'ia32')).toBeNull()
|
||||
expect(detectFrpcPlatform('freebsd', 'arm64')).toBeNull()
|
||||
expect(detectFrpcPlatform('darwin', 'mips')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FRPC_RELEASES pinning', () => {
|
||||
it('pins a release per supported platform (https url, semver, 64-hex sha256)', () => {
|
||||
expect([...FRPC_PLATFORMS].sort()).toEqual([
|
||||
'darwin-amd64',
|
||||
'darwin-arm64',
|
||||
'linux-amd64',
|
||||
'linux-arm64',
|
||||
])
|
||||
for (const platform of FRPC_PLATFORMS) {
|
||||
const ref = FRPC_RELEASES[platform]
|
||||
expect(ref.url.startsWith('https://')).toBe(true)
|
||||
expect(ref.url.endsWith('.tar.gz')).toBe(true)
|
||||
expect(ref.version).toMatch(/^\d+\.\d+\.\d+$/)
|
||||
expect(ref.sha256).toMatch(/^[0-9a-f]{64}$/)
|
||||
}
|
||||
})
|
||||
|
||||
it('pins REAL (non-placeholder) frp v0.61.1 checksums', () => {
|
||||
for (const platform of FRPC_PLATFORMS) {
|
||||
expect(FRPC_RELEASES[platform].sha256).not.toBe('0'.repeat(64))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractTarFileByBasename (tar path-traversal safe extractor)', () => {
|
||||
it('returns the bytes of the first regular file whose basename matches', () => {
|
||||
const tar = makeTar([
|
||||
{ name: 'frp_x/', content: new Uint8Array(0), typeflag: TYPE_DIR },
|
||||
{ name: 'frp_x/frps', content: FRPS_BYTES },
|
||||
{ name: 'frp_x/frpc', content: FRPC_BYTES },
|
||||
])
|
||||
expect(extractTarFileByBasename(tar, 'frpc')).toEqual(FRPC_BYTES)
|
||||
expect(extractTarFileByBasename(tar, 'frps')).toEqual(FRPS_BYTES)
|
||||
})
|
||||
|
||||
it('does NOT match a directory entry that shares the basename', () => {
|
||||
const tar = makeTar([
|
||||
{ name: 'frpc/', content: new Uint8Array(0), typeflag: TYPE_DIR },
|
||||
{ name: 'frp_x/frpc', content: FRPC_BYTES },
|
||||
])
|
||||
expect(extractTarFileByBasename(tar, 'frpc')).toEqual(FRPC_BYTES)
|
||||
})
|
||||
|
||||
it('throws when no matching file entry exists', () => {
|
||||
const tar = makeTar([{ name: 'frp_x/frps', content: FRPS_BYTES }])
|
||||
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(TarExtractError)
|
||||
})
|
||||
|
||||
it('REJECTS a matching entry whose name contains a ".." traversal segment', () => {
|
||||
const tar = makeTar([{ name: 'frp_x/../../../tmp/frpc', content: FRPC_BYTES }])
|
||||
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(/unsafe|traversal|\.\./i)
|
||||
})
|
||||
|
||||
it('REJECTS a matching entry with an absolute path name', () => {
|
||||
const tar = makeTar([{ name: '/etc/frpc', content: FRPC_BYTES }])
|
||||
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(/unsafe|absolute/i)
|
||||
})
|
||||
|
||||
it('throws on a corrupt (truncated) tar rather than reading out of bounds', () => {
|
||||
const full = makeTar([{ name: 'frp_x/frpc', content: FRPC_BYTES }])
|
||||
const truncated = full.subarray(0, TAR_BLOCK + 4) // header + partial content
|
||||
expect(() => extractTarFileByBasename(truncated, 'frpc')).toThrow(TarExtractError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractFrpcBinary (gunzip + extract)', () => {
|
||||
it('gunzips then extracts the inner frpc bytes', () => {
|
||||
expect(extractFrpcBinary(realisticFrpTarGz())).toEqual(FRPC_BYTES)
|
||||
})
|
||||
|
||||
it('throws on non-gzip input', () => {
|
||||
expect(() => extractFrpcBinary(new Uint8Array([1, 2, 3, 4]))).toThrow(TarExtractError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('provisionFrpc (B3 verify-download + extract discipline)', () => {
|
||||
it('selects the arch URL, VERIFIES the archive, and places the INNER frpc (not the archive)', async () => {
|
||||
const archive = realisticFrpTarGz()
|
||||
const url = 'https://example.test/frp-darwin-arm64.tar.gz'
|
||||
const releases = releaseOverride('darwin-arm64', {
|
||||
version: '0.61.1',
|
||||
url,
|
||||
sha256: sha256Hex(archive),
|
||||
})
|
||||
const { deps, state, fetchedUrls } = makeFakeDeps({ [url]: archive })
|
||||
|
||||
const result = await provisionFrpc(
|
||||
{ platform: 'darwin', arch: 'arm64', binDir: BIN_DIR, releases },
|
||||
deps,
|
||||
)
|
||||
|
||||
expect(fetchedUrls).toEqual([url])
|
||||
expect(result.binPath).toBe(BIN_PATH)
|
||||
expect(result.version).toBe('0.61.1')
|
||||
expect(result.platform).toBe('darwin-arm64')
|
||||
// atomic place: renamed into the final path, made executable
|
||||
expect(state.renames.some((r) => r.to === BIN_PATH)).toBe(true)
|
||||
expect(state.chmods.some((c) => (c.mode & 0o111) !== 0)).toBe(true)
|
||||
// the placed file is the EXTRACTED frpc binary, NOT the .tar.gz archive
|
||||
const placed = state.writes.get(BIN_PATH)
|
||||
expect(placed).toEqual(FRPC_BYTES)
|
||||
expect(placed).not.toEqual(archive)
|
||||
})
|
||||
|
||||
it('ignores the decoy frps entry and places frpc even when frps precedes it', async () => {
|
||||
const archive = makeTarGz([
|
||||
{ name: 'frp_x/frps', content: FRPS_BYTES },
|
||||
{ name: 'frp_x/frpc', content: FRPC_BYTES },
|
||||
])
|
||||
const url = 'https://example.test/frp.tar.gz'
|
||||
const releases = releaseOverride('linux-amd64', {
|
||||
version: '0.61.1',
|
||||
url,
|
||||
sha256: sha256Hex(archive),
|
||||
})
|
||||
const { deps, state } = makeFakeDeps({ [url]: archive })
|
||||
|
||||
await provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps)
|
||||
|
||||
expect(state.writes.get(BIN_PATH)).toEqual(FRPC_BYTES)
|
||||
})
|
||||
|
||||
it('REJECTS on sha256 mismatch and places NO binary (temp cleaned up, no extraction)', async () => {
|
||||
const archive = realisticFrpTarGz()
|
||||
const url = 'https://example.test/frp-linux-amd64.tar.gz'
|
||||
const releases = releaseOverride('linux-amd64', {
|
||||
version: '0.61.1',
|
||||
url,
|
||||
sha256: 'f'.repeat(64), // deliberately wrong
|
||||
})
|
||||
const { deps, state } = makeFakeDeps({ [url]: archive })
|
||||
|
||||
await expect(
|
||||
provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps),
|
||||
).rejects.toThrow(/sha-?256|hash|integrity|mismatch/i)
|
||||
|
||||
expect(state.renames).toEqual([])
|
||||
expect(state.writes.has(BIN_PATH)).toBe(false)
|
||||
expect(state.removed.length).toBeGreaterThan(0) // unverified temp removed
|
||||
})
|
||||
|
||||
it('never places or execs before verifying (mismatch leaves nothing executable)', async () => {
|
||||
const archive = realisticFrpTarGz()
|
||||
const url = 'https://example.test/bad.tar.gz'
|
||||
const releases = releaseOverride('linux-arm64', {
|
||||
version: '0.61.1',
|
||||
url,
|
||||
sha256: '0'.repeat(64),
|
||||
})
|
||||
const { deps, state } = makeFakeDeps({ [url]: archive })
|
||||
|
||||
await expect(
|
||||
provisionFrpc({ platform: 'linux', arch: 'arm64', binDir: BIN_DIR, releases }, deps),
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(state.chmods.every((c) => c.path !== BIN_PATH)).toBe(true)
|
||||
})
|
||||
|
||||
it('throws and places nothing when the verified archive has NO frpc entry', async () => {
|
||||
const archive = makeTarGz([
|
||||
{ name: 'frp_x/frps', content: FRPS_BYTES },
|
||||
{ name: 'frp_x/LICENSE', content: new TextEncoder().encode('MIT') },
|
||||
])
|
||||
const url = 'https://example.test/no-frpc.tar.gz'
|
||||
const releases = releaseOverride('darwin-amd64', {
|
||||
version: '0.61.1',
|
||||
url,
|
||||
sha256: sha256Hex(archive),
|
||||
})
|
||||
const { deps, state } = makeFakeDeps({ [url]: archive })
|
||||
|
||||
await expect(
|
||||
provisionFrpc({ platform: 'darwin', arch: 'x64', binDir: BIN_DIR, releases }, deps),
|
||||
).rejects.toThrow(/extract|frpc|tar/i)
|
||||
|
||||
expect(state.renames).toEqual([])
|
||||
expect(state.writes.has(BIN_PATH)).toBe(false)
|
||||
expect(state.removed.length).toBeGreaterThan(0) // temp removed on extraction failure
|
||||
})
|
||||
|
||||
it('REJECTS a traversal frpc entry and never writes outside binDir', async () => {
|
||||
const archive = makeTarGz([
|
||||
{ name: 'frp_x/../../../tmp/frpc', content: FRPC_BYTES },
|
||||
])
|
||||
const url = 'https://example.test/evil.tar.gz'
|
||||
const releases = releaseOverride('linux-amd64', {
|
||||
version: '0.61.1',
|
||||
url,
|
||||
sha256: sha256Hex(archive),
|
||||
})
|
||||
const { deps, state } = makeFakeDeps({ [url]: archive })
|
||||
|
||||
await expect(
|
||||
provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps),
|
||||
).rejects.toThrow()
|
||||
|
||||
// nothing placed, and every write that ever happened stayed inside binDir
|
||||
expect(state.writes.has(BIN_PATH)).toBe(false)
|
||||
expect(state.renames).toEqual([])
|
||||
expect([...state.writes.keys()].every((p) => p.startsWith(`${BIN_DIR}/`))).toBe(true)
|
||||
expect(state.removed.every((p) => p.startsWith(`${BIN_DIR}/`))).toBe(true)
|
||||
})
|
||||
|
||||
it('REJECTS a hash-matching but corrupt (non-gzip) archive after the gate', async () => {
|
||||
const garbage = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]) // hashes fine, not a gzip
|
||||
const url = 'https://example.test/corrupt.tar.gz'
|
||||
const releases = releaseOverride('darwin-arm64', {
|
||||
version: '0.61.1',
|
||||
url,
|
||||
sha256: sha256Hex(garbage),
|
||||
})
|
||||
const { deps, state } = makeFakeDeps({ [url]: garbage })
|
||||
|
||||
await expect(
|
||||
provisionFrpc({ platform: 'darwin', arch: 'arm64', binDir: BIN_DIR, releases }, deps),
|
||||
).rejects.toThrow(/extract|gzip|tar/i)
|
||||
|
||||
expect(state.writes.has(BIN_PATH)).toBe(false)
|
||||
expect(state.removed.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('throws a clear error on an unsupported platform (nothing fetched)', async () => {
|
||||
const { deps, fetchedUrls } = makeFakeDeps({})
|
||||
await expect(
|
||||
provisionFrpc({ platform: 'win32', arch: 'x64', binDir: BIN_DIR }, deps),
|
||||
).rejects.toThrow(/unsupported|platform/i)
|
||||
expect(fetchedUrls).toEqual([])
|
||||
})
|
||||
})
|
||||
100
agent/test/frpcToml.test.ts
Normal file
100
agent/test/frpcToml.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildNativeFrpcToml, type NativeFrpcOptions } from '../src/transport/frpcToml.js'
|
||||
|
||||
const BASE: NativeFrpcOptions = {
|
||||
subdomain: 'alice',
|
||||
localPort: 3000,
|
||||
authToken: 'super-secret-token',
|
||||
certFile: '/home/alice/.web-terminal-agent/frpc.cert.pem',
|
||||
keyFile: '/home/alice/.web-terminal-agent/frpc.key.pem',
|
||||
trustedCaFile: '/home/alice/.web-terminal-agent/frps-ctrl-ca.pem',
|
||||
}
|
||||
|
||||
describe('buildNativeFrpcToml (B2h)', () => {
|
||||
it('emits the native-tunnel server/port/tls keys (PLAN_NATIVE_TUNNEL §4)', () => {
|
||||
const toml = buildNativeFrpcToml(BASE)
|
||||
expect(toml).toContain('serverAddr = "8.138.1.192"')
|
||||
expect(toml).toContain('serverPort = 443')
|
||||
expect(toml).toContain('transport.tls.enable = true')
|
||||
expect(toml).toContain('transport.tls.serverName = "frp.terminal.yaojia.wang"')
|
||||
expect(toml).toContain('transport.tls.disableCustomTLSFirstByte = true')
|
||||
expect(toml).toContain(`transport.tls.certFile = "${BASE.certFile}"`)
|
||||
expect(toml).toContain(`transport.tls.keyFile = "${BASE.keyFile}"`)
|
||||
expect(toml).toContain(`transport.tls.trustedCaFile = "${BASE.trustedCaFile}"`)
|
||||
expect(toml).toContain('auth.method = "token"')
|
||||
expect(toml).toContain('auth.token = "super-secret-token"')
|
||||
})
|
||||
|
||||
it('emits a well-formed [[proxies]] http block bound to loopback', () => {
|
||||
const toml = buildNativeFrpcToml(BASE)
|
||||
expect(toml).toContain('[[proxies]]')
|
||||
expect(toml).toContain('type = "http"')
|
||||
expect(toml).toContain('subdomain = "alice"')
|
||||
expect(toml).toContain('localIP = "127.0.0.1"')
|
||||
expect(toml).toContain('localPort = 3000')
|
||||
// the proxy block comes after the server/tls preamble
|
||||
expect(toml.indexOf('[[proxies]]')).toBeGreaterThan(toml.indexOf('serverAddr'))
|
||||
})
|
||||
|
||||
it('does NOT emit the retired v0.8 [common]/tls_enable shape', () => {
|
||||
const toml = buildNativeFrpcToml(BASE)
|
||||
expect(toml).not.toContain('[common]')
|
||||
expect(toml).not.toContain('tls_enable')
|
||||
})
|
||||
|
||||
it('honours a configurable serverAddr (default 8.138.1.192)', () => {
|
||||
expect(buildNativeFrpcToml(BASE)).toContain('serverAddr = "8.138.1.192"')
|
||||
expect(buildNativeFrpcToml({ ...BASE, serverAddr: '10.9.8.7' })).toContain(
|
||||
'serverAddr = "10.9.8.7"',
|
||||
)
|
||||
})
|
||||
|
||||
it('THROWS when localIP is non-loopback (anti-SSRF hard invariant)', () => {
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '10.0.0.5' })).toThrow(/loopback/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '0.0.0.0' })).toThrow(/loopback/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '192.168.1.9' })).toThrow(/loopback/i)
|
||||
})
|
||||
|
||||
it('accepts the three explicit loopback localIP forms', () => {
|
||||
for (const ip of ['127.0.0.1', '::1', 'localhost']) {
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localIP: ip })).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an empty or non-label-safe subdomain', () => {
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: '' })).toThrow(/subdomain/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'has space' })).toThrow(/subdomain/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: '-bad' })).toThrow(/subdomain/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'bad-' })).toThrow(/subdomain/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'a'.repeat(64) })).toThrow(/subdomain/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'a/b' })).toThrow(/subdomain/i)
|
||||
})
|
||||
|
||||
it('rejects an out-of-range or non-integer localPort', () => {
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 0 })).toThrow(/port/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 70000 })).toThrow(/port/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 3000.5 })).toThrow(/port/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, localPort: -1 })).toThrow(/port/i)
|
||||
})
|
||||
|
||||
it('rejects missing keystore paths and an empty auth token', () => {
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, certFile: '' })).toThrow(/certFile/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, keyFile: '' })).toThrow(/keyFile/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, trustedCaFile: '' })).toThrow(/trustedCaFile/i)
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, authToken: '' })).toThrow(/token/i)
|
||||
})
|
||||
|
||||
it('escapes backslashes and quotes in Windows-style paths (valid TOML basic string)', () => {
|
||||
const winCert = 'C:\\Users\\alice\\.web-terminal-agent\\frpc.cert.pem'
|
||||
const toml = buildNativeFrpcToml({ ...BASE, certFile: winCert })
|
||||
expect(toml).toContain(
|
||||
'transport.tls.certFile = "C:\\\\Users\\\\alice\\\\.web-terminal-agent\\\\frpc.cert.pem"',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects control characters in paths/token (TOML-injection guard)', () => {
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, authToken: 'a\nb' })).toThrow()
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, certFile: 'a\nb' })).toThrow()
|
||||
expect(() => buildNativeFrpcToml({ ...BASE, keyFile: 'a"b\nq' })).toThrow()
|
||||
})
|
||||
})
|
||||
58
agent/test/heartbeat.test.ts
Normal file
58
agent/test/heartbeat.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decodeMuxFrame } from 'relay-contracts'
|
||||
import { holdTunnel } from '../src/transport/tunnel.js'
|
||||
import { createHeartbeat, HEARTBEAT_INTERVAL_MS } from '../src/transport/heartbeat.js'
|
||||
import { FakeWs, FakeTimer } from './fixtures/fakes.js'
|
||||
|
||||
describe('Heartbeat (T9, §4.1)', () => {
|
||||
it('replies PONG echoing the inbound PING token byte-exact', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const hb = createHeartbeat(tunnel, { timer: new FakeTimer() })
|
||||
const token = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])
|
||||
hb.onPing(token)
|
||||
const frame = decodeMuxFrame(ws.sent.at(-1)!)
|
||||
expect(frame.header.type).toBe('pong')
|
||||
expect(Buffer.from(frame.payload).equals(Buffer.from(token))).toBe(true)
|
||||
})
|
||||
|
||||
it('fires onDead when no PONG arrives within the interval', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const timer = new FakeTimer()
|
||||
const hb = createHeartbeat(tunnel, { timer, intervalMs: 1000 })
|
||||
let dead = false
|
||||
hb.onDead(() => {
|
||||
dead = true
|
||||
})
|
||||
hb.start() // sends first ping, arms deadline
|
||||
timer.advance(1000) // deadline fires, no pong seen
|
||||
expect(dead).toBe(true)
|
||||
})
|
||||
|
||||
it('does not fire onDead when PONG arrives in time', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const timer = new FakeTimer()
|
||||
const hb = createHeartbeat(tunnel, { timer, intervalMs: 1000, genToken: () => new Uint8Array(8) })
|
||||
let dead = false
|
||||
hb.onDead(() => {
|
||||
dead = true
|
||||
})
|
||||
hb.start()
|
||||
hb.onPong(new Uint8Array(8)) // clears pending before the deadline
|
||||
timer.advance(1000)
|
||||
expect(dead).toBe(false)
|
||||
})
|
||||
|
||||
it('exposes the frozen 15s interval', () => {
|
||||
expect(HEARTBEAT_INTERVAL_MS).toBe(15_000)
|
||||
})
|
||||
|
||||
it('stop() cancels timers (no leak)', () => {
|
||||
const ws = new FakeWs()
|
||||
const hb = createHeartbeat(holdTunnel(ws), { timer: new FakeTimer(), intervalMs: 1000 })
|
||||
hb.start()
|
||||
expect(() => hb.stop()).not.toThrow()
|
||||
})
|
||||
})
|
||||
147
agent/test/hostEndpoint.test.ts
Normal file
147
agent/test/hostEndpoint.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type {
|
||||
AeadAlg,
|
||||
ClientHello,
|
||||
E2ESession,
|
||||
HandshakeResult,
|
||||
HostHello,
|
||||
} from 'relay-contracts'
|
||||
import { generateIdentity } from '../src/keys/identity.js'
|
||||
import {
|
||||
MitmAbortError,
|
||||
createE2ETransform,
|
||||
makeHostHello,
|
||||
type CreateHostHandshake,
|
||||
type VerifyDeviceProof,
|
||||
} from '../src/e2e/hostEndpoint.js'
|
||||
import type { ReplaySealer } from '../src/e2e/replaySeal.js'
|
||||
|
||||
const ALG: AeadAlg = 'aes-256-gcm'
|
||||
|
||||
function clientHello(proof: string): ClientHello {
|
||||
return {
|
||||
clientEphPub: new Uint8Array([1, 2, 3]),
|
||||
clientNonce: new Uint8Array([4, 5, 6]),
|
||||
aeadOffer: [ALG],
|
||||
deviceAuthProof: proof,
|
||||
}
|
||||
}
|
||||
|
||||
function fakeHandshake(keysByte: number): CreateHostHandshake {
|
||||
return () => ({
|
||||
async respond(): Promise<{ hello: HostHello; result: HandshakeResult }> {
|
||||
const result: HandshakeResult = {
|
||||
keys: {
|
||||
c2h: new Uint8Array([keysByte]) as never,
|
||||
h2c: new Uint8Array([keysByte + 1]) as never,
|
||||
},
|
||||
aead: ALG,
|
||||
transcript: new Uint8Array([0xff]),
|
||||
}
|
||||
const hello: HostHello = {
|
||||
hostEphPub: new Uint8Array([7]),
|
||||
hostNonce: new Uint8Array([8]),
|
||||
aeadChoice: ALG,
|
||||
enrollFpr: 'fpr',
|
||||
sig: new Uint8Array([9]),
|
||||
}
|
||||
return { hello, result }
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// The load-bearing MITM verifier: only a specific proof passes.
|
||||
const realVerifier: VerifyDeviceProof = async (proof) => proof === 'VALID-PROOF'
|
||||
|
||||
describe('makeHostHello (T15, anti-MITM)', () => {
|
||||
it('derives DirectionalKeys{c2h,h2c} on a valid proof (FIX 2 — no single sessionKey)', async () => {
|
||||
const { result } = await makeHostHello(clientHello('VALID-PROOF'), generateIdentity(), realVerifier, {
|
||||
createHostHandshake: fakeHandshake(0x10),
|
||||
})
|
||||
expect(result.keys).toHaveProperty('c2h')
|
||||
expect(result.keys).toHaveProperty('h2c')
|
||||
expect(result).not.toHaveProperty('sessionKey')
|
||||
})
|
||||
|
||||
it('ABORTS on a forged proof: no keys, no HostHello (MitmAbortError)', async () => {
|
||||
const respond = vi.fn()
|
||||
const spyHandshake: CreateHostHandshake = () => ({ respond: respond as never })
|
||||
await expect(
|
||||
makeHostHello(clientHello('FORGED'), generateIdentity(), realVerifier, {
|
||||
createHostHandshake: spyHandshake,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(MitmAbortError)
|
||||
expect(respond).not.toHaveBeenCalled() // no key derivation reached
|
||||
})
|
||||
|
||||
it('no-stub guard: swapping the verifier for always-true makes the MITM test FAIL', async () => {
|
||||
const stub: VerifyDeviceProof = async () => true
|
||||
// With the stubbed verifier, the forged proof WRONGLY completes — proving the verifier is
|
||||
// load-bearing (a real build forbids this stub via the import guard below).
|
||||
const out = await makeHostHello(clientHello('FORGED'), generateIdentity(), stub, {
|
||||
createHostHandshake: fakeHandshake(0x20),
|
||||
})
|
||||
expect(out.result.keys).toBeDefined()
|
||||
})
|
||||
|
||||
it('no-stub CI guard: hostEndpoint.ts imports NO verifier from relay-e2e (FIX 6b)', () => {
|
||||
const raw = readFileSync(join(import.meta.dirname, '..', 'src', 'e2e', 'hostEndpoint.ts'), 'utf8')
|
||||
const src = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '') // strip comments
|
||||
expect(src).not.toMatch(/import[^\n]*verifyDeviceAuthProof/)
|
||||
expect(src).not.toMatch(/from ['"]relay-e2e['"]/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createE2ETransform (T15)', () => {
|
||||
function fakeSession(): E2ESession {
|
||||
// Reversible transform that HIDES the plaintext (XOR) so INV2 assertions are meaningful.
|
||||
return {
|
||||
role: 'host',
|
||||
seal: (pt) => Uint8Array.from([0xe0, ...pt.map((b) => b ^ 0x55)]),
|
||||
open: (ct) => Uint8Array.from(ct.slice(1)).map((b) => b ^ 0x55),
|
||||
rederive: () => {},
|
||||
}
|
||||
}
|
||||
function fakeReplay(): ReplaySealer & { calls: number } {
|
||||
const r = {
|
||||
calls: 0,
|
||||
epoch: 'test-epoch',
|
||||
seal(_pt: Uint8Array) {
|
||||
r.calls += 1
|
||||
return { seq: 0n, nonce: new Uint8Array(), ciphertext: new Uint8Array([0xde]), tag: new Uint8Array() }
|
||||
},
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
it('outbound seals under BOTH the live session and the replay key (FIX 3, INV2)', () => {
|
||||
const replay = fakeReplay()
|
||||
const t = createE2ETransform(generateIdentity(), realVerifier, replay)
|
||||
t.openStream(1)
|
||||
t.seedSession(1, fakeSession(), new Uint8Array([0x48])) // HostHello control frame
|
||||
const marker = new TextEncoder().encode('PLAIN')
|
||||
const sealed = t.outbound(1, marker)
|
||||
expect(replay.calls).toBe(1) // replay-bound seal happened
|
||||
expect(Buffer.from(sealed).includes(Buffer.from(marker))).toBe(false) // ciphertext, not plaintext
|
||||
expect(t.takeControlFrames!(1)).toEqual([new Uint8Array([0x48])]) // HostHello flushed once
|
||||
expect(t.takeControlFrames!(1)).toEqual([])
|
||||
})
|
||||
|
||||
it('inbound before seeding returns null (handshake pending); after, opens opaque', () => {
|
||||
const t = createE2ETransform(generateIdentity(), realVerifier, fakeReplay())
|
||||
const session = fakeSession()
|
||||
t.openStream(1)
|
||||
expect(t.inbound(1, new Uint8Array([1, 2]))).toBeNull()
|
||||
t.seedSession(1, session, new Uint8Array([0x48]))
|
||||
const ct = session.seal(new Uint8Array([9, 9])) // c2h ciphertext
|
||||
expect([...t.inbound(1, ct)!]).toEqual([9, 9])
|
||||
})
|
||||
|
||||
it('outbound before the session is established aborts (no silent plaintext leak)', () => {
|
||||
const t = createE2ETransform(generateIdentity(), realVerifier, fakeReplay())
|
||||
t.openStream(1)
|
||||
expect(() => t.outbound(1, new Uint8Array([1]))).toThrow(MitmAbortError)
|
||||
})
|
||||
})
|
||||
126
agent/test/hostRecord.test.ts
Normal file
126
agent/test/hostRecord.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import {
|
||||
loadHostRecord,
|
||||
resolveHostIdentity,
|
||||
saveHostRecord,
|
||||
subdomainFromCertPem,
|
||||
} from '../src/config/hostRecord.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://enroll.terminal.example.com/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: null,
|
||||
hostId: null,
|
||||
}
|
||||
|
||||
function tmpState(): string {
|
||||
return mkdtempSync(join(tmpdir(), 'wta-hr-'))
|
||||
}
|
||||
|
||||
/** A real frp-client leaf as issued by the control-plane (SPIFFE URI SAN carries the subdomain). */
|
||||
const LEAF_PEM = `-----BEGIN CERTIFICATE-----
|
||||
MIIB1TCCAXygAwIBAgIUUj+CZ+6p29yI59VpyrekwSp9tAgwCgYIKoZIzj0EAwIw
|
||||
EDEOMAwGA1UEAwwFaDdmZDgwHhcNMjYwNzI5MDgzNzIyWhcNMzYwNzI2MDgzNzIy
|
||||
WjAQMQ4wDAYDVQQDDAVoN2ZkODBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABACg
|
||||
xWQCQuxawnkkPZIgagEFtG0oBiuron4SSw3U1Q0FwCSH3BJep1MJtIuEQU3HfM4N
|
||||
6Tk5kW4MWuIM8sNriiqjgbMwgbAwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMC
|
||||
B4AwEwYDVR0lBAwwCgYIKwYBBQUHAwIwXAYDVR0RBFUwU4IaaDdmZDgudGVybWlu
|
||||
YWwuZXhhbXBsZS5jb22GNXNwaWZmZTovL3JlbGF5LmV4YW1wbGUuY29tL2FjY291
|
||||
bnQvYWNjLTEyMy9ob3N0L2g3ZmQ4MB0GA1UdDgQWBBSy/SJwjH/lm8TaY5Yk/TF+
|
||||
wpg78TAKBggqhkjOPQQDAgNHADBEAiAjq1o5xpk+iF55uVfdyLP/a9OC09O0mN4P
|
||||
YRk8x5MFaQIgYC3GTWqkwu0azrdffKl6jX0stbG+oM+0Cx2Cn7wy27c=
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
describe('host record persistence', () => {
|
||||
it('round-trips the enrolment identifiers through stateDir', () => {
|
||||
const dir = tmpState()
|
||||
saveHostRecord(dir, { hostId: 'h-1', subdomain: 'h7fd8' })
|
||||
expect(loadHostRecord(dir)).toEqual({ hostId: 'h-1', subdomain: 'h7fd8' })
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns null when nothing was ever enrolled here', () => {
|
||||
const dir = tmpState()
|
||||
expect(loadHostRecord(dir)).toBeNull()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('treats a corrupt record as absent rather than throwing (never crashes the run loop)', () => {
|
||||
const dir = tmpState()
|
||||
writeFileSync(join(dir, 'host.json'), '{not json')
|
||||
expect(loadHostRecord(dir)).toBeNull()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('ignores a record whose fields are the wrong shape', () => {
|
||||
const dir = tmpState()
|
||||
writeFileSync(join(dir, 'host.json'), JSON.stringify({ hostId: 42, subdomain: [] }))
|
||||
expect(loadHostRecord(dir)).toEqual({ hostId: null, subdomain: null })
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('subdomainFromCertPem', () => {
|
||||
it('reads the subdomain out of the leaf SPIFFE URI SAN', () => {
|
||||
expect(subdomainFromCertPem(LEAF_PEM)).toBe('h7fd8')
|
||||
})
|
||||
|
||||
it('returns null for a certificate with no SPIFFE SAN', () => {
|
||||
expect(subdomainFromCertPem('-----BEGIN CERTIFICATE-----\nnope\n-----END CERTIFICATE-----')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for garbage instead of throwing', () => {
|
||||
expect(subdomainFromCertPem('not a cert')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveHostIdentity precedence', () => {
|
||||
it('keeps explicit config (env/argv) over everything else', () => {
|
||||
const dir = tmpState()
|
||||
saveHostRecord(dir, { hostId: 'from-file', subdomain: 'from-file' })
|
||||
const out = resolveHostIdentity(
|
||||
{ ...CFG, stateDir: dir, subdomain: 'from-env', hostId: 'from-env' },
|
||||
() => LEAF_PEM,
|
||||
)
|
||||
expect(out).toEqual({ hostId: 'from-env', subdomain: 'from-env' })
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('falls back to the persisted enrolment record', () => {
|
||||
const dir = tmpState()
|
||||
saveHostRecord(dir, { hostId: 'h-1', subdomain: 'h7fd8' })
|
||||
expect(resolveHostIdentity({ ...CFG, stateDir: dir }, () => null)).toEqual({
|
||||
hostId: 'h-1',
|
||||
subdomain: 'h7fd8',
|
||||
})
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/**
|
||||
* Hosts enrolled before the record existed have no `host.json`. Their leaf still carries the
|
||||
* subdomain, so they get an identifier in the logs without needing a re-pair.
|
||||
*/
|
||||
it('falls back to the leaf SPIFFE SAN when there is no record (legacy installs)', () => {
|
||||
const dir = tmpState()
|
||||
expect(resolveHostIdentity({ ...CFG, stateDir: dir }, () => LEAF_PEM)).toEqual({
|
||||
hostId: null,
|
||||
subdomain: 'h7fd8',
|
||||
})
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('yields nulls when nothing is known (unenrolled host)', () => {
|
||||
const dir = tmpState()
|
||||
expect(resolveHostIdentity({ ...CFG, stateDir: dir }, () => null)).toEqual({
|
||||
hostId: null,
|
||||
subdomain: null,
|
||||
})
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
105
agent/test/identity.test.ts
Normal file
105
agent/test/identity.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { createPublicKey, verify } from 'node:crypto'
|
||||
import {
|
||||
computeEnrollFpr,
|
||||
generateIdentity,
|
||||
generateP256Identity,
|
||||
identityFromPrivatePem,
|
||||
p256IdentityFromPrivatePem,
|
||||
verifySignature,
|
||||
} from '../src/keys/identity.js'
|
||||
|
||||
describe('AgentIdentity (INV4)', () => {
|
||||
it('generates distinct keypairs', () => {
|
||||
const a = generateIdentity()
|
||||
const b = generateIdentity()
|
||||
expect(Buffer.from(a.publicKey).equals(Buffer.from(b.publicKey))).toBe(false)
|
||||
expect(a.publicKey.length).toBe(32)
|
||||
})
|
||||
|
||||
it('sign/verify round-trips', () => {
|
||||
const id = generateIdentity()
|
||||
const msg = new TextEncoder().encode('transcript-bytes')
|
||||
const sig = id.sign(msg)
|
||||
expect(verifySignature(id.publicKey, msg, sig)).toBe(true)
|
||||
expect(verifySignature(id.publicKey, new TextEncoder().encode('tampered'), sig)).toBe(false)
|
||||
})
|
||||
|
||||
it('enrollFpr is deterministic base64url(SHA-256(pubkey))', () => {
|
||||
const id = generateIdentity()
|
||||
expect(computeEnrollFpr(id.publicKey)).toBe(id.enrollFpr)
|
||||
expect(id.enrollFpr).not.toMatch(/[+/=]/) // base64url alphabet only
|
||||
})
|
||||
|
||||
it('reloads the same identity from PEM', () => {
|
||||
const id = generateIdentity()
|
||||
const pem = id.exportPrivatePkcs8Pem()
|
||||
const reloaded = identityFromPrivatePem(pem)
|
||||
expect(Buffer.from(reloaded.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||
expect(reloaded.enrollFpr).toBe(id.enrollFpr)
|
||||
})
|
||||
|
||||
it('security: no API returns raw private-key bytes', () => {
|
||||
const id = generateIdentity()
|
||||
// The only private-key surface is a PEM export for the 0600 keystore and an in-process
|
||||
// KeyObject; there is NO Uint8Array/raw-bytes getter for the private key.
|
||||
expect('privateKey' in id).toBe(false)
|
||||
const src = readFileSync(
|
||||
join(import.meta.dirname, '..', 'src', 'keys', 'identity.ts'),
|
||||
'utf8',
|
||||
)
|
||||
// No function exports raw private key bytes onto the network surface.
|
||||
expect(src).not.toMatch(/exportPrivateRaw|privateKeyBytes|toRawPrivate/)
|
||||
})
|
||||
|
||||
it('keeps the Ed25519 alg tag (no P-256 regression)', () => {
|
||||
expect(generateIdentity().alg).toBe('ed25519')
|
||||
})
|
||||
})
|
||||
|
||||
describe('P-256 AgentIdentity (FIX H-host-2 — native frp-client key)', () => {
|
||||
it('generates distinct EC P-256 keypairs tagged alg=p256', () => {
|
||||
const a = generateP256Identity()
|
||||
const b = generateP256Identity()
|
||||
expect(a.alg).toBe('p256')
|
||||
expect(Buffer.from(a.publicKey).equals(Buffer.from(b.publicKey))).toBe(false)
|
||||
// publicKey is the EC SubjectPublicKeyInfo DER (outer SEQUENCE), importable as a public key.
|
||||
expect(a.publicKey[0]).toBe(0x30)
|
||||
const pub = createPublicKey({ key: Buffer.from(a.publicKey), format: 'der', type: 'spki' })
|
||||
expect(pub.asymmetricKeyType).toBe('ec')
|
||||
expect(pub.asymmetricKeyDetails?.namedCurve).toBe('prime256v1')
|
||||
})
|
||||
|
||||
it('sign produces a DER ECDSA signature that verifies under SHA-256', () => {
|
||||
const id = generateP256Identity()
|
||||
const msg = new TextEncoder().encode('certificationRequestInfo-bytes')
|
||||
const sig = id.sign(msg)
|
||||
const pub = createPublicKey({ key: Buffer.from(id.publicKey), format: 'der', type: 'spki' })
|
||||
expect(verify('sha256', msg, pub, sig)).toBe(true)
|
||||
expect(verify('sha256', new TextEncoder().encode('tampered'), pub, sig)).toBe(false)
|
||||
})
|
||||
|
||||
it('enrollFpr is deterministic base64url(SHA-256(spki))', () => {
|
||||
const id = generateP256Identity()
|
||||
expect(computeEnrollFpr(id.publicKey)).toBe(id.enrollFpr)
|
||||
expect(id.enrollFpr).not.toMatch(/[+/=]/) // base64url alphabet only
|
||||
})
|
||||
|
||||
it('reloads the same P-256 identity from PEM (keystore load path)', () => {
|
||||
const id = generateP256Identity()
|
||||
const pem = id.exportPrivatePkcs8Pem()
|
||||
const reloaded = p256IdentityFromPrivatePem(pem)
|
||||
expect(reloaded.alg).toBe('p256')
|
||||
expect(Buffer.from(reloaded.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||
expect(reloaded.enrollFpr).toBe(id.enrollFpr)
|
||||
})
|
||||
|
||||
it('security: P-256 identity exposes no raw private-key getter', () => {
|
||||
const id = generateP256Identity()
|
||||
expect('privateKey' in id).toBe(false)
|
||||
// the PEM export is the only serialization surface; it is a PRIVATE key PEM (0600 keystore only).
|
||||
expect(id.exportPrivatePkcs8Pem()).toContain('PRIVATE KEY')
|
||||
})
|
||||
})
|
||||
422
agent/test/install.test.ts
Normal file
422
agent/test/install.test.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import {
|
||||
BindHostError,
|
||||
RootRefusedError,
|
||||
assertNativeZone,
|
||||
buildInstallOptions,
|
||||
detectPlatform,
|
||||
installService,
|
||||
normalizeBindHost,
|
||||
uninstallService,
|
||||
type InstallDeps,
|
||||
} from '../src/service/install.js'
|
||||
import { agentLabel, baseAppLabel, buildLaunchdPlist } from '../src/service/launchd.js'
|
||||
import { agentUnitName, baseAppUnitName, buildSystemdUnit } from '../src/service/systemd.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://x/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
|
||||
type FakeDeps = InstallDeps & {
|
||||
writes: Array<[string, string]>
|
||||
runs: Array<[string, readonly string[]]>
|
||||
}
|
||||
|
||||
function deps(uid = 501): FakeDeps {
|
||||
const writes: Array<[string, string]> = []
|
||||
const runs: Array<[string, readonly string[]]> = []
|
||||
return {
|
||||
writes,
|
||||
runs,
|
||||
writeFile: (p, c) => writes.push([p, c]),
|
||||
runCommand: async (cmd, args) => {
|
||||
runs.push([cmd, args])
|
||||
},
|
||||
getuid: () => uid,
|
||||
homedir: () => '/home/alice',
|
||||
username: () => 'alice',
|
||||
binPath: () => '/usr/local/bin/web-terminal-agent',
|
||||
}
|
||||
}
|
||||
|
||||
/** The unit content whose path contains `needle` (e.g. `'base-app'`, `'agent'`). */
|
||||
function unitWith(d: FakeDeps, needle: string): string {
|
||||
const hit = d.writes.find(([path]) => path.includes(needle))
|
||||
if (!hit) throw new Error(`no unit written whose path contains '${needle}'`)
|
||||
return hit[1]
|
||||
}
|
||||
|
||||
describe('detectPlatform (T17)', () => {
|
||||
it('maps darwin→launchd, linux→systemd, else null', () => {
|
||||
expect(detectPlatform('darwin')).toBe('launchd')
|
||||
expect(detectPlatform('linux')).toBe('systemd')
|
||||
expect(detectPlatform('win32')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('installService — least privilege (T17)', () => {
|
||||
it('refuses to install as root (negative, least privilege)', async () => {
|
||||
await expect(installService(CFG, 'systemd', deps(0))).rejects.toBeInstanceOf(RootRefusedError)
|
||||
})
|
||||
|
||||
it('root refusal emits nothing', async () => {
|
||||
const d = deps(0)
|
||||
await expect(installService(CFG, 'systemd', d)).rejects.toBeInstanceOf(RootRefusedError)
|
||||
expect(d.writes).toHaveLength(0)
|
||||
expect(d.runs).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('installService — two distinct units (FIX M-host-2service)', () => {
|
||||
it('systemd: writes a base-app unit AND an agent unit, both run-as-user, both enabled', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d)
|
||||
// exactly two units
|
||||
expect(d.writes).toHaveLength(2)
|
||||
const baseApp = unitWith(d, baseAppUnitName())
|
||||
const agent = unitWith(d, agentUnitName())
|
||||
// agent unit supervises frpc via `<node> <bin> run` (absolute node so a minimal service PATH
|
||||
// that lacks /usr/local/bin can't fail with EX_CONFIG); base-app runs the node server (loopback)
|
||||
expect(agent).toContain('/usr/local/bin/web-terminal-agent run')
|
||||
expect(agent).toMatch(/ExecStart=\S*node\S* \/usr\/local\/bin\/web-terminal-agent run/)
|
||||
expect(baseApp).toContain('ExecStart=')
|
||||
expect(baseApp).toContain('server.js')
|
||||
expect(baseApp).not.toContain('web-terminal-agent run')
|
||||
// both least-privilege + restart-on-failure
|
||||
for (const unit of [baseApp, agent]) {
|
||||
expect(unit).toContain('User=alice')
|
||||
expect(unit).not.toContain('User=root')
|
||||
expect(unit).toContain('Restart=on-failure')
|
||||
}
|
||||
// both enabled
|
||||
expect(d.runs.every(([cmd]) => cmd === 'systemctl')).toBe(true)
|
||||
expect(d.runs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('launchd: writes a base-app plist AND an agent plist, both with KeepAlive, both loaded', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'launchd', d)
|
||||
expect(d.writes).toHaveLength(2)
|
||||
const baseApp = unitWith(d, baseAppLabel())
|
||||
const agent = unitWith(d, agentLabel())
|
||||
expect(agent).toContain('<string>run</string>')
|
||||
expect(baseApp).toContain('server.js')
|
||||
expect(baseApp).not.toContain('<string>run</string>')
|
||||
for (const plist of [baseApp, agent]) {
|
||||
expect(plist).toContain('<key>KeepAlive</key>')
|
||||
}
|
||||
expect(d.writes.every(([path]) => path.includes('LaunchAgents'))).toBe(true)
|
||||
expect(d.runs.every(([cmd]) => cmd === 'launchctl')).toBe(true)
|
||||
expect(d.runs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('routes base-app env to the base-app unit ONLY (never onto the agent unit)', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d, { env: { BIND_HOST: '127.0.0.1', PORT: '3000' } })
|
||||
const baseApp = unitWith(d, baseAppUnitName())
|
||||
const agent = unitWith(d, agentUnitName())
|
||||
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
|
||||
expect(baseApp).toContain('Environment="PORT=3000"')
|
||||
// the agent unit must NOT carry the base-app env
|
||||
expect(agent).not.toContain('BIND_HOST')
|
||||
expect(agent).not.toContain('PORT=3000')
|
||||
})
|
||||
|
||||
it('uninstall tears down BOTH units (launchd unload)', async () => {
|
||||
const d = deps()
|
||||
await uninstallService('launchd', d)
|
||||
const targets = d.runs.map(([, args]) => args[args.length - 1])
|
||||
expect(d.runs.every(([cmd]) => cmd === 'launchctl')).toBe(true)
|
||||
expect(targets.some((t) => t?.includes(baseAppLabel()))).toBe(true)
|
||||
expect(targets.some((t) => t?.includes(agentLabel()))).toBe(true)
|
||||
})
|
||||
|
||||
it('uninstall tears down BOTH units (systemd disable)', async () => {
|
||||
const d = deps()
|
||||
await uninstallService('systemd', d)
|
||||
const units = d.runs.map(([, args]) => args[args.length - 1])
|
||||
expect(d.runs.every(([cmd]) => cmd === 'systemctl')).toBe(true)
|
||||
expect(units).toContain(baseAppUnitName())
|
||||
expect(units).toContain(agentUnitName())
|
||||
})
|
||||
})
|
||||
|
||||
describe('BIND_HOST loopback S-GATE (FIX C-host-1, CRITICAL)', () => {
|
||||
it('normalizeBindHost defaults an absent value to loopback', () => {
|
||||
expect(normalizeBindHost(undefined)).toBe('127.0.0.1')
|
||||
expect(normalizeBindHost('')).toBe('127.0.0.1')
|
||||
})
|
||||
|
||||
it('normalizeBindHost accepts loopback forms (127.0.0.0/8, ::1, localhost)', () => {
|
||||
expect(normalizeBindHost('127.0.0.1')).toBe('127.0.0.1')
|
||||
expect(normalizeBindHost('127.0.0.2')).toBe('127.0.0.2')
|
||||
expect(normalizeBindHost('::1')).toBe('::1')
|
||||
expect(normalizeBindHost('localhost')).toBe('localhost')
|
||||
})
|
||||
|
||||
it('normalizeBindHost REJECTS 0.0.0.0 and other non-loopback values', () => {
|
||||
expect(() => normalizeBindHost('0.0.0.0')).toThrow(BindHostError)
|
||||
expect(() => normalizeBindHost('192.168.1.10')).toThrow(BindHostError)
|
||||
expect(() => normalizeBindHost('::')).toThrow(BindHostError)
|
||||
})
|
||||
|
||||
it('REGRESSION: rejects a suffixed hostname that merely starts with 127. (S-GATE bypass)', () => {
|
||||
// These are hostnames, not loopback literals — Node would DNS-resolve them before bind().
|
||||
expect(() => normalizeBindHost('127.0.0.1.attacker.example.com')).toThrow(BindHostError)
|
||||
expect(() => normalizeBindHost('127.evil.net')).toThrow(BindHostError)
|
||||
expect(() => normalizeBindHost('127.0.0.1x')).toThrow(BindHostError)
|
||||
expect(() => buildInstallOptions({ BIND_HOST: '127.0.0.1.attacker.example.com' })).toThrow(
|
||||
BindHostError,
|
||||
)
|
||||
})
|
||||
|
||||
it('buildInstallOptions throws on BIND_HOST=0.0.0.0 (fail-closed at env read)', () => {
|
||||
expect(() => buildInstallOptions({ BIND_HOST: '0.0.0.0' })).toThrow(BindHostError)
|
||||
})
|
||||
|
||||
it('NEGATIVE: installService with BIND_HOST=0.0.0.0 throws AND emits nothing', async () => {
|
||||
const d = deps()
|
||||
await expect(
|
||||
installService(CFG, 'systemd', d, { env: { BIND_HOST: '0.0.0.0', PORT: '3000' } }),
|
||||
).rejects.toBeInstanceOf(BindHostError)
|
||||
expect(d.writes).toHaveLength(0)
|
||||
expect(d.runs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('NEGATIVE (launchd): a 0.0.0.0 install emits no plist', async () => {
|
||||
const d = deps()
|
||||
await expect(
|
||||
installService(CFG, 'launchd', d, { env: { BIND_HOST: '0.0.0.0' } }),
|
||||
).rejects.toBeInstanceOf(BindHostError)
|
||||
expect(d.writes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('the emitted base-app unit can NEVER contain BIND_HOST=0.0.0.0 (normalized when absent)', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d, { env: { PORT: '3000' } })
|
||||
const baseApp = unitWith(d, baseAppUnitName())
|
||||
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
|
||||
expect(baseApp).not.toContain('0.0.0.0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildInstallOptions — env → InstallOptions (S0/S2 + S-GATE)', () => {
|
||||
it('defaults BIND_HOST to loopback so a tunnel install is never LAN-exposed (S0/R2)', () => {
|
||||
const options = buildInstallOptions({})
|
||||
expect(options.env).toEqual({ BIND_HOST: '127.0.0.1' })
|
||||
})
|
||||
|
||||
it('honours an explicit loopback BIND_HOST and passes through the S0 base-app env vars', () => {
|
||||
const options = buildInstallOptions({
|
||||
BIND_HOST: '127.0.0.2',
|
||||
PORT: '3000',
|
||||
SHELL_PATH: '/bin/zsh',
|
||||
IDLE_TTL: '86400',
|
||||
USE_TMUX: '1',
|
||||
ALLOWED_ORIGINS: 'https://keep.me',
|
||||
SCROLLBACK_BYTES: '2097152',
|
||||
MAX_PAYLOAD_BYTES: '1048576',
|
||||
})
|
||||
expect(options.env).toEqual({
|
||||
BIND_HOST: '127.0.0.2',
|
||||
PORT: '3000',
|
||||
SHELL_PATH: '/bin/zsh',
|
||||
IDLE_TTL: '86400',
|
||||
USE_TMUX: '1',
|
||||
ALLOWED_ORIGINS: 'https://keep.me',
|
||||
SCROLLBACK_BYTES: '2097152',
|
||||
MAX_PAYLOAD_BYTES: '1048576',
|
||||
})
|
||||
})
|
||||
|
||||
it('AG3: passes SCROLLBACK_BYTES and MAX_PAYLOAD_BYTES through as base-app config', () => {
|
||||
const options = buildInstallOptions({ SCROLLBACK_BYTES: '2097152', MAX_PAYLOAD_BYTES: '1048576' })
|
||||
expect(options.env).toEqual({
|
||||
BIND_HOST: '127.0.0.1',
|
||||
SCROLLBACK_BYTES: '2097152',
|
||||
MAX_PAYLOAD_BYTES: '1048576',
|
||||
})
|
||||
})
|
||||
|
||||
it('omits unset/empty passthrough vars', () => {
|
||||
const options = buildInstallOptions({ PORT: '', SHELL_PATH: '/bin/bash' })
|
||||
expect(options.env).toEqual({ BIND_HOST: '127.0.0.1', SHELL_PATH: '/bin/bash' })
|
||||
})
|
||||
|
||||
it('derives domain + default `terminal` zone from TUNNEL_DOMAIN', () => {
|
||||
const options = buildInstallOptions({ TUNNEL_DOMAIN: 'yaojia.wang' })
|
||||
expect(options.domain).toBe('yaojia.wang')
|
||||
expect(options.zone).toBe('terminal')
|
||||
})
|
||||
|
||||
it('lets TUNNEL_ZONE override the origin zone and carries AGENT_ENV_FILE through', () => {
|
||||
const options = buildInstallOptions({
|
||||
TUNNEL_DOMAIN: 'yaojia.wang',
|
||||
TUNNEL_ZONE: 'term',
|
||||
AGENT_ENV_FILE: '/etc/wt.env',
|
||||
})
|
||||
expect(options.zone).toBe('term')
|
||||
expect(options.envFile).toBe('/etc/wt.env')
|
||||
})
|
||||
|
||||
it('omits domain/zone when no TUNNEL_DOMAIN is set', () => {
|
||||
const options = buildInstallOptions({})
|
||||
expect(options.domain).toBeUndefined()
|
||||
expect(options.zone).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tunnel-origin derivation into the base-app unit (FIX L-host-zone)', () => {
|
||||
it('assertNativeZone accepts `terminal` and rejects `term`/undefined', () => {
|
||||
expect(() => assertNativeZone('terminal')).not.toThrow()
|
||||
expect(() => assertNativeZone('term')).toThrow(/terminal/)
|
||||
expect(() => assertNativeZone(undefined)).toThrow(/terminal/)
|
||||
})
|
||||
|
||||
it('merges https://<sub>.terminal.<domain> into the base-app ALLOWED_ORIGINS', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'launchd', d, { domain: 'yaojia.wang', zone: 'terminal' })
|
||||
const baseApp = unitWith(d, baseAppLabel())
|
||||
expect(baseApp).toContain('<key>ALLOWED_ORIGINS</key>')
|
||||
expect(baseApp).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
|
||||
})
|
||||
|
||||
it('preserves a caller-provided ALLOWED_ORIGINS and appends the tunnel origin', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d, {
|
||||
env: { ALLOWED_ORIGINS: 'https://keep.me' },
|
||||
domain: 'yaojia.wang',
|
||||
zone: 'terminal',
|
||||
})
|
||||
const baseApp = unitWith(d, baseAppUnitName())
|
||||
expect(baseApp).toContain('https://keep.me,https://host-42.terminal.yaojia.wang')
|
||||
})
|
||||
|
||||
it('does not derive an origin when the config has no subdomain', async () => {
|
||||
const d = deps()
|
||||
await installService({ ...CFG, subdomain: null }, 'launchd', d, {
|
||||
domain: 'yaojia.wang',
|
||||
zone: 'terminal',
|
||||
})
|
||||
const baseApp = unitWith(d, baseAppLabel())
|
||||
expect(baseApp).not.toContain('ALLOWED_ORIGINS')
|
||||
})
|
||||
})
|
||||
|
||||
describe('install CLI seam end-to-end — resolved env reaches the base-app unit (S2)', () => {
|
||||
const ENV = { PORT: '3000', SHELL_PATH: '/bin/zsh', TUNNEL_DOMAIN: 'yaojia.wang' } as const
|
||||
|
||||
it('launchd: the base-app plist carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'launchd', d, buildInstallOptions(ENV))
|
||||
const baseApp = unitWith(d, baseAppLabel())
|
||||
expect(baseApp).toContain('<key>BIND_HOST</key>')
|
||||
expect(baseApp).toContain('<string>127.0.0.1</string>')
|
||||
expect(baseApp).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
|
||||
expect(baseApp).toContain('<key>PORT</key>')
|
||||
expect(baseApp).not.toContain('0.0.0.0')
|
||||
})
|
||||
|
||||
it('systemd: the base-app unit carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d, buildInstallOptions(ENV))
|
||||
const baseApp = unitWith(d, baseAppUnitName())
|
||||
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
|
||||
expect(baseApp).toContain('Environment="ALLOWED_ORIGINS=https://host-42.terminal.yaojia.wang"')
|
||||
expect(baseApp).toContain('Environment="PORT=3000"')
|
||||
expect(baseApp).not.toContain('0.0.0.0')
|
||||
})
|
||||
|
||||
it('systemd: emits EnvironmentFile= (before inline Environment) on the base-app unit', async () => {
|
||||
const d = deps()
|
||||
await installService(CFG, 'systemd', d, {
|
||||
env: { BIND_HOST: '127.0.0.1', PORT: '3000' },
|
||||
envFile: '/etc/web-terminal.env',
|
||||
})
|
||||
const baseApp = unitWith(d, baseAppUnitName())
|
||||
expect(baseApp).toContain('EnvironmentFile=/etc/web-terminal.env')
|
||||
expect(baseApp.indexOf('EnvironmentFile=')).toBeLessThan(baseApp.indexOf('Environment='))
|
||||
})
|
||||
})
|
||||
|
||||
describe('unit writers — escaping & control-char hardening', () => {
|
||||
it('launchd: escapes XML-significant characters in env values', () => {
|
||||
const plist = buildLaunchdPlist(['/bin/agent', 'run'], { X: `a&b<c>d"e'f` })
|
||||
expect(plist).toContain('<string>a&b<c>d"e'f</string>')
|
||||
expect(plist).not.toContain('a&b<c>d')
|
||||
})
|
||||
|
||||
it('launchd: injects a sorted, XML-escaped EnvironmentVariables dict', () => {
|
||||
const plist = buildLaunchdPlist(['/bin/agent', 'run'], {
|
||||
BIND_HOST: '127.0.0.1',
|
||||
ALLOWED_ORIGINS: 'https://a',
|
||||
PORT: '3000',
|
||||
})
|
||||
expect(plist).toContain('<key>EnvironmentVariables</key>')
|
||||
expect(plist.indexOf('ALLOWED_ORIGINS')).toBeLessThan(plist.indexOf('BIND_HOST'))
|
||||
expect(plist.indexOf('BIND_HOST')).toBeLessThan(plist.indexOf('>PORT<'))
|
||||
})
|
||||
|
||||
it('launchd: no env → no EnvironmentVariables block', () => {
|
||||
const plist = buildLaunchdPlist(['/bin/agent', 'run'])
|
||||
expect(plist).not.toContain('EnvironmentVariables')
|
||||
})
|
||||
|
||||
it('systemd: escapes backslash and double-quote in Environment values', () => {
|
||||
const unit = buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a"b\\c' } })
|
||||
expect(unit).toContain('Environment="X=a\\"b\\\\c"')
|
||||
})
|
||||
|
||||
it('systemd: rejects a newline in an env value (no [Service] directive injection)', () => {
|
||||
expect(() =>
|
||||
buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a\nExecStartPre=/x' } }),
|
||||
).toThrow(/control character/)
|
||||
})
|
||||
|
||||
it('systemd: rejects a carriage return in an env value', () => {
|
||||
expect(() => buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a\rb' } })).toThrow(
|
||||
/control character/,
|
||||
)
|
||||
})
|
||||
|
||||
it('AG2: rejects a newline in the ExecStart command (no [Service] directive injection)', () => {
|
||||
expect(() =>
|
||||
buildSystemdUnit('/bin/agent run\nExecStartPre=/x', 'alice'),
|
||||
).toThrow(/control character/)
|
||||
})
|
||||
|
||||
it('AG2: rejects a newline in the User field', () => {
|
||||
expect(() => buildSystemdUnit('/bin/agent run', 'alice\nExecStartPre=/x')).toThrow(
|
||||
/control character/,
|
||||
)
|
||||
})
|
||||
|
||||
it('AG2: rejects a newline in the Description field', () => {
|
||||
expect(() =>
|
||||
buildSystemdUnit('/bin/agent run', 'alice', {}, 'desc\n[Service]\nExecStartPre=/x'),
|
||||
).toThrow(/control character/)
|
||||
})
|
||||
|
||||
it('AG2: rejects a newline in the EnvironmentFile path', () => {
|
||||
expect(() =>
|
||||
buildSystemdUnit('/bin/agent run', 'alice', { envFile: '/etc/x.env\nExecStartPre=/y' }),
|
||||
).toThrow(/control character/)
|
||||
})
|
||||
|
||||
it('AG2: rejects a newline in an Environment KEY (not just the value)', () => {
|
||||
expect(() =>
|
||||
buildSystemdUnit('/bin/agent run', 'alice', { env: { 'X\nExecStartPre=/y': 'v' } }),
|
||||
).toThrow(/control character/)
|
||||
})
|
||||
|
||||
it('systemd: default (no env) omits Environment lines', () => {
|
||||
const unit = buildSystemdUnit('/bin/agent run', 'alice')
|
||||
expect(unit).not.toContain('Environment')
|
||||
})
|
||||
})
|
||||
117
agent/test/keystore.test.ts
Normal file
117
agent/test/keystore.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createPublicKey, generateKeyPairSync, verify } from 'node:crypto'
|
||||
import { generateIdentity, generateP256Identity } from '../src/keys/identity.js'
|
||||
import { KeystoreError, openKeystore } from '../src/keys/keystore.js'
|
||||
|
||||
const dirs: string[] = []
|
||||
function freshDir(): string {
|
||||
const d = mkdtempSync(join(tmpdir(), 'wta-ks-'))
|
||||
dirs.push(d)
|
||||
return d
|
||||
}
|
||||
afterEach(() => {
|
||||
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function mode(path: string): number {
|
||||
return statSync(path).mode & 0o777
|
||||
}
|
||||
|
||||
describe('Keystore (INV4/INV5)', () => {
|
||||
it('persists the private key 0600 and reloads it', () => {
|
||||
const dir = freshDir()
|
||||
const ks = openKeystore(dir)
|
||||
const id = generateIdentity()
|
||||
ks.saveIdentity(id)
|
||||
expect(mode(join(dir, 'agent.key.pem'))).toBe(0o600)
|
||||
const reloaded = ks.loadIdentity()
|
||||
expect(reloaded).not.toBeNull()
|
||||
expect(Buffer.from(reloaded!.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||
})
|
||||
|
||||
it('persists cert + CA chain 0600', () => {
|
||||
const dir = freshDir()
|
||||
const ks = openKeystore(dir)
|
||||
ks.saveCert('CERTPEM', 'CAPEM')
|
||||
expect(mode(join(dir, 'agent.cert.pem'))).toBe(0o600)
|
||||
expect(mode(join(dir, 'agent.ca.pem'))).toBe(0o600)
|
||||
expect(ks.loadCert()).toEqual({ certPem: 'CERTPEM', caChainPem: 'CAPEM' })
|
||||
})
|
||||
|
||||
it('FIX 3: round-trips hostContentSecret 0600', () => {
|
||||
const dir = freshDir()
|
||||
const ks = openKeystore(dir)
|
||||
const secret = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])
|
||||
ks.saveContentSecret(secret)
|
||||
expect(mode(join(dir, 'content.secret'))).toBe(0o600)
|
||||
expect(Buffer.from(ks.loadContentSecret()!).equals(Buffer.from(secret))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns null when nothing is stored yet', () => {
|
||||
const ks = openKeystore(freshDir())
|
||||
expect(ks.loadIdentity()).toBeNull()
|
||||
expect(ks.loadCert()).toBeNull()
|
||||
expect(ks.loadContentSecret()).toBeNull()
|
||||
})
|
||||
|
||||
it('throws a typed error on a corrupt key file', () => {
|
||||
const dir = freshDir()
|
||||
writeFileSync(join(dir, 'agent.key.pem'), 'not-a-pem')
|
||||
expect(() => openKeystore(dir).loadIdentity()).toThrow(KeystoreError)
|
||||
})
|
||||
|
||||
it('refuses to write into a group/world-accessible dir (INV5)', () => {
|
||||
const dir = join(freshDir(), 'wideopen')
|
||||
mkdirSync(dir, { mode: 0o755 })
|
||||
expect(() => openKeystore(dir).saveIdentity(generateIdentity())).toThrow(KeystoreError)
|
||||
})
|
||||
|
||||
it('keeps the Ed25519 identity byte-identical across save/load (no P-256 regression)', () => {
|
||||
const dir = freshDir()
|
||||
const ks = openKeystore(dir)
|
||||
const id = generateIdentity()
|
||||
ks.saveIdentity(id)
|
||||
const reloaded = ks.loadIdentity()
|
||||
expect(reloaded!.alg).toBe('ed25519')
|
||||
expect(Buffer.from(reloaded!.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||
expect(reloaded!.enrollFpr).toBe(id.enrollFpr)
|
||||
})
|
||||
|
||||
it('FIX H-host-2: round-trips a P-256 frp-client identity (alg + usable signing key)', () => {
|
||||
const dir = freshDir()
|
||||
const ks = openKeystore(dir)
|
||||
const id = generateP256Identity()
|
||||
ks.saveIdentity(id)
|
||||
expect(mode(join(dir, 'agent.key.pem'))).toBe(0o600)
|
||||
|
||||
const reloaded = ks.loadIdentity()
|
||||
expect(reloaded).not.toBeNull()
|
||||
// loadIdentity() branched on the stored key's alg discriminant → P-256, not Ed25519.
|
||||
expect(reloaded!.alg).toBe('p256')
|
||||
// EC SubjectPublicKeyInfo DER (outer SEQUENCE) preserved exactly.
|
||||
expect(reloaded!.publicKey[0]).toBe(0x30)
|
||||
expect(Buffer.from(reloaded!.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
|
||||
expect(reloaded!.enrollFpr).toBe(id.enrollFpr)
|
||||
|
||||
// The reloaded key still signs: a DER ECDSA signature that verifies under the original pubkey.
|
||||
const msg = new TextEncoder().encode('csr-bytes')
|
||||
const sig = reloaded!.sign(msg)
|
||||
const pub = createPublicKey({ key: Buffer.from(id.publicKey), format: 'der', type: 'spki' })
|
||||
expect(verify('sha256', msg, pub, sig)).toBe(true)
|
||||
})
|
||||
|
||||
it('AG1: rejects a stored EC key on a non-P256 curve (e.g. secp384r1) with a clear error', () => {
|
||||
const dir = freshDir()
|
||||
const ks = openKeystore(dir)
|
||||
// Plant a valid PKCS#8 EC key on the WRONG curve directly at the key path — `ec` alone must not
|
||||
// be mistaken for P-256; loadIdentity must assert the named curve and fail closed.
|
||||
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'secp384r1' })
|
||||
const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
|
||||
writeFileSync(join(dir, 'agent.key.pem'), pem, { mode: 0o600 })
|
||||
expect(() => ks.loadIdentity()).toThrow(KeystoreError)
|
||||
expect(() => ks.loadIdentity()).toThrow(/prime256v1|P-256|curve/)
|
||||
})
|
||||
})
|
||||
43
agent/test/logger.test.ts
Normal file
43
agent/test/logger.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createLogger } from '../src/log/logger.js'
|
||||
|
||||
function capture() {
|
||||
const lines: string[] = []
|
||||
return { lines, sink: (l: string) => lines.push(l) }
|
||||
}
|
||||
|
||||
describe('redacting logger (INV9)', () => {
|
||||
it('never emits secret meta values', () => {
|
||||
const { lines, sink } = capture()
|
||||
const log = createLogger('debug', sink)
|
||||
log.log('info', 'enrolled', {
|
||||
privateKey: 'SECRET-PRIV-KEY',
|
||||
cert: 'SECRET-CERT',
|
||||
pairingCode: 'ABCD-1234',
|
||||
agentToken: 'tok-xyz',
|
||||
})
|
||||
const joined = lines.join('\n')
|
||||
expect(joined).not.toContain('SECRET-PRIV-KEY')
|
||||
expect(joined).not.toContain('SECRET-CERT')
|
||||
expect(joined).not.toContain('ABCD-1234')
|
||||
expect(joined).not.toContain('tok-xyz')
|
||||
expect(joined).toContain('[REDACTED]')
|
||||
})
|
||||
|
||||
it('passes non-secret meta (nonce) through', () => {
|
||||
const { lines, sink } = capture()
|
||||
const log = createLogger('debug', sink)
|
||||
log.log('debug', 'frame', { nonce: 'abc123', streamId: 7 })
|
||||
expect(lines[0]).toContain('abc123')
|
||||
expect(lines[0]).toContain('7')
|
||||
})
|
||||
|
||||
it('drops messages below the threshold level', () => {
|
||||
const { lines, sink } = capture()
|
||||
const log = createLogger('warn', sink)
|
||||
log.log('debug', 'noisy')
|
||||
log.log('error', 'boom')
|
||||
expect(lines).toHaveLength(1)
|
||||
expect(lines[0]).toContain('boom')
|
||||
})
|
||||
})
|
||||
49
agent/test/loopback.test.ts
Normal file
49
agent/test/loopback.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildLoopbackUrl, dialLoopback, type RawWs, type WsConstructor } from '../src/transport/loopback.js'
|
||||
|
||||
describe('buildLoopbackUrl (T8)', () => {
|
||||
it('joins target + path without a double slash', () => {
|
||||
expect(buildLoopbackUrl('ws://127.0.0.1:3000', '/term?join=x')).toBe('ws://127.0.0.1:3000/term?join=x')
|
||||
expect(buildLoopbackUrl('ws://127.0.0.1:3000/', 'term')).toBe('ws://127.0.0.1:3000/term')
|
||||
})
|
||||
})
|
||||
|
||||
class FakeRawWs implements RawWs {
|
||||
static last: FakeRawWs | null = null
|
||||
readonly url: string
|
||||
readonly origin: string | undefined
|
||||
private readonly handlers = new Map<string, (...a: unknown[]) => void>()
|
||||
constructor(url: string, opts?: { headers?: Record<string, string> }) {
|
||||
this.url = url
|
||||
this.origin = opts?.headers?.['Origin']
|
||||
FakeRawWs.last = this
|
||||
}
|
||||
send(): void {}
|
||||
close(): void {}
|
||||
on(): void {}
|
||||
once(event: string, cb: (...a: unknown[]) => void): void {
|
||||
this.handlers.set(event, cb)
|
||||
}
|
||||
fire(event: string, ...args: unknown[]): void {
|
||||
this.handlers.get(event)?.(...args)
|
||||
}
|
||||
}
|
||||
|
||||
describe('dialLoopback (T8)', () => {
|
||||
it('replays Origin and resolves on open', async () => {
|
||||
const dial = dialLoopback('ws://127.0.0.1:3000', FakeRawWs as unknown as WsConstructor)
|
||||
const promise = dial('/term?join=x', 'https://host-42.term.example.com')
|
||||
FakeRawWs.last!.fire('open')
|
||||
const ws = await promise
|
||||
expect(FakeRawWs.last!.url).toBe('ws://127.0.0.1:3000/term?join=x')
|
||||
expect(FakeRawWs.last!.origin).toBe('https://host-42.term.example.com')
|
||||
expect(ws).toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects on a pre-open error (base app down)', async () => {
|
||||
const dial = dialLoopback('ws://127.0.0.1:3000', FakeRawWs as unknown as WsConstructor)
|
||||
const promise = dial('/term', 'https://x')
|
||||
FakeRawWs.last!.fire('error', new Error('ECONNREFUSED'))
|
||||
await expect(promise).rejects.toThrow('ECONNREFUSED')
|
||||
})
|
||||
})
|
||||
39
agent/test/loopbackLiteral.test.ts
Normal file
39
agent/test/loopbackLiteral.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isLoopbackHostLiteral } from '../src/net/loopbackLiteral.js'
|
||||
|
||||
describe('isLoopbackHostLiteral — strict loopback-literal check (FIX C-host-1 / anti-SSRF)', () => {
|
||||
it('accepts exact loopback literals', () => {
|
||||
expect(isLoopbackHostLiteral('localhost')).toBe(true)
|
||||
expect(isLoopbackHostLiteral('::1')).toBe(true)
|
||||
expect(isLoopbackHostLiteral('[::1]')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts any well-formed IPv4 in 127.0.0.0/8', () => {
|
||||
expect(isLoopbackHostLiteral('127.0.0.1')).toBe(true)
|
||||
expect(isLoopbackHostLiteral('127.0.0.2')).toBe(true)
|
||||
expect(isLoopbackHostLiteral('127.5.5.5')).toBe(true)
|
||||
expect(isLoopbackHostLiteral('127.255.255.255')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-loopback IPs and wildcards', () => {
|
||||
expect(isLoopbackHostLiteral('0.0.0.0')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('192.168.1.10')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('10.0.0.5')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('::')).toBe(false)
|
||||
})
|
||||
|
||||
it('REJECTS suffixed hostnames that merely start with 127. (the S-GATE bypass)', () => {
|
||||
expect(isLoopbackHostLiteral('127.0.0.1.attacker.example.com')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('127.evil.net')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('127.0.0.1.evil.example.com')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('127.0.0.1x')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects malformed / non-dotted-quad partials and out-of-range octets', () => {
|
||||
expect(isLoopbackHostLiteral('127.1')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('127.0.0.256')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('0127.0.0.1')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('')).toBe(false)
|
||||
expect(isLoopbackHostLiteral('127')).toBe(false)
|
||||
})
|
||||
})
|
||||
350
agent/test/nativeRenew.test.ts
Normal file
350
agent/test/nativeRenew.test.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* A5 native cert auto-renew wiring — TDD.
|
||||
*
|
||||
* Covers the host-side glue that was the "one real host code gap": the native run-loop must actually
|
||||
* RENEW the frp-client leaf (not merely monitor its freshness). Three units:
|
||||
* - `createMtlsFetch` — the injected `fetchImpl` `renewCert` uses: it POSTs /renew over mTLS
|
||||
* presenting the CURRENT keystore leaf (read fresh on every call, so a post-rotation renewal
|
||||
* authenticates with the new leaf) and maps the transport response to a `Response`.
|
||||
* - `wireAutoRenew` — routes the rotator's rotated→restartChild(+log), revoked→stop(+log),
|
||||
* error→log(no secret) callbacks and starts it.
|
||||
* - `startNativeAutoRenew` — the end-to-end builder `superviseNative` calls (identity + mTLS fetch
|
||||
* + rotator), returning null (disabled) when unenrolled.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import { generateP256Identity } from '../src/keys/identity.js'
|
||||
import { openKeystore } from '../src/keys/keystore.js'
|
||||
import { createLogger } from '../src/log/logger.js'
|
||||
import type { CertRotator } from '../src/certs/rotation.js'
|
||||
import {
|
||||
createMtlsFetch,
|
||||
startNativeAutoRenew,
|
||||
wireAutoRenew,
|
||||
type MtlsRequest,
|
||||
} from '../src/certs/nativeRenew.js'
|
||||
import { FakeTimer } from './fixtures/fakes.js'
|
||||
import { CertExpiredBeyondGraceError } from '../src/certs/rotation.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://cp.example.com/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
|
||||
function enrolledKs(): { dir: string; ks: ReturnType<typeof openKeystore> } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wta-nr-'))
|
||||
const ks = openKeystore(dir)
|
||||
ks.saveIdentity(generateP256Identity())
|
||||
ks.saveCert('LEAFCERT', 'CACHAIN')
|
||||
return { dir, ks }
|
||||
}
|
||||
|
||||
const farFuture = (): { validTo: Date } => ({ validTo: new Date(Date.now() + 86_400_000) })
|
||||
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
|
||||
|
||||
describe('createMtlsFetch (A5)', () => {
|
||||
it('presents the current keystore cert/key/CA over mTLS and maps the transport response', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const seen: Array<{ url: string; tls: Record<string, unknown>; init: Record<string, unknown> }> = []
|
||||
const request: MtlsRequest = async (url, tls, init) => {
|
||||
seen.push({ url, tls: tls as unknown as Record<string, unknown>, init: init as unknown as Record<string, unknown> })
|
||||
return { status: 200, body: JSON.stringify({ cert: 'NEW', caChain: 'NEWCA' }) }
|
||||
}
|
||||
const f = createMtlsFetch(ks, { request, certParser: farFuture })
|
||||
|
||||
const res = await f('https://cp.example.com/renew', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{"csr":"x"}',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.ok).toBe(true)
|
||||
expect(await res.json()).toEqual({ cert: 'NEW', caChain: 'NEWCA' })
|
||||
expect(seen[0]!.url).toBe('https://cp.example.com/renew')
|
||||
expect(seen[0]!.tls.cert).toBe('LEAFCERT')
|
||||
// /renew verifies the LE-fronted control-plane against the SYSTEM roots, so no private CA is pinned
|
||||
// (pinning the enroll caChain here fails with "unable to get local issuer certificate").
|
||||
expect(seen[0]!.tls.ca).toBeUndefined()
|
||||
expect(seen[0]!.tls.rejectUnauthorized).toBe(true)
|
||||
expect(String(seen[0]!.tls.key)).toContain('PRIVATE KEY') // in-process PKCS#8 key, mTLS only
|
||||
expect(seen[0]!.init.method).toBe('POST')
|
||||
expect(seen[0]!.init.body).toBe('{"csr":"x"}')
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('reads the current cert on every call so a post-rotation renewal uses the new leaf', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const certs: string[] = []
|
||||
const request: MtlsRequest = async (_url, tls) => {
|
||||
certs.push(tls.cert)
|
||||
return { status: 200, body: '{}' }
|
||||
}
|
||||
const f = createMtlsFetch(ks, { request, certParser: farFuture })
|
||||
|
||||
await f('https://x/renew', { method: 'POST' })
|
||||
ks.saveCert('ROTATEDLEAF', 'CACHAIN') // simulate a completed rotation
|
||||
await f('https://x/renew', { method: 'POST' })
|
||||
|
||||
expect(certs).toEqual(['LEAFCERT', 'ROTATEDLEAF'])
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('propagates a not-enrolled keystore as a throw (renewCert then retries with backoff)', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wta-nr-'))
|
||||
const ks = openKeystore(dir)
|
||||
const request: MtlsRequest = async () => ({ status: 200, body: '{}' })
|
||||
const f = createMtlsFetch(ks, { request, certParser: farFuture })
|
||||
await expect(f('https://x/renew', { method: 'POST' })).rejects.toThrow()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('wireAutoRenew (A5)', () => {
|
||||
function fakeRotator(): {
|
||||
rotator: CertRotator
|
||||
fire: {
|
||||
rotated?: () => void
|
||||
revoked?: () => void
|
||||
error?: (e: unknown) => void
|
||||
exhausted?: (e: CertExpiredBeyondGraceError) => void
|
||||
}
|
||||
start: ReturnType<typeof vi.fn>
|
||||
stop: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const fire: {
|
||||
rotated?: () => void
|
||||
revoked?: () => void
|
||||
error?: (e: unknown) => void
|
||||
exhausted?: (e: CertExpiredBeyondGraceError) => void
|
||||
} = {}
|
||||
const start = vi.fn()
|
||||
const stop = vi.fn()
|
||||
const rotator: CertRotator = {
|
||||
start,
|
||||
stop,
|
||||
onRotated: (cb) => {
|
||||
fire.rotated = cb
|
||||
},
|
||||
onRevoked: (cb) => {
|
||||
fire.revoked = cb
|
||||
},
|
||||
onError: (cb) => {
|
||||
fire.error = cb
|
||||
},
|
||||
onExhausted: (cb) => {
|
||||
fire.exhausted = cb
|
||||
},
|
||||
}
|
||||
return { rotator, fire, start, stop }
|
||||
}
|
||||
|
||||
it('routes rotated→restartChild, revoked→stop, error→log; starts and stops the rotator', () => {
|
||||
const { rotator, fire, start, stop } = fakeRotator()
|
||||
const restartChild = vi.fn()
|
||||
const stopSupervisor = vi.fn()
|
||||
const lines: string[] = []
|
||||
const logger = createLogger('info', (l) => lines.push(l))
|
||||
|
||||
const controller = wireAutoRenew(rotator, { restartChild, stop: stopSupervisor }, logger, {
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
})
|
||||
expect(start).toHaveBeenCalledTimes(1)
|
||||
|
||||
fire.rotated!()
|
||||
expect(restartChild).toHaveBeenCalledTimes(1)
|
||||
|
||||
fire.revoked!()
|
||||
expect(stopSupervisor).toHaveBeenCalledTimes(1)
|
||||
|
||||
fire.error!(new Error('network down'))
|
||||
|
||||
controller.stop()
|
||||
expect(stop).toHaveBeenCalledTimes(1)
|
||||
|
||||
const joined = lines.join('\n')
|
||||
expect(joined).toContain('host-42') // non-secret identifier is logged
|
||||
expect(joined).not.toContain('LEAFCERT') // never a leaf/key/CSR
|
||||
})
|
||||
})
|
||||
|
||||
describe('startNativeAutoRenew (A5 end-to-end)', () => {
|
||||
it('a scheduled 200 renewal rotates the leaf on disk and restarts frpc with it', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const timer = new FakeTimer()
|
||||
const request: MtlsRequest = async () => ({
|
||||
status: 200,
|
||||
body: JSON.stringify({ cert: 'FRESHLEAF', caChain: ['CACHAIN'] }),
|
||||
})
|
||||
const restartChild = vi.fn()
|
||||
const stop = vi.fn()
|
||||
|
||||
const controller = startNativeAutoRenew(
|
||||
CFG,
|
||||
ks,
|
||||
{ restartChild, stop },
|
||||
createLogger('error', () => {}),
|
||||
{ mtlsRequest: request, certParser: farFuture, timer, renewBeforeMs: 1000, now: () => new Date(0), parseCert: () => new Date(2000) },
|
||||
)!
|
||||
expect(controller).not.toBeNull()
|
||||
|
||||
timer.advance(1000) // renewal fires at ~2/3 TTL
|
||||
await flush()
|
||||
|
||||
expect(ks.loadCert()!.certPem).toContain('FRESHLEAF') // atomic persist
|
||||
expect(restartChild).toHaveBeenCalledTimes(1) // frpc restarted onto the fresh leaf
|
||||
expect(stop).not.toHaveBeenCalled()
|
||||
controller.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('a scheduled 403 renewal tears the tunnel down (revoked) and never rotates', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const timer = new FakeTimer()
|
||||
const request: MtlsRequest = async () => ({ status: 403, body: '' })
|
||||
const restartChild = vi.fn()
|
||||
const stop = vi.fn()
|
||||
|
||||
const controller = startNativeAutoRenew(
|
||||
CFG,
|
||||
ks,
|
||||
{ restartChild, stop },
|
||||
createLogger('error', () => {}),
|
||||
{ mtlsRequest: request, certParser: farFuture, timer, renewBeforeMs: 1000, now: () => new Date(0), parseCert: () => new Date(2000) },
|
||||
)!
|
||||
|
||||
timer.advance(1000)
|
||||
await flush()
|
||||
|
||||
expect(stop).toHaveBeenCalledTimes(1)
|
||||
expect(restartChild).not.toHaveBeenCalled()
|
||||
expect(ks.loadCert()!.certPem).toBe('LEAFCERT') // untouched
|
||||
controller.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('a failing renewal is logged (no secret) and retried without crashing, then rotates', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const timer = new FakeTimer()
|
||||
let calls = 0
|
||||
const request: MtlsRequest = async () => {
|
||||
calls += 1
|
||||
if (calls === 1) throw new Error('ECONNREFUSED')
|
||||
return { status: 200, body: JSON.stringify({ cert: 'FRESHLEAF', caChain: ['CACHAIN'] }) }
|
||||
}
|
||||
const restartChild = vi.fn()
|
||||
const stop = vi.fn()
|
||||
const lines: string[] = []
|
||||
|
||||
const controller = startNativeAutoRenew(
|
||||
CFG,
|
||||
ks,
|
||||
{ restartChild, stop },
|
||||
createLogger('warn', (l) => lines.push(l)),
|
||||
{
|
||||
mtlsRequest: request,
|
||||
certParser: farFuture,
|
||||
timer,
|
||||
renewBeforeMs: 1000,
|
||||
retryBaseMs: 500,
|
||||
now: () => new Date(0),
|
||||
parseCert: () => new Date(2000),
|
||||
},
|
||||
)!
|
||||
|
||||
timer.advance(1000) // first attempt throws
|
||||
await flush()
|
||||
expect(restartChild).not.toHaveBeenCalled()
|
||||
expect(stop).not.toHaveBeenCalled() // a failure NEVER tears down
|
||||
expect(lines.join('\n')).toContain('retry')
|
||||
|
||||
timer.advance(500) // backoff retry fires and succeeds
|
||||
await flush()
|
||||
expect(ks.loadCert()!.certPem).toContain('FRESHLEAF')
|
||||
expect(restartChild).toHaveBeenCalledTimes(1)
|
||||
expect(lines.join('\n')).not.toContain('LEAFCERT')
|
||||
controller.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns null (auto-renew disabled) when the keystore has no identity', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wta-nr-'))
|
||||
const ks = openKeystore(dir)
|
||||
const lines: string[] = []
|
||||
const controller = startNativeAutoRenew(
|
||||
CFG,
|
||||
ks,
|
||||
{ restartChild: () => {}, stop: () => {} },
|
||||
createLogger('warn', (l) => lines.push(l)),
|
||||
{},
|
||||
)
|
||||
expect(controller).toBeNull()
|
||||
expect(lines.join('\n')).toContain('auto-renew disabled')
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The mTLS renew transport stays STRICT about expiry: nginx refuses to forward an expired client
|
||||
* cert at all, so an expired leaf must be routed to the plain `/recover` endpoint by the rotator
|
||||
* rather than smuggled through this transport.
|
||||
*/
|
||||
describe('createMtlsFetch stays fail-closed on an expired leaf', () => {
|
||||
it('refuses to present a lapsed leaf (recovery is the rotator\'s job, not this transport\'s)', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
let called = 0
|
||||
const request: MtlsRequest = async () => {
|
||||
called += 1
|
||||
return { status: 201, body: '{}' }
|
||||
}
|
||||
const f = createMtlsFetch(ks, {
|
||||
request,
|
||||
certParser: () => ({ validTo: new Date(Date.now() - 86_400_000) }),
|
||||
})
|
||||
await expect(f('https://cp.example.com/renew', { method: 'POST' })).rejects.toThrow(
|
||||
/expired/i,
|
||||
)
|
||||
expect(called).toBe(0)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('wireAutoRenew exhausted routing', () => {
|
||||
it('logs an actionable re-pair alarm and leaves the supervisor running', () => {
|
||||
const fire: { exhausted?: (e: CertExpiredBeyondGraceError) => void } = {}
|
||||
const rotator: CertRotator = {
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
onRotated: () => {},
|
||||
onRevoked: () => {},
|
||||
onError: () => {},
|
||||
onExhausted: (cb) => {
|
||||
fire.exhausted = cb
|
||||
},
|
||||
}
|
||||
const restartChild = vi.fn()
|
||||
const stop = vi.fn()
|
||||
const lines: string[] = []
|
||||
wireAutoRenew(rotator, { restartChild, stop }, createLogger('info', (l) => lines.push(l)), {
|
||||
subdomain: 'h7fd8',
|
||||
hostId: 'h-1',
|
||||
})
|
||||
fire.exhausted?.(new CertExpiredBeyondGraceError(40 * 86_400_000, 30 * 86_400_000))
|
||||
|
||||
const alarm = lines.find((l) => /re-pair/i.test(l))
|
||||
expect(alarm).toBeDefined()
|
||||
expect(alarm).toContain('h7fd8')
|
||||
// The supervisor keeps running: a later `pair` writes fresh cert files that the restarting
|
||||
// frpc child picks up. Tearing down here would make recovery need a manual restart too.
|
||||
expect(stop).not.toHaveBeenCalled()
|
||||
expect(restartChild).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
169
agent/test/nativeRenewTransport.test.ts
Normal file
169
agent/test/nativeRenewTransport.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* A5 default mTLS transport (`defaultMtlsRequest`) — the ONE seam the other nativeRenew tests inject
|
||||
* past, so the real `node:https` transport had zero coverage. These tests mock `node:https` and drive
|
||||
* the actual transport to prove:
|
||||
* - the exact request options (rejectUnauthorized:true + client cert/key + pinned CA + method/body),
|
||||
* - the HIGH fix: a request timeout is armed and a stalled peer REJECTS (never hangs forever),
|
||||
* - the MEDIUM fix: an oversized response body is capped (destroyed + rejected, not buffered).
|
||||
*/
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { requestMock } = vi.hoisted(() => ({ requestMock: vi.fn() }))
|
||||
vi.mock('node:https', () => ({ request: requestMock, default: { request: requestMock } }))
|
||||
|
||||
import { generateP256Identity } from '../src/keys/identity.js'
|
||||
import { openKeystore } from '../src/keys/keystore.js'
|
||||
import {
|
||||
createMtlsFetch,
|
||||
RENEW_REQUEST_TIMEOUT_MS,
|
||||
MAX_RENEW_RESPONSE_BYTES,
|
||||
} from '../src/certs/nativeRenew.js'
|
||||
|
||||
const farFuture = (): { validTo: Date } => ({ validTo: new Date(Date.now() + 86_400_000) })
|
||||
|
||||
/** Fake `http.ClientRequest`: records setTimeout/write/end and emits 'error' on destroy(err). */
|
||||
class FakeClientRequest extends EventEmitter {
|
||||
readonly setTimeoutCalls: Array<{ ms: number; cb: () => void }> = []
|
||||
readonly written: string[] = []
|
||||
ended = false
|
||||
destroyedWith: Error | undefined
|
||||
setTimeout(ms: number, cb: () => void): this {
|
||||
this.setTimeoutCalls.push({ ms, cb })
|
||||
return this
|
||||
}
|
||||
write(chunk: string): boolean {
|
||||
this.written.push(chunk)
|
||||
return true
|
||||
}
|
||||
end(): this {
|
||||
this.ended = true
|
||||
return this
|
||||
}
|
||||
destroy(err?: Error): this {
|
||||
this.destroyedWith = err
|
||||
if (err) this.emit('error', err)
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
/** Fake `http.IncomingMessage`: an EventEmitter with a statusCode and a real destroy(). */
|
||||
class FakeIncomingMessage extends EventEmitter {
|
||||
statusCode = 200
|
||||
destroyed = false
|
||||
destroy(): this {
|
||||
this.destroyed = true
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
type ReqCb = (res: FakeIncomingMessage) => void
|
||||
let dirs: string[] = []
|
||||
|
||||
function enrolledKs(): ReturnType<typeof openKeystore> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wta-nrt-'))
|
||||
dirs.push(dir)
|
||||
const ks = openKeystore(dir)
|
||||
ks.saveIdentity(generateP256Identity())
|
||||
ks.saveCert('LEAFCERT', 'CACHAIN')
|
||||
return ks
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
requestMock.mockReset()
|
||||
})
|
||||
afterEach(() => {
|
||||
for (const d of dirs) rmSync(d, { recursive: true, force: true })
|
||||
dirs = []
|
||||
})
|
||||
|
||||
describe('defaultMtlsRequest transport (A5 — real node:https)', () => {
|
||||
it('passes rejectUnauthorized:true + the client cert/key/CA + method/body, and arms the timeout', async () => {
|
||||
const ks = enrolledKs()
|
||||
let seenUrl = ''
|
||||
let seenOpts: Record<string, unknown> = {}
|
||||
let seenReq: FakeClientRequest | undefined
|
||||
requestMock.mockImplementation((url: string, opts: Record<string, unknown>, cb: ReqCb) => {
|
||||
seenUrl = url
|
||||
seenOpts = opts
|
||||
const req = new FakeClientRequest()
|
||||
seenReq = req
|
||||
queueMicrotask(() => {
|
||||
const res = new FakeIncomingMessage()
|
||||
res.statusCode = 200
|
||||
cb(res)
|
||||
res.emit('data', Buffer.from('{"cert":"NEW",'))
|
||||
res.emit('data', Buffer.from('"caChain":"NEWCA"}'))
|
||||
res.emit('end')
|
||||
})
|
||||
return req
|
||||
})
|
||||
|
||||
const f = createMtlsFetch(ks, { certParser: farFuture }) // NO request seam → real defaultMtlsRequest
|
||||
const res = await f('https://cp.example.com/renew', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{"csr":"x"}',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ cert: 'NEW', caChain: 'NEWCA' })
|
||||
expect(seenUrl).toBe('https://cp.example.com/renew')
|
||||
expect(seenOpts.rejectUnauthorized).toBe(true) // anti-MITM (INV14)
|
||||
expect(seenOpts.method).toBe('POST')
|
||||
expect(seenOpts.cert).toBe('LEAFCERT')
|
||||
// ca omitted ⇒ verify the LE-fronted control-plane against the system roots (not the private CA).
|
||||
expect(seenOpts.ca).toBeUndefined()
|
||||
expect(String(seenOpts.key)).toContain('PRIVATE KEY') // in-process PKCS#8 key
|
||||
// HIGH fix: a socket timeout is armed with the sane default so a stall can never hang forever.
|
||||
expect(seenReq!.setTimeoutCalls).toHaveLength(1)
|
||||
expect(seenReq!.setTimeoutCalls[0]!.ms).toBe(RENEW_REQUEST_TIMEOUT_MS)
|
||||
expect(seenReq!.written).toEqual(['{"csr":"x"}'])
|
||||
expect(seenReq!.ended).toBe(true)
|
||||
})
|
||||
|
||||
it('HIGH: a stalled peer (accepts TLS, never responds) REJECTS via the timeout, not hangs', async () => {
|
||||
const ks = enrolledKs()
|
||||
let seenReq: FakeClientRequest | undefined
|
||||
requestMock.mockImplementation(() => {
|
||||
const req = new FakeClientRequest()
|
||||
seenReq = req
|
||||
return req // never invokes the response callback — a stalled control-plane
|
||||
})
|
||||
|
||||
const f = createMtlsFetch(ks, { certParser: farFuture })
|
||||
const p = f('https://cp.example.com/renew', { method: 'POST', body: '{}' })
|
||||
|
||||
// Fire the armed socket-timeout callback (what node does when the socket idles past the limit).
|
||||
expect(seenReq!.setTimeoutCalls).toHaveLength(1)
|
||||
expect(seenReq!.setTimeoutCalls[0]!.ms).toBe(RENEW_REQUEST_TIMEOUT_MS)
|
||||
seenReq!.setTimeoutCalls[0]!.cb()
|
||||
|
||||
await expect(p).rejects.toThrow(/timed out/i)
|
||||
expect(seenReq!.destroyedWith).toBeInstanceOf(Error) // socket was torn down
|
||||
})
|
||||
|
||||
it('MEDIUM: an oversized response body is capped — res destroyed + promise rejected', async () => {
|
||||
const ks = enrolledKs()
|
||||
let seenRes: FakeIncomingMessage | undefined
|
||||
requestMock.mockImplementation((_url: string, _opts: unknown, cb: ReqCb) => {
|
||||
const req = new FakeClientRequest()
|
||||
queueMicrotask(() => {
|
||||
const res = new FakeIncomingMessage()
|
||||
res.statusCode = 200
|
||||
seenRes = res
|
||||
cb(res)
|
||||
res.emit('data', Buffer.alloc(MAX_RENEW_RESPONSE_BYTES + 1)) // one byte over the cap
|
||||
// deliberately NO 'end' — a capped stream must reject on its own, not wait for end
|
||||
})
|
||||
return req
|
||||
})
|
||||
|
||||
const f = createMtlsFetch(ks, { certParser: farFuture })
|
||||
await expect(f('https://cp.example.com/renew', { method: 'POST' })).rejects.toThrow(/cap|exceed/i)
|
||||
expect(seenRes!.destroyed).toBe(true)
|
||||
})
|
||||
})
|
||||
84
agent/test/originConfig.test.ts
Normal file
84
agent/test/originConfig.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_ORIGIN_ZONE,
|
||||
ensureAllowedOrigin,
|
||||
mergeOrigins,
|
||||
subdomainOrigin,
|
||||
type OriginFsDeps,
|
||||
} from '../src/service/originConfig.js'
|
||||
|
||||
function memFs(initial: string | null): { fs: OriginFsDeps; get(): string } {
|
||||
const store = { content: initial }
|
||||
const fs: OriginFsDeps = {
|
||||
exists: () => store.content !== null,
|
||||
read: () => store.content ?? '',
|
||||
write: (_p, c) => {
|
||||
store.content = c
|
||||
},
|
||||
}
|
||||
return { fs, get: () => store.content ?? '' }
|
||||
}
|
||||
|
||||
const PATH = '/etc/web-terminal.env'
|
||||
|
||||
describe('ensureAllowedOrigin (T17, EXPLORE §3)', () => {
|
||||
it('composes https://<subdomain>.term.<domain>', () => {
|
||||
expect(subdomainOrigin('host-42', 'example.com')).toBe('https://host-42.term.example.com')
|
||||
})
|
||||
|
||||
it('appends the origin when the file has none', () => {
|
||||
const { fs, get } = memFs('PORT=3000\n')
|
||||
ensureAllowedOrigin(PATH, 'host-42', 'example.com', fs)
|
||||
expect(get()).toContain('ALLOWED_ORIGINS=https://host-42.term.example.com')
|
||||
expect(get()).toContain('PORT=3000')
|
||||
})
|
||||
|
||||
it('is idempotent (no duplicate on a second run)', () => {
|
||||
const { fs, get } = memFs('ALLOWED_ORIGINS=https://host-42.term.example.com\n')
|
||||
ensureAllowedOrigin(PATH, 'host-42', 'example.com', fs)
|
||||
const matches = get().match(/host-42\.term\.example\.com/g) ?? []
|
||||
expect(matches).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves existing origins (never weakens the Origin check)', () => {
|
||||
const { fs, get } = memFs('ALLOWED_ORIGINS=https://existing.example.com\n')
|
||||
ensureAllowedOrigin(PATH, 'host-42', 'example.com', fs)
|
||||
expect(get()).toContain('https://existing.example.com')
|
||||
expect(get()).toContain('https://host-42.term.example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('zone parameterization (PLAN_NATIVE_TUNNEL S2)', () => {
|
||||
it('defaults to the historical `term` zone', () => {
|
||||
expect(DEFAULT_ORIGIN_ZONE).toBe('term')
|
||||
expect(subdomainOrigin('t1', 'yaojia.wang')).toBe('https://t1.term.yaojia.wang')
|
||||
})
|
||||
|
||||
it('composes the `terminal` zone for native-tunnel hosts', () => {
|
||||
expect(subdomainOrigin('t1', 'yaojia.wang', 'terminal')).toBe('https://t1.terminal.yaojia.wang')
|
||||
})
|
||||
|
||||
it('ensureAllowedOrigin writes the caller-selected zone', () => {
|
||||
const { fs, get } = memFs('PORT=3000\n')
|
||||
ensureAllowedOrigin(PATH, 't1', 'yaojia.wang', fs, 'terminal')
|
||||
expect(get()).toContain('ALLOWED_ORIGINS=https://t1.terminal.yaojia.wang')
|
||||
expect(get()).not.toContain('.term.yaojia.wang')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeOrigins (PLAN_NATIVE_TUNNEL S2)', () => {
|
||||
it('appends to an empty/undefined value', () => {
|
||||
expect(mergeOrigins(undefined, 'https://a.example.com')).toBe('https://a.example.com')
|
||||
expect(mergeOrigins('', 'https://a.example.com')).toBe('https://a.example.com')
|
||||
})
|
||||
|
||||
it('de-duplicates an origin already present', () => {
|
||||
expect(mergeOrigins('https://a.example.com', 'https://a.example.com')).toBe('https://a.example.com')
|
||||
})
|
||||
|
||||
it('appends a new origin, trimming whitespace, preserving existing ones', () => {
|
||||
expect(mergeOrigins(' https://a.example.com , https://b.example.com ', 'https://c.example.com')).toBe(
|
||||
'https://a.example.com,https://b.example.com,https://c.example.com',
|
||||
)
|
||||
})
|
||||
})
|
||||
105
agent/test/pair.test.ts
Normal file
105
agent/test/pair.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { encodeBase64UrlBytes } from 'relay-contracts'
|
||||
import { generateIdentity } from '../src/keys/identity.js'
|
||||
import { openKeystore } from '../src/keys/keystore.js'
|
||||
import {
|
||||
DEFAULT_ENROLL_MODE,
|
||||
EnrollError,
|
||||
PairingCodeExpiredError,
|
||||
PairingCodeSpentError,
|
||||
redeemPairingCode,
|
||||
} from '../src/enroll/pair.js'
|
||||
|
||||
const ENROLL_URL = 'https://example.com/enroll'
|
||||
const VALID_UUID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
function freshKs() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wta-pair-'))
|
||||
return { dir, ks: openKeystore(dir) }
|
||||
}
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
function okBody(wrappedSecret: Uint8Array) {
|
||||
return {
|
||||
hostId: VALID_UUID,
|
||||
subdomain: 'host-42',
|
||||
cert: 'CERTPEM',
|
||||
caChain: 'CAPEM',
|
||||
hostContentSecret: encodeBase64UrlBytes(wrappedSecret),
|
||||
}
|
||||
}
|
||||
|
||||
describe('redeemPairingCode (§4.5, T4)', () => {
|
||||
it('defaults to ed25519 mode', () => {
|
||||
expect(DEFAULT_ENROLL_MODE).toBe('ed25519')
|
||||
})
|
||||
|
||||
it('sends only pubkey + csr, never the private key (INV4)', async () => {
|
||||
const { dir, ks } = freshKs()
|
||||
const id = generateIdentity()
|
||||
const fetchImpl = vi.fn(async (_u: string | URL | Request, init?: RequestInit) => {
|
||||
const body = JSON.parse(String(init!.body)) as Record<string, unknown>
|
||||
expect(body).toHaveProperty('agentPubkey')
|
||||
expect(body).toHaveProperty('csr')
|
||||
expect(JSON.stringify(body)).not.toContain('PRIVATE KEY')
|
||||
expect(body).not.toHaveProperty('privateKey')
|
||||
return jsonResponse(200, okBody(new Uint8Array([9, 9, 9])))
|
||||
})
|
||||
await redeemPairingCode(ENROLL_URL, 'ABCD-1234', id, ks, { fetchImpl: fetchImpl as unknown as typeof fetch })
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('stores cert + unwrapped content secret; wrapped bytes not persisted (FIX 3)', async () => {
|
||||
const { dir, ks } = freshKs()
|
||||
const id = generateIdentity()
|
||||
const wrapped = new Uint8Array([1, 2, 3])
|
||||
const unwrapped = new Uint8Array([7, 7, 7])
|
||||
const fetchImpl = async () => jsonResponse(200, okBody(wrapped))
|
||||
await redeemPairingCode(ENROLL_URL, 'ABCD-1234', id, ks, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
unwrapContentSecret: () => unwrapped,
|
||||
})
|
||||
expect(ks.loadCert()).toEqual({ certPem: 'CERTPEM', caChainPem: 'CAPEM' })
|
||||
const stored = ks.loadContentSecret()!
|
||||
expect(Buffer.from(stored).equals(Buffer.from(unwrapped))).toBe(true)
|
||||
expect(Buffer.from(stored).equals(Buffer.from(wrapped))).toBe(false)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('maps 409 → PairingCodeSpentError (single-use)', async () => {
|
||||
const { dir, ks } = freshKs()
|
||||
const fetchImpl = async () => jsonResponse(409, {})
|
||||
await expect(
|
||||
redeemPairingCode(ENROLL_URL, 'X', generateIdentity(), ks, { fetchImpl: fetchImpl as unknown as typeof fetch }),
|
||||
).rejects.toBeInstanceOf(PairingCodeSpentError)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('maps 410 → PairingCodeExpiredError', async () => {
|
||||
const { dir, ks } = freshKs()
|
||||
const fetchImpl = async () => jsonResponse(410, {})
|
||||
await expect(
|
||||
redeemPairingCode(ENROLL_URL, 'X', generateIdentity(), ks, { fetchImpl: fetchImpl as unknown as typeof fetch }),
|
||||
).rejects.toBeInstanceOf(PairingCodeExpiredError)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('rejects a schema-mismatched response (boundary validation)', async () => {
|
||||
const { dir, ks } = freshKs()
|
||||
const fetchImpl = async () => jsonResponse(200, { hostId: 'not-a-uuid', subdomain: 'x' })
|
||||
await expect(
|
||||
redeemPairingCode(ENROLL_URL, 'X', generateIdentity(), ks, { fetchImpl: fetchImpl as unknown as typeof fetch }),
|
||||
).rejects.toBeInstanceOf(EnrollError)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
216
agent/test/probe.test.ts
Normal file
216
agent/test/probe.test.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
DEFAULT_CERT_RENEW_WINDOW_MS,
|
||||
certIsFresh,
|
||||
frpcProxyStarted,
|
||||
probeLoopbackBaseApp,
|
||||
renderHealthStatus,
|
||||
runHealthProbe,
|
||||
startHealthMonitor,
|
||||
type HealthProbeSeams,
|
||||
type HealthReport,
|
||||
type IntervalTimer,
|
||||
} from '../src/health/probe.js'
|
||||
|
||||
/** All-passing seams; each test overrides exactly one to prove sub-check independence. */
|
||||
function healthySeams(over: Partial<HealthProbeSeams> = {}): HealthProbeSeams {
|
||||
return {
|
||||
isFrpcAlive: () => true,
|
||||
probeBaseApp: async () => true,
|
||||
readFrpcLog: () => 'proxy [web-terminal] start proxy success',
|
||||
certNotAfter: () => new Date('2026-08-01T00:00:00Z'),
|
||||
now: () => new Date('2026-07-08T00:00:00Z'),
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('frpcProxyStarted (log scan)', () => {
|
||||
it('detects the frpc start-proxy-success line', () => {
|
||||
expect(frpcProxyStarted('2026/07/08 [I] [proxy_manager] start proxy success')).toBe(true)
|
||||
})
|
||||
|
||||
it('is false before frpc reports success', () => {
|
||||
expect(frpcProxyStarted('login to server success\nstart proxy ...')).toBe(false)
|
||||
expect(frpcProxyStarted('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('certIsFresh (near-expiry check)', () => {
|
||||
const now = new Date('2026-07-08T00:00:00Z')
|
||||
|
||||
it('is fresh when notAfter is beyond the renewal window', () => {
|
||||
const notAfter = new Date(now.getTime() + DEFAULT_CERT_RENEW_WINDOW_MS + 60_000)
|
||||
expect(certIsFresh(notAfter, now, DEFAULT_CERT_RENEW_WINDOW_MS)).toBe(true)
|
||||
})
|
||||
|
||||
it('is NOT fresh when notAfter is inside the renewal window', () => {
|
||||
const notAfter = new Date(now.getTime() + DEFAULT_CERT_RENEW_WINDOW_MS - 60_000)
|
||||
expect(certIsFresh(notAfter, now, DEFAULT_CERT_RENEW_WINDOW_MS)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a missing cert (null notAfter) as not fresh', () => {
|
||||
expect(certIsFresh(null, now, DEFAULT_CERT_RENEW_WINDOW_MS)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('probeLoopbackBaseApp (loopback-only)', () => {
|
||||
it('targets 127.0.0.1:PORT and returns true on an ok response', async () => {
|
||||
const fetchImpl = vi.fn(async (url: string) => ({ ok: url.includes('127.0.0.1:3000') }))
|
||||
await expect(probeLoopbackBaseApp(3000, fetchImpl)).resolves.toBe(true)
|
||||
expect(fetchImpl).toHaveBeenCalledWith('http://127.0.0.1:3000/')
|
||||
})
|
||||
|
||||
it('returns false on a non-ok response', async () => {
|
||||
await expect(probeLoopbackBaseApp(3000, async () => ({ ok: false }))).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('swallows a rejected fetch (a probe never throws)', async () => {
|
||||
await expect(
|
||||
probeLoopbackBaseApp(3000, async () => {
|
||||
throw new Error('ECONNREFUSED')
|
||||
}),
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an out-of-range port without fetching', async () => {
|
||||
const fetchImpl = vi.fn(async () => ({ ok: true }))
|
||||
await expect(probeLoopbackBaseApp(0, fetchImpl)).resolves.toBe(false)
|
||||
await expect(probeLoopbackBaseApp(70000, fetchImpl)).resolves.toBe(false)
|
||||
expect(fetchImpl).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('runHealthProbe (aggregate verdict)', () => {
|
||||
it('is healthy when all four sub-checks pass', async () => {
|
||||
const report = await runHealthProbe(healthySeams())
|
||||
expect(report).toEqual<HealthReport>({
|
||||
frpcAlive: true,
|
||||
baseAppReachable: true,
|
||||
proxyStarted: true,
|
||||
certFresh: true,
|
||||
healthy: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('is unhealthy if frpc is dead', async () => {
|
||||
const report = await runHealthProbe(healthySeams({ isFrpcAlive: () => false }))
|
||||
expect(report.frpcAlive).toBe(false)
|
||||
expect(report.healthy).toBe(false)
|
||||
})
|
||||
|
||||
it('is unhealthy if the base app is unreachable', async () => {
|
||||
const report = await runHealthProbe(healthySeams({ probeBaseApp: async () => false }))
|
||||
expect(report.baseAppReachable).toBe(false)
|
||||
expect(report.healthy).toBe(false)
|
||||
})
|
||||
|
||||
it('is unhealthy if the proxy never started', async () => {
|
||||
const report = await runHealthProbe(healthySeams({ readFrpcLog: () => 'connecting...' }))
|
||||
expect(report.proxyStarted).toBe(false)
|
||||
expect(report.healthy).toBe(false)
|
||||
})
|
||||
|
||||
it('is unhealthy if the cert is near expiry', async () => {
|
||||
const report = await runHealthProbe(
|
||||
healthySeams({
|
||||
certNotAfter: () => new Date('2026-07-08T01:00:00Z'), // 1h out, inside 8h window
|
||||
}),
|
||||
)
|
||||
expect(report.certFresh).toBe(false)
|
||||
expect(report.healthy).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderHealthStatus (INV9 — non-secret only)', () => {
|
||||
const report: HealthReport = {
|
||||
frpcAlive: true,
|
||||
baseAppReachable: true,
|
||||
proxyStarted: true,
|
||||
certFresh: true,
|
||||
healthy: true,
|
||||
}
|
||||
|
||||
it('prints subdomain, host id, expiry date, and flags', () => {
|
||||
const lines = renderHealthStatus(
|
||||
{ subdomain: 'alice', hostId: 'h-1', certNotAfter: new Date('2026-08-01T00:00:00Z') },
|
||||
report,
|
||||
)
|
||||
const joined = lines.join('\n')
|
||||
expect(joined).toContain('subdomain: alice')
|
||||
expect(joined).toContain('host_id: h-1')
|
||||
expect(joined).toContain('cert_expiry: 2026-08-01T00:00:00.000Z')
|
||||
expect(joined).toContain('healthy: true')
|
||||
})
|
||||
|
||||
it('leaks NO key/cert/token/CSR material', () => {
|
||||
const lines = renderHealthStatus(
|
||||
{ subdomain: 'alice', hostId: 'h-1', certNotAfter: new Date('2026-08-01T00:00:00Z') },
|
||||
report,
|
||||
)
|
||||
const joined = lines.join('\n')
|
||||
expect(joined).not.toMatch(/PRIVATE KEY|BEGIN CERTIFICATE|BEGIN CERTIFICATE REQUEST/)
|
||||
expect(joined.toLowerCase()).not.toMatch(/token|secret|csr|pem/)
|
||||
})
|
||||
|
||||
it('renders (none)/(unknown) placeholders when identifiers are absent', () => {
|
||||
const lines = renderHealthStatus({ subdomain: null, hostId: null, certNotAfter: null }, report)
|
||||
const joined = lines.join('\n')
|
||||
expect(joined).toContain('subdomain: (none)')
|
||||
expect(joined).toContain('cert_expiry: (unknown)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('startHealthMonitor (periodic)', () => {
|
||||
function fakeTimer(): { timer: IntervalTimer; fire: () => void; cleared: boolean } {
|
||||
let cb: (() => void) | null = null
|
||||
const state = { cleared: false }
|
||||
return {
|
||||
timer: {
|
||||
setInterval: (fn) => {
|
||||
cb = fn
|
||||
return 1
|
||||
},
|
||||
clearInterval: () => {
|
||||
state.cleared = true
|
||||
},
|
||||
},
|
||||
fire: () => cb?.(),
|
||||
get cleared() {
|
||||
return state.cleared
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
it('runs the probe on each tick and reports it', async () => {
|
||||
const report: HealthReport = {
|
||||
frpcAlive: true,
|
||||
baseAppReachable: true,
|
||||
proxyStarted: true,
|
||||
certFresh: true,
|
||||
healthy: true,
|
||||
}
|
||||
const probe = vi.fn(async () => report)
|
||||
const seen: HealthReport[] = []
|
||||
const ft = fakeTimer()
|
||||
const monitor = startHealthMonitor(probe, (r) => seen.push(r), { timer: ft.timer })
|
||||
|
||||
ft.fire()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
expect(seen).toEqual([report])
|
||||
|
||||
monitor.stop()
|
||||
expect(ft.cleared).toBe(true)
|
||||
})
|
||||
|
||||
it('swallows a rejected probe (monitor never crashes)', async () => {
|
||||
const ft = fakeTimer()
|
||||
const onReport = vi.fn()
|
||||
startHealthMonitor(async () => Promise.reject(new Error('boom')), onReport, { timer: ft.timer })
|
||||
ft.fire()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(onReport).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
113
agent/test/replaySeal.test.ts
Normal file
113
agent/test/replaySeal.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts'
|
||||
import { createReplaySealer, type ReplayCrypto } from '../src/e2e/replaySeal.js'
|
||||
|
||||
/**
|
||||
* Fake AEAD. The derived key is a tag that DEPENDS on every ReplayKeyParams field — crucially on
|
||||
* `epoch` (F6), so two sealer generations for the same (secret, sessionId, alg) yield DIFFERENT keys.
|
||||
* The tag leads with `epoch` so key[0] also varies per generation; ciphertext = plaintext XOR key[0]
|
||||
* (a UUID's first char is a hex digit 0x30–0x66 → always nonzero, so the marker is always hidden).
|
||||
*/
|
||||
function fakeCrypto(): ReplayCrypto & { derivations: ReplayKeyParams[]; keys: Uint8Array[] } {
|
||||
const derivations: ReplayKeyParams[] = []
|
||||
const keys: Uint8Array[] = []
|
||||
return {
|
||||
derivations,
|
||||
keys,
|
||||
deriveContentKey(params: ReplayKeyParams): AeadKey {
|
||||
derivations.push(params)
|
||||
const tag = new TextEncoder().encode(
|
||||
`${params.epoch}:${Buffer.from(params.hostContentSecret).toString('hex')}:${params.sessionId}:${params.alg}`,
|
||||
)
|
||||
keys.push(tag)
|
||||
return tag as unknown as AeadKey
|
||||
},
|
||||
sealReplayFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope {
|
||||
const kb = (key as unknown as Uint8Array)[0] ?? 0x5a
|
||||
const ciphertext = plaintext.map((b) => b ^ kb)
|
||||
return { seq, nonce: new Uint8Array([Number(seq & 0xffn)]), ciphertext, tag: new Uint8Array([0xaa]) }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const SECRET = new Uint8Array([1, 2, 3, 4])
|
||||
|
||||
describe('createReplaySealer (T19, FIX 3)', () => {
|
||||
it('folds the exposed per-generation epoch into K_content, deriving it exactly once', () => {
|
||||
const c = fakeCrypto()
|
||||
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
|
||||
expect(c.derivations).toHaveLength(1)
|
||||
expect(c.derivations[0]!.epoch).toBe(sealer.epoch)
|
||||
expect(sealer.epoch).toMatch(/^[0-9a-f-]{36}$/) // randomUUID shape
|
||||
})
|
||||
|
||||
it('recoverable WITHIN one generation: re-deriving with the exposed epoch yields the same key', () => {
|
||||
const c = fakeCrypto()
|
||||
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
|
||||
// The browser re-derives from the SAME (secret, sessionId, alg, epoch) carried with the ring buffer.
|
||||
const browser = fakeCrypto()
|
||||
const browserKey = browser.deriveContentKey({
|
||||
hostContentSecret: SECRET,
|
||||
sessionId: 'sess-1',
|
||||
alg: 'aes-256-gcm',
|
||||
epoch: sealer.epoch,
|
||||
}) as unknown as Uint8Array
|
||||
expect(Buffer.from(browserKey).equals(Buffer.from(c.keys[0]!))).toBe(true)
|
||||
})
|
||||
|
||||
it('a different sessionId → a different key (per-session separation)', () => {
|
||||
const c = fakeCrypto()
|
||||
createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
|
||||
createReplaySealer(SECRET, 'sess-2', 'aes-256-gcm', c)
|
||||
expect(c.derivations[0]!.sessionId).not.toBe(c.derivations[1]!.sessionId)
|
||||
})
|
||||
|
||||
it('F6 regression: two generations for the SAME (secret, sessionId, alg) get DIFFERENT epochs → DIFFERENT keys, so seq=0 never collides', () => {
|
||||
const c = fakeCrypto()
|
||||
const gen1 = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
|
||||
const gen2 = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
|
||||
// Fresh epoch per generation…
|
||||
expect(gen2.epoch).not.toBe(gen1.epoch)
|
||||
// …therefore distinct K_content even though (secret, sessionId, alg) are identical…
|
||||
expect(Buffer.from(c.keys[1]!).equals(Buffer.from(c.keys[0]!))).toBe(false)
|
||||
// …so the seq=0 seal of generation 2 uses a DIFFERENT key than the seq=0 seal of generation 1
|
||||
// (this is exactly the (key, nonce) reuse F6 prevents — same nonce, but a fresh key).
|
||||
const s1 = gen1.seal(new Uint8Array([0x41, 0x42, 0x43]))
|
||||
const s2 = gen2.seal(new Uint8Array([0x41, 0x42, 0x43]))
|
||||
expect(s1.seq).toBe(0n)
|
||||
expect(s2.seq).toBe(0n)
|
||||
expect(s1.nonce).toEqual(s2.nonce) // same deterministic nonce (seq=0)…
|
||||
})
|
||||
|
||||
it('emits a monotonic seq (INV13) and never leaks the plaintext marker (INV2)', () => {
|
||||
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto())
|
||||
const marker = new TextEncoder().encode('SECRET-MARKER')
|
||||
const e0 = sealer.seal(marker)
|
||||
const e1 = sealer.seal(marker)
|
||||
expect(e0.seq).toBe(0n)
|
||||
expect(e1.seq).toBe(1n)
|
||||
expect(Buffer.from(e0.ciphertext).includes(Buffer.from(marker))).toBe(false)
|
||||
})
|
||||
|
||||
it('replay seal is DISTINCT from a live h2c seal for the same plaintext (FIX 3)', () => {
|
||||
const replay = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto())
|
||||
// model a live seal with a different key byte (0x11 is below the 0x30–0x66 hex-digit epoch prefix)
|
||||
const liveKey = new Uint8Array([0x11]) as unknown as AeadKey
|
||||
const live = fakeCrypto().sealReplayFrame(liveKey, 0n, new Uint8Array([0x41, 0x42]))
|
||||
const rep = replay.seal(new Uint8Array([0x41, 0x42]))
|
||||
expect(Buffer.from(rep.ciphertext).equals(Buffer.from(live.ciphertext))).toBe(false)
|
||||
})
|
||||
|
||||
it('the hostContentSecret is never mutated', () => {
|
||||
const secret = new Uint8Array([9, 9, 9])
|
||||
const spy = vi.fn()
|
||||
createReplaySealer(secret, 's', 'aes-256-gcm', {
|
||||
deriveContentKey: (p) => {
|
||||
spy(p.hostContentSecret)
|
||||
return new Uint8Array([1]) as unknown as AeadKey
|
||||
},
|
||||
sealReplayFrame: (_k, seq, pt) => ({ seq, nonce: new Uint8Array(), ciphertext: pt, tag: new Uint8Array() }),
|
||||
})
|
||||
expect([...secret]).toEqual([9, 9, 9])
|
||||
})
|
||||
})
|
||||
69
agent/test/revocation.test.ts
Normal file
69
agent/test/revocation.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { GOAWAY_REASON_TO_CODE } from 'relay-contracts'
|
||||
import {
|
||||
applyGoAway,
|
||||
applyGoAwayCode,
|
||||
classifyGoAway,
|
||||
createRevocationState,
|
||||
} from '../src/lifecycle/revocation.js'
|
||||
|
||||
describe('classifyGoAway (T14, frozen 3-value union)', () => {
|
||||
it('maps operatorDrain and shutdown → drain, revoked → revoked', () => {
|
||||
expect(classifyGoAway('operatorDrain')).toBe('drain')
|
||||
expect(classifyGoAway('shutdown')).toBe('drain')
|
||||
expect(classifyGoAway('revoked')).toBe('revoked')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createRevocationState (T14, INV12)', () => {
|
||||
it('revoke tears down once and makes isRevoked() true (suppresses reconnect)', () => {
|
||||
let teardowns = 0
|
||||
const state = createRevocationState(() => {
|
||||
teardowns += 1
|
||||
})
|
||||
expect(state.isRevoked()).toBe(false)
|
||||
state.markRevoked('goaway-revoked')
|
||||
expect(state.isRevoked()).toBe(true)
|
||||
state.markRevoked('operator') // idempotent
|
||||
expect(teardowns).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyGoAway / applyGoAwayCode (T14)', () => {
|
||||
it('a revoked GOAWAY tears down; a drain GOAWAY does not', () => {
|
||||
const revokeState = createRevocationState(() => {})
|
||||
expect(applyGoAway('revoked', revokeState)).toBe('revoked')
|
||||
expect(revokeState.isRevoked()).toBe(true)
|
||||
|
||||
const drainState = createRevocationState(() => {})
|
||||
expect(applyGoAway('operatorDrain', drainState)).toBe('drain')
|
||||
expect(applyGoAway('shutdown', drainState)).toBe('drain')
|
||||
expect(drainState.isRevoked()).toBe(false)
|
||||
})
|
||||
|
||||
it('decodes the frozen wire codes via decodeGoAwayReason', () => {
|
||||
const state = createRevocationState(() => {})
|
||||
expect(applyGoAwayCode(GOAWAY_REASON_TO_CODE.operatorDrain, state)).toBe('drain')
|
||||
expect(applyGoAwayCode(GOAWAY_REASON_TO_CODE.revoked, state)).toBe('revoked')
|
||||
})
|
||||
|
||||
it('an unknown reason code FAILS CLOSED to revoked (INV12 safety)', () => {
|
||||
const state = createRevocationState(() => {})
|
||||
expect(applyGoAwayCode(99, state)).toBe('revoked')
|
||||
expect(state.isRevoked()).toBe(true)
|
||||
})
|
||||
|
||||
it('cross-plan-drift guard: no bare integer reason literal in revocation.ts', () => {
|
||||
const src = readFileSync(
|
||||
join(import.meta.dirname, '..', 'src', 'lifecycle', 'revocation.ts'),
|
||||
'utf8',
|
||||
)
|
||||
// The reason mapping must come only from the frozen union/decoder — never a hardcoded
|
||||
// `=== 2` / `reason: 2` style integer. (Comments are allowed to mention numbers.)
|
||||
const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
|
||||
expect(code).not.toMatch(/reason\s*[=:]\s*\d/)
|
||||
expect(code).not.toMatch(/===\s*[123]\b/)
|
||||
})
|
||||
})
|
||||
310
agent/test/rotation.test.ts
Normal file
310
agent/test/rotation.test.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import { generateIdentity } from '../src/keys/identity.js'
|
||||
import { openKeystore } from '../src/keys/keystore.js'
|
||||
import {
|
||||
computeRenewDelayMs,
|
||||
createCertRotator,
|
||||
recoverCert,
|
||||
recoveryUrlFor,
|
||||
renewCert,
|
||||
renewalUrlFor,
|
||||
} from '../src/certs/rotation.js'
|
||||
import { CertExpiredBeyondGraceError } from '../src/certs/rotation.js'
|
||||
import { createBackoff } from '../src/transport/backoff.js'
|
||||
import { FakeTimer } from './fixtures/fakes.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://example.com/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
|
||||
function enrolledKs() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wta-rot-'))
|
||||
const ks = openKeystore(dir)
|
||||
ks.saveIdentity(generateIdentity())
|
||||
ks.saveCert('OLDCERT', 'OLDCA')
|
||||
return { dir, ks }
|
||||
}
|
||||
|
||||
function jsonRes(status: number, body: unknown): Response {
|
||||
return { ok: status >= 200 && status < 300, status, json: async () => body } as unknown as Response
|
||||
}
|
||||
|
||||
describe('cert rotation math (T13)', () => {
|
||||
it('derives P3 renewal URL from enrollUrl', () => {
|
||||
expect(renewalUrlFor(CFG)).toBe('https://example.com/renew')
|
||||
})
|
||||
|
||||
it('renews renewBeforeMs before expiry, clamped at 0', () => {
|
||||
const now = new Date('2026-01-01T00:00:00Z')
|
||||
const parse = () => new Date('2026-01-01T01:00:00Z') // expires in 1h
|
||||
expect(computeRenewDelayMs('C', 5 * 60_000, now, parse)).toBe(55 * 60_000)
|
||||
const parsePast = () => new Date('2025-01-01T00:00:00Z')
|
||||
expect(computeRenewDelayMs('C', 5 * 60_000, now, parsePast)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renewCert (T13)', () => {
|
||||
it('installs a fresh cert atomically on success (same key)', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const before = ks.loadIdentity()!.publicKey
|
||||
const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] }))
|
||||
const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch)
|
||||
expect(out).toBe('rotated')
|
||||
expect(ks.loadCert()!.certPem).toContain('NEWCERT'); expect(ks.loadCert()!.caChainPem).toContain('NEWCA')
|
||||
// pubkey unchanged — only the cert rotated
|
||||
expect(Buffer.from(ks.loadIdentity()!.publicKey).equals(Buffer.from(before))).toBe(true)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('403 → revoked (⇒ T14 teardown, INV12)', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const fetchImpl = async () => jsonRes(403, {})
|
||||
const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch)
|
||||
expect(out).toBe('revoked')
|
||||
expect(ks.loadCert()).toEqual({ certPem: 'OLDCERT', caChainPem: 'OLDCA' }) // untouched
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
const flush = () => new Promise((r) => setImmediate(r))
|
||||
|
||||
describe('createCertRotator (T13)', () => {
|
||||
it('fires onRevoked when the scheduled renewal returns 403', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const timer = new FakeTimer()
|
||||
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
||||
timer,
|
||||
renewBeforeMs: 1000,
|
||||
fetchImpl: (async () => jsonRes(403, {})) as unknown as typeof fetch,
|
||||
now: () => new Date(0),
|
||||
parseCert: () => new Date(2000), // expires 2s after epoch → delay ~1000ms
|
||||
})
|
||||
let revoked = false
|
||||
rotator.onRevoked(() => {
|
||||
revoked = true
|
||||
})
|
||||
rotator.start()
|
||||
timer.advance(1000) // scheduled renewal fires
|
||||
await flush()
|
||||
expect(revoked).toBe(true)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('rotates and reschedules on a successful renewal (seamless)', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const timer = new FakeTimer()
|
||||
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
||||
timer,
|
||||
renewBeforeMs: 1000,
|
||||
fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })) as unknown as typeof fetch,
|
||||
now: () => new Date(0),
|
||||
parseCert: () => new Date(2000),
|
||||
})
|
||||
let rotated = 0
|
||||
rotator.onRotated(() => {
|
||||
rotated += 1
|
||||
})
|
||||
rotator.start()
|
||||
timer.advance(1000)
|
||||
await flush()
|
||||
expect(rotated).toBe(1)
|
||||
expect(ks.loadCert()!.certPem).toContain('NEWCERT')
|
||||
rotator.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('invokes onError and retries with backoff (not renewBeforeMs) when a renewal throws', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const timer = new FakeTimer()
|
||||
let calls = 0
|
||||
const fetchImpl = (async () => {
|
||||
calls += 1
|
||||
if (calls === 1) throw new Error('network down')
|
||||
return jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })
|
||||
}) as unknown as typeof fetch
|
||||
const errors: unknown[] = []
|
||||
let rotated = 0
|
||||
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
||||
timer,
|
||||
renewBeforeMs: 1000,
|
||||
retryBackoff: createBackoff({ baseMs: 500, jitter: false }),
|
||||
fetchImpl,
|
||||
now: () => new Date(0),
|
||||
parseCert: () => new Date(2000), // initial renewal scheduled at ~1000ms
|
||||
})
|
||||
rotator.onError((e) => errors.push(e))
|
||||
rotator.onRotated(() => {
|
||||
rotated += 1
|
||||
})
|
||||
rotator.start()
|
||||
|
||||
timer.advance(1000) // first attempt fires → throws
|
||||
await flush()
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(rotated).toBe(0)
|
||||
|
||||
// The retry is armed at the 500ms backoff delay, NOT renewBeforeMs (1000): advancing only 500
|
||||
// must fire it. A crash-loop never escapes here (the supervisor keeps running).
|
||||
timer.advance(500)
|
||||
await flush()
|
||||
expect(rotated).toBe(1)
|
||||
expect(ks.loadCert()!.certPem).toContain('NEWCERT')
|
||||
rotator.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Expired-leaf recovery (production deadlock, 2026-07): `/renew` is mTLS-authenticated by the leaf
|
||||
* it renews, so a lapsed leaf can never renew itself — and nginx will not even forward an expired
|
||||
* client cert. Recovery is therefore a PLAIN POST to `/recover` carrying the expired cert in the
|
||||
* body, plus a terminal signal once the grace window is spent so the agent stops retrying forever.
|
||||
*/
|
||||
describe('expired-leaf recovery', () => {
|
||||
const HOUR = 3_600_000
|
||||
|
||||
it('derives /recover as a sibling PATH on the same enroll host', () => {
|
||||
expect(recoveryUrlFor({ ...CFG, enrollUrl: 'https://enroll.terminal.example.com/enroll' })).toBe(
|
||||
'https://enroll.terminal.example.com/recover',
|
||||
)
|
||||
})
|
||||
|
||||
it('honours an explicit recoverUrl over the derivation', () => {
|
||||
expect(recoveryUrlFor({ ...CFG, recoverUrl: 'https://elsewhere.example.com/recover' })).toBe(
|
||||
'https://elsewhere.example.com/recover',
|
||||
)
|
||||
})
|
||||
|
||||
it('recoverCert posts the EXPIRED cert plus a CSR, with no client cert, and installs the result', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
let seenUrl = ''
|
||||
let seenBody: { cert?: string; csr?: string } = {}
|
||||
const fetchImpl = (async (u: string, init: { body: string }) => {
|
||||
seenUrl = u
|
||||
seenBody = JSON.parse(init.body)
|
||||
return jsonRes(201, { cert: 'FRESHCERT', caChain: ['FRESHCA'] })
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
const outcome = await recoverCert(
|
||||
{ ...CFG, enrollUrl: 'https://enroll.terminal.example.com/enroll' },
|
||||
ks.loadIdentity()!,
|
||||
ks,
|
||||
fetchImpl,
|
||||
)
|
||||
expect(outcome).toBe('rotated')
|
||||
expect(seenUrl).toBe('https://enroll.terminal.example.com/recover')
|
||||
expect(seenBody.cert).toBe('OLDCERT') // the lapsed leaf travels in the BODY, not the TLS layer
|
||||
expect(seenBody.csr).toBeTruthy()
|
||||
expect(ks.loadCert()!.certPem).toContain('FRESHCERT')
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('a still-valid leaf uses the mTLS /renew fetch, never the recovery one', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const timer = new FakeTimer()
|
||||
let renewCalls = 0
|
||||
let recoverCalls = 0
|
||||
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
||||
timer,
|
||||
renewBeforeMs: 1000,
|
||||
fetchImpl: (async () => {
|
||||
renewCalls += 1
|
||||
return jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })
|
||||
}) as unknown as typeof fetch,
|
||||
recoverFetchImpl: (async () => {
|
||||
recoverCalls += 1
|
||||
return jsonRes(200, {})
|
||||
}) as unknown as typeof fetch,
|
||||
now: () => new Date(0),
|
||||
parseCert: () => new Date(2000), // valid at now=0
|
||||
})
|
||||
rotator.start()
|
||||
timer.advance(1000)
|
||||
await flush()
|
||||
expect(renewCalls).toBe(1)
|
||||
expect(recoverCalls).toBe(0)
|
||||
rotator.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('an expired leaf INSIDE the grace window switches to the recovery fetch', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const timer = new FakeTimer()
|
||||
let renewCalls = 0
|
||||
let recoverCalls = 0
|
||||
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
||||
timer,
|
||||
renewBeforeMs: 1000,
|
||||
expiredGraceMs: 30 * 24 * HOUR,
|
||||
fetchImpl: (async () => {
|
||||
renewCalls += 1
|
||||
return jsonRes(200, {})
|
||||
}) as unknown as typeof fetch,
|
||||
recoverFetchImpl: (async () => {
|
||||
recoverCalls += 1
|
||||
return jsonRes(201, { cert: 'NEWCERT', caChain: ['NEWCA'] })
|
||||
}) as unknown as typeof fetch,
|
||||
now: () => new Date(8 * 24 * HOUR), // 8 days after the leaf lapsed
|
||||
parseCert: () => new Date(0),
|
||||
})
|
||||
let rotated = 0
|
||||
rotator.onRotated(() => {
|
||||
rotated += 1
|
||||
})
|
||||
rotator.start()
|
||||
timer.advance(0)
|
||||
await flush()
|
||||
expect(recoverCalls).toBe(1)
|
||||
expect(renewCalls).toBe(0)
|
||||
expect(rotated).toBe(1)
|
||||
rotator.stop()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('BEYOND the grace window it fires onExhausted, issues no request, and stops retrying', async () => {
|
||||
const { dir, ks } = enrolledKs()
|
||||
const timer = new FakeTimer()
|
||||
let requests = 0
|
||||
const countingFetch = (async () => {
|
||||
requests += 1
|
||||
return jsonRes(201, {})
|
||||
}) as unknown as typeof fetch
|
||||
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
||||
timer,
|
||||
renewBeforeMs: 1000,
|
||||
expiredGraceMs: 30 * 24 * HOUR,
|
||||
retryBackoff: createBackoff({ baseMs: 500, jitter: false }),
|
||||
fetchImpl: countingFetch,
|
||||
recoverFetchImpl: countingFetch,
|
||||
now: () => new Date(31 * 24 * HOUR), // 31 days stale ⇒ past a 30-day grace
|
||||
parseCert: () => new Date(0),
|
||||
})
|
||||
const errors: unknown[] = []
|
||||
let exhausted: CertExpiredBeyondGraceError | null = null
|
||||
rotator.onError((e) => errors.push(e))
|
||||
rotator.onExhausted((e) => {
|
||||
exhausted = e
|
||||
})
|
||||
rotator.start()
|
||||
timer.advance(0)
|
||||
await flush()
|
||||
|
||||
expect(exhausted).toBeInstanceOf(CertExpiredBeyondGraceError)
|
||||
expect(requests).toBe(0) // nothing is even attempted — it cannot succeed
|
||||
expect(errors).toHaveLength(0)
|
||||
// Terminal: no retry armed. Retrying forever is what produced 6380 identical warnings.
|
||||
timer.advance(60_000)
|
||||
await flush()
|
||||
expect(requests).toBe(0)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
128
agent/test/runTunnel.test.ts
Normal file
128
agent/test/runTunnel.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { decodeMuxFrame, encodeGoaway, encodeMuxFrame, encodeOpen, type MuxOpen } from 'relay-contracts'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import type { Keystore } from '../src/keys/keystore.js'
|
||||
import { createBackoff } from '../src/transport/backoff.js'
|
||||
import { runTunnel, type RunTunnelDeps } from '../src/transport/runTunnel.js'
|
||||
import { FakeTimer, FakeWs } from './fixtures/fakes.js'
|
||||
|
||||
/**
|
||||
* C2 supervisor: proves runTunnel ports the cafeDemo assembly into a supervised loop —
|
||||
* bytes splice both ways, a dead session reconnects, and a `revoked` GOAWAY stops for good (INV12).
|
||||
*/
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://x/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
const OPEN: MuxOpen = {
|
||||
streamId: 5,
|
||||
subdomain: 'host-42',
|
||||
requestPath: '/term?join=abc',
|
||||
originHeader: 'https://host-42.term.example.com',
|
||||
remoteAddrHash: 'x',
|
||||
capabilityTokenRef: 'jti',
|
||||
}
|
||||
const KS = {} as unknown as Keystore // unused when connectRelay/dialLoopback are injected
|
||||
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
|
||||
|
||||
function emitOpen(upstream: FakeWs, open: MuxOpen): void {
|
||||
const payload = encodeOpen(open)
|
||||
upstream.emitMessage(
|
||||
encodeMuxFrame(
|
||||
{ version: 1, type: 'open', fin: false, rst: false, streamId: open.streamId, payloadLen: payload.length },
|
||||
payload,
|
||||
),
|
||||
)
|
||||
}
|
||||
function emitGoAwayRevoked(upstream: FakeWs): void {
|
||||
const payload = encodeGoaway(0, 'revoked')
|
||||
upstream.emitMessage(
|
||||
encodeMuxFrame(
|
||||
{ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: payload.length },
|
||||
payload,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function baseDeps(over: Partial<RunTunnelDeps>): Partial<RunTunnelDeps> {
|
||||
return {
|
||||
timer: new FakeTimer(), // never auto-fires ⇒ heartbeat is inert during the test
|
||||
sleep: async () => {},
|
||||
backoff: createBackoff(),
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('runTunnel supervisor (C2)', () => {
|
||||
it('splices bytes both ways through the loopback', async () => {
|
||||
const upstream = new FakeWs()
|
||||
const loopback = new FakeWs()
|
||||
const connectRelay = vi.fn(async () => upstream)
|
||||
const dialLoopback = vi.fn(async () => loopback)
|
||||
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: dialLoopback as never }))
|
||||
await flush()
|
||||
|
||||
emitOpen(upstream, OPEN)
|
||||
await flush()
|
||||
expect(dialLoopback).toHaveBeenCalledWith('/term?join=abc', 'https://host-42.term.example.com')
|
||||
|
||||
upstream.emitMessage(encodeMuxFrame({ version: 1, type: 'data', fin: false, rst: false, streamId: 5, payloadLen: 3 }, new Uint8Array([104, 105, 10])))
|
||||
expect(loopback.sent.at(-1)).toEqual(new Uint8Array([104, 105, 10]))
|
||||
|
||||
loopback.emit('message', new Uint8Array([79, 75])) // "OK" echoes back upstream as DATA
|
||||
const last = decodeMuxFrame(upstream.sent.at(-1)!)
|
||||
expect(last.header.type).toBe('data')
|
||||
expect([...last.payload]).toEqual([79, 75])
|
||||
|
||||
await handle.stop()
|
||||
expect(await handle.done).toBe(0)
|
||||
})
|
||||
|
||||
it('reconnects after the tunnel dies', async () => {
|
||||
const sockets = [new FakeWs(), new FakeWs()]
|
||||
let i = 0
|
||||
const connectRelay = vi.fn(async () => sockets[i++]!)
|
||||
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: async () => new FakeWs() }))
|
||||
await flush()
|
||||
expect(connectRelay).toHaveBeenCalledTimes(1)
|
||||
|
||||
sockets[0]!.emit('close') // first session dies ⇒ supervisor redials
|
||||
await flush()
|
||||
expect(connectRelay).toHaveBeenCalledTimes(2)
|
||||
|
||||
await handle.stop()
|
||||
})
|
||||
|
||||
it('a revoked GOAWAY tears down and NEVER reconnects (INV12)', async () => {
|
||||
const connectRelay = vi.fn(async () => new FakeWs())
|
||||
let socket: FakeWs | undefined
|
||||
const wrapped = vi.fn(async () => {
|
||||
socket = new FakeWs()
|
||||
return socket
|
||||
})
|
||||
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay: wrapped, dialLoopback: async () => new FakeWs() }))
|
||||
await flush()
|
||||
expect(wrapped).toHaveBeenCalledTimes(1)
|
||||
|
||||
emitGoAwayRevoked(socket!)
|
||||
await flush()
|
||||
|
||||
expect(await handle.done).toBe(0)
|
||||
expect(wrapped).toHaveBeenCalledTimes(1) // no reconnect after revocation
|
||||
expect(connectRelay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stop() ends the loop with exit code 0 and does not reconnect', async () => {
|
||||
const connectRelay = vi.fn(async () => new FakeWs())
|
||||
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: async () => new FakeWs() }))
|
||||
await flush()
|
||||
|
||||
await handle.stop()
|
||||
expect(await handle.done).toBe(0)
|
||||
expect(connectRelay).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
30
agent/test/scaffold.test.ts
Normal file
30
agent/test/scaffold.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import * as contracts from 'relay-contracts'
|
||||
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(import.meta.dirname, '..', 'package.json'), 'utf8'),
|
||||
) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
|
||||
|
||||
describe('package scaffold', () => {
|
||||
it('resolves the frozen relay-contracts surface', () => {
|
||||
// §4.1 codec + §4.4 shapes + §4.5 pairing must all be importable.
|
||||
expect(typeof contracts.encodeMuxFrame).toBe('function')
|
||||
expect(typeof contracts.decodeMuxFrame).toBe('function')
|
||||
expect(typeof contracts.decodeGoAwayReason).toBe('function')
|
||||
expect(contracts.EnrollResultSchema).toBeDefined()
|
||||
})
|
||||
|
||||
it('declares web-terminal-agent bin (INDEX §4.5 distribution)', () => {
|
||||
expect(pkg.dependencies?.['relay-contracts']).toBe('file:../relay-contracts')
|
||||
})
|
||||
|
||||
it('INV11 tripwire: no ANSI/xterm/vt100 dependency', () => {
|
||||
const allDeps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }
|
||||
const forbidden = /xterm|ansi|vt100/i
|
||||
for (const name of Object.keys(allDeps)) {
|
||||
expect(forbidden.test(name), `forbidden terminal-parser dep: ${name}`).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
20
agent/test/seams.test.ts
Normal file
20
agent/test/seams.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
describe('transport seams (W0 cycle-breaker)', () => {
|
||||
it('is a type-only module with no runtime side effects or transport deps', async () => {
|
||||
const src = readFileSync(
|
||||
join(import.meta.dirname, '..', 'src', 'transport', 'seams.ts'),
|
||||
'utf8',
|
||||
)
|
||||
// No runtime imports: the seam file must not pull ws/crypto/node runtime.
|
||||
expect(src).not.toMatch(/from ['"]ws['"]/)
|
||||
expect(src).not.toMatch(/from ['"]node:crypto['"]/)
|
||||
// Importing it must not throw or emit anything.
|
||||
const mod = await import('../src/transport/seams.js')
|
||||
expect(mod).toBeDefined()
|
||||
// Interfaces are erased at runtime, so the module has no runtime exports.
|
||||
expect(Object.keys(mod)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
59
agent/test/security/tripwires.test.ts
Normal file
59
agent/test/security/tripwires.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const SRC = join(import.meta.dirname, '..', '..', 'src')
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = []
|
||||
for (const name of readdirSync(dir)) {
|
||||
const path = join(dir, name)
|
||||
if (statSync(path).isDirectory()) out.push(...walk(path))
|
||||
else if (name.endsWith('.ts')) out.push(path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const files = walk(SRC)
|
||||
function code(path: string): string {
|
||||
return readFileSync(path, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
|
||||
}
|
||||
|
||||
describe('agent security tripwires (T18, INDEX §3)', () => {
|
||||
it('INV9: no console.log anywhere in src', () => {
|
||||
for (const f of files) {
|
||||
expect(code(f), `console.log in ${f}`).not.toMatch(/console\.log/)
|
||||
}
|
||||
})
|
||||
|
||||
it('INV11: no ANSI/xterm/vt100 import anywhere in src (byte-shuttle)', () => {
|
||||
for (const f of files) {
|
||||
expect(code(f), `terminal-parser import in ${f}`).not.toMatch(/from ['"][^'"]*(xterm|ansi|vt100)[^'"]*['"]/)
|
||||
}
|
||||
})
|
||||
|
||||
it('INV2 anti-MITM: hostEndpoint imports NO verifier from relay-e2e (FIX 6b)', () => {
|
||||
const he = code(join(SRC, 'e2e', 'hostEndpoint.ts'))
|
||||
expect(he).not.toMatch(/from ['"]relay-e2e['"]/)
|
||||
expect(he).not.toMatch(/verifyDeviceAuthProof/)
|
||||
})
|
||||
|
||||
it('INV12: revocation.ts uses the frozen decoder, no bare integer reason literal', () => {
|
||||
const rev = code(join(SRC, 'lifecycle', 'revocation.ts'))
|
||||
expect(rev).toMatch(/decodeGoAwayReason/)
|
||||
expect(rev).not.toMatch(/reason\s*[=:]\s*\d/)
|
||||
expect(rev).not.toMatch(/===\s*[123]\b/)
|
||||
})
|
||||
|
||||
it('INV4: identity exposes no raw private-key byte export', () => {
|
||||
const id = code(join(SRC, 'keys', 'identity.ts'))
|
||||
expect(id).not.toMatch(/exportPrivateRaw|privateKeyBytes|rawPrivateKey/)
|
||||
})
|
||||
|
||||
it('every src module is well under the 800-line hard cap', () => {
|
||||
for (const f of files) {
|
||||
const lines = readFileSync(f, 'utf8').split('\n').length
|
||||
expect(lines, `${f} is ${lines} lines`).toBeLessThan(800)
|
||||
}
|
||||
})
|
||||
})
|
||||
140
agent/test/streamRouter.test.ts
Normal file
140
agent/test/streamRouter.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { decodeMuxFrame, encodeMuxFrame, type MuxOpen } from 'relay-contracts'
|
||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||
import { createLogger } from '../src/log/logger.js'
|
||||
import { holdTunnel, dataHeader } from '../src/transport/tunnel.js'
|
||||
import { createStreamRouter, identityTransform } from '../src/transport/streamRouter.js'
|
||||
import { FakeWs } from './fixtures/fakes.js'
|
||||
|
||||
const CFG: AgentConfig = {
|
||||
relayUrl: 'wss://relay/agent',
|
||||
enrollUrl: 'https://x/enroll',
|
||||
stateDir: '/tmp/x',
|
||||
localTargetUrl: 'ws://127.0.0.1:3000',
|
||||
subdomain: 'host-42',
|
||||
hostId: 'h-1',
|
||||
}
|
||||
|
||||
const OPEN: MuxOpen = {
|
||||
streamId: 5,
|
||||
subdomain: 'host-42',
|
||||
requestPath: '/term?join=abc',
|
||||
originHeader: 'https://host-42.term.example.com',
|
||||
remoteAddrHash: 'x',
|
||||
capabilityTokenRef: 'jti',
|
||||
}
|
||||
|
||||
const silentLogger = createLogger('error', () => {})
|
||||
const flush = () => new Promise((r) => setImmediate(r))
|
||||
|
||||
function setup(dialImpl?: (path: string, origin: string) => Promise<FakeWs>) {
|
||||
const upstream = new FakeWs()
|
||||
const tunnel = holdTunnel(upstream)
|
||||
const loopbacks: FakeWs[] = []
|
||||
const dial = vi.fn(
|
||||
dialImpl ??
|
||||
(async () => {
|
||||
const ws = new FakeWs()
|
||||
loopbacks.push(ws)
|
||||
return ws
|
||||
}),
|
||||
)
|
||||
const router = createStreamRouter(CFG, tunnel, dial as never, identityTransform, silentLogger)
|
||||
return { upstream, router, dial, loopbacks }
|
||||
}
|
||||
|
||||
function lastFrame(ws: FakeWs) {
|
||||
return decodeMuxFrame(ws.sent.at(-1)!)
|
||||
}
|
||||
|
||||
describe('StreamRouter (T8)', () => {
|
||||
it('dials loopback with the request path and replayed Origin', async () => {
|
||||
const { router, dial } = setup()
|
||||
router.handleOpen(OPEN)
|
||||
await flush()
|
||||
expect(dial).toHaveBeenCalledWith('/term?join=abc', 'https://host-42.term.example.com')
|
||||
})
|
||||
|
||||
it('INV1 defense-in-depth: foreign subdomain is RST and NEVER dialed', async () => {
|
||||
const { router, dial, upstream } = setup()
|
||||
router.handleOpen({ ...OPEN, subdomain: 'host-999' })
|
||||
await flush()
|
||||
expect(dial).not.toHaveBeenCalled()
|
||||
const f = lastFrame(upstream)
|
||||
expect(f.header.type).toBe('close')
|
||||
expect(f.header.rst).toBe(true)
|
||||
expect(router.activeStreamCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('splices loopback output → tunnel DATA', async () => {
|
||||
const { router, upstream, loopbacks } = setup()
|
||||
router.handleOpen(OPEN)
|
||||
await flush()
|
||||
const bytes = new Uint8Array([9, 8, 7])
|
||||
loopbacks[0]!.emit('message', bytes)
|
||||
const f = lastFrame(upstream)
|
||||
expect(f.header.type).toBe('data')
|
||||
expect(Buffer.from(f.payload).equals(Buffer.from(bytes))).toBe(true)
|
||||
})
|
||||
|
||||
it('forwards tunnel DATA → loopback socket (buffering pre-open)', async () => {
|
||||
const { router, loopbacks } = setup()
|
||||
router.handleOpen(OPEN)
|
||||
router.handleData(5, new Uint8Array([1, 2])) // arrives before dial resolves → buffered
|
||||
await flush()
|
||||
router.handleData(5, new Uint8Array([3, 4]))
|
||||
expect(loopbacks[0]!.sent.map((b) => [...b])).toEqual([[1, 2], [3, 4]])
|
||||
})
|
||||
|
||||
it('DATA on an unknown stream is RST (illegal transition)', () => {
|
||||
const { router, upstream } = setup()
|
||||
router.handleData(999, new Uint8Array([1]))
|
||||
const f = lastFrame(upstream)
|
||||
expect(f.header.type).toBe('close')
|
||||
expect(f.header.rst).toBe(true)
|
||||
})
|
||||
|
||||
it('two concurrent streams get separate sockets (no cross-stream bleed)', async () => {
|
||||
const { router, loopbacks } = setup()
|
||||
router.handleOpen(OPEN)
|
||||
router.handleOpen({ ...OPEN, streamId: 6 })
|
||||
await flush()
|
||||
router.handleData(5, new Uint8Array([0xaa]))
|
||||
router.handleData(6, new Uint8Array([0xbb]))
|
||||
expect(loopbacks[0]!.sent).toHaveLength(1)
|
||||
expect(loopbacks[1]!.sent).toHaveLength(1)
|
||||
expect([...loopbacks[0]!.sent[0]!]).toEqual([0xaa])
|
||||
expect([...loopbacks[1]!.sent[0]!]).toEqual([0xbb])
|
||||
expect(router.activeStreamCount()).toBe(2)
|
||||
})
|
||||
|
||||
it('CLOSE frees the loopback socket', async () => {
|
||||
const { router, loopbacks } = setup()
|
||||
router.handleOpen(OPEN)
|
||||
await flush()
|
||||
router.handleClose(5, false)
|
||||
expect(loopbacks[0]!.closed).toBe(true)
|
||||
expect(router.activeStreamCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('loopback connect-refused → RST upstream (typed, no swallow)', async () => {
|
||||
const { router, upstream } = setup(async () => {
|
||||
throw new Error('ECONNREFUSED')
|
||||
})
|
||||
router.handleOpen(OPEN)
|
||||
await flush()
|
||||
const f = lastFrame(upstream)
|
||||
expect(f.header.type).toBe('close')
|
||||
expect(f.header.rst).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('opaque splice sanity (INV11)', () => {
|
||||
it('does not import any terminal parser (identity transform)', () => {
|
||||
const bytes = new Uint8Array([0x1b, 0x5b, 0x41])
|
||||
expect(identityTransform.outbound(1, bytes)).toBe(bytes)
|
||||
// encode/decode round-trip stays byte-exact
|
||||
const framed = encodeMuxFrame(dataHeader(1, bytes.length), bytes)
|
||||
expect(Buffer.from(decodeMuxFrame(framed).payload).equals(Buffer.from(bytes))).toBe(true)
|
||||
})
|
||||
})
|
||||
162
agent/test/tunnel.test.ts
Normal file
162
agent/test/tunnel.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
decodeMuxFrame,
|
||||
encodeMuxFrame,
|
||||
encodeOpen,
|
||||
encodeGoaway,
|
||||
type MuxFrameHeader,
|
||||
type MuxOpen,
|
||||
} from 'relay-contracts'
|
||||
import { holdTunnel, dataHeader } from '../src/transport/tunnel.js'
|
||||
import { FakeWs } from './fixtures/fakes.js'
|
||||
|
||||
const OPEN: MuxOpen = {
|
||||
streamId: 5,
|
||||
subdomain: 'host-42',
|
||||
requestPath: '/term?join=abc',
|
||||
originHeader: 'https://host-42.term.example.com',
|
||||
remoteAddrHash: 'deadbeef',
|
||||
capabilityTokenRef: 'jti-1',
|
||||
}
|
||||
|
||||
function openFrame(open: MuxOpen): Uint8Array {
|
||||
const payload = encodeOpen(open)
|
||||
const header: MuxFrameHeader = {
|
||||
version: 1,
|
||||
type: 'open',
|
||||
fin: false,
|
||||
rst: false,
|
||||
streamId: open.streamId,
|
||||
payloadLen: payload.length,
|
||||
}
|
||||
return encodeMuxFrame(header, payload)
|
||||
}
|
||||
|
||||
function stubHandlers() {
|
||||
return {
|
||||
handleOpen: vi.fn(),
|
||||
handleData: vi.fn(),
|
||||
handleClose: vi.fn(),
|
||||
}
|
||||
}
|
||||
const stubHb = { onPing: vi.fn(), onPong: vi.fn() }
|
||||
|
||||
describe('Tunnel (T7, §4.1)', () => {
|
||||
it('round-trips a frame and yields decoded header+payload via onFrame', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const seen: Array<{ h: MuxFrameHeader; p: Uint8Array }> = []
|
||||
tunnel.onFrame((h, p) => seen.push({ h, p }))
|
||||
ws.emitMessage(openFrame(OPEN))
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0]!.h.type).toBe('open')
|
||||
expect(seen[0]!.h.streamId).toBe(5)
|
||||
})
|
||||
|
||||
it('dispatches OPEN/DATA to the router', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const handlers = stubHandlers()
|
||||
tunnel.dispatchTo(handlers, stubHb)
|
||||
ws.emitMessage(openFrame(OPEN))
|
||||
expect(handlers.handleOpen).toHaveBeenCalledOnce()
|
||||
const data = new Uint8Array([1, 2, 3])
|
||||
ws.emitMessage(encodeMuxFrame(dataHeader(5, 3), data))
|
||||
expect(handlers.handleData).toHaveBeenCalledWith(5, expect.any(Uint8Array))
|
||||
})
|
||||
|
||||
it('INV11: DATA payload passes through opaque (no parsing)', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const handlers = stubHandlers()
|
||||
tunnel.dispatchTo(handlers, stubHb)
|
||||
const opaque = new Uint8Array([0x1b, 0x5b, 0x33, 0x31, 0x6d]) // looks like an ANSI seq
|
||||
ws.emitMessage(encodeMuxFrame(dataHeader(7, opaque.length), opaque))
|
||||
const forwarded = handlers.handleData.mock.calls[0]![1] as Uint8Array
|
||||
expect(Buffer.from(forwarded).equals(Buffer.from(opaque))).toBe(true)
|
||||
})
|
||||
|
||||
it('drains after inbound GOAWAY: no new OPEN accepted', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const handlers = stubHandlers()
|
||||
tunnel.dispatchTo(handlers, stubHb)
|
||||
const goaway = encodeGoaway(0, 'operatorDrain')
|
||||
ws.emitMessage(
|
||||
encodeMuxFrame(
|
||||
{ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: goaway.length },
|
||||
goaway,
|
||||
),
|
||||
)
|
||||
ws.emitMessage(openFrame({ ...OPEN, streamId: 9 }))
|
||||
expect(handlers.handleOpen).not.toHaveBeenCalled()
|
||||
// and it RST'd the refused stream
|
||||
const last = decodeMuxFrame(ws.sent.at(-1)!)
|
||||
expect(last.header.type).toBe('close')
|
||||
expect(last.header.rst).toBe(true)
|
||||
})
|
||||
|
||||
it('malformed framing does not crash the tunnel', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
tunnel.dispatchTo(stubHandlers(), stubHb)
|
||||
expect(() => ws.emitMessage(new Uint8Array([0, 1, 2]))).not.toThrow()
|
||||
})
|
||||
|
||||
it('dispatches CLOSE→handleClose and PONG→heartbeat.onPong', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const handlers = stubHandlers()
|
||||
const hb = { onPing: vi.fn(), onPong: vi.fn() }
|
||||
tunnel.dispatchTo(handlers, hb)
|
||||
ws.emitMessage(
|
||||
encodeMuxFrame({ version: 1, type: 'close', fin: false, rst: true, streamId: 5, payloadLen: 0 }, new Uint8Array(0)),
|
||||
)
|
||||
expect(handlers.handleClose).toHaveBeenCalledWith(5, true)
|
||||
ws.emitMessage(
|
||||
encodeMuxFrame({ version: 1, type: 'pong', fin: false, rst: false, streamId: 0, payloadLen: 2 }, new Uint8Array([1, 2])),
|
||||
)
|
||||
expect(hb.onPong).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('send/goAway/sendRst/close emit well-formed frames', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
tunnel.send(dataHeader(3, 2), new Uint8Array([9, 9]))
|
||||
expect(decodeMuxFrame(ws.sent.at(-1)!).header.type).toBe('data')
|
||||
tunnel.goAway(3, 'shutdown')
|
||||
expect(decodeMuxFrame(ws.sent.at(-1)!).header.type).toBe('goaway')
|
||||
tunnel.sendRst(3)
|
||||
const rst = decodeMuxFrame(ws.sent.at(-1)!)
|
||||
expect(rst.header.type).toBe('close')
|
||||
expect(rst.header.rst).toBe(true)
|
||||
tunnel.close()
|
||||
expect(ws.closed).toBe(true)
|
||||
})
|
||||
|
||||
it('windowUpdate frames are dispatched without disturbing the stream handlers', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const handlers = stubHandlers()
|
||||
tunnel.dispatchTo(handlers, stubHb)
|
||||
ws.emitMessage(
|
||||
encodeMuxFrame({ version: 1, type: 'windowUpdate', fin: false, rst: false, streamId: 1, payloadLen: 4 }, new Uint8Array([0, 0, 0, 5])),
|
||||
)
|
||||
expect(handlers.handleData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onGoAway surfaces the frozen reason', () => {
|
||||
const ws = new FakeWs()
|
||||
const tunnel = holdTunnel(ws)
|
||||
const reasons: string[] = []
|
||||
tunnel.onGoAway((r) => reasons.push(r))
|
||||
const goaway = encodeGoaway(3, 'revoked')
|
||||
ws.emitMessage(
|
||||
encodeMuxFrame(
|
||||
{ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: goaway.length },
|
||||
goaway,
|
||||
),
|
||||
)
|
||||
expect(reasons).toEqual(['revoked'])
|
||||
})
|
||||
})
|
||||
23
agent/tsconfig.json
Normal file
23
agent/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"outDir": "dist",
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
13
agent/vitest.config.ts
Normal file
13
agent/vitest.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['**/*.d.ts', 'src/index.ts'],
|
||||
},
|
||||
},
|
||||
})
|
||||
45
android/.gitignore
vendored
Normal file
45
android/.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
# Gradle
|
||||
.gradle/
|
||||
build/
|
||||
**/build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!src/**/build/
|
||||
|
||||
# Gradle caches / config-cache
|
||||
.gradle/configuration-cache/
|
||||
|
||||
# IDE — IntelliJ IDEA / Android Studio
|
||||
.idea/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
captures/
|
||||
.navigation/
|
||||
|
||||
# Local machine config (never commit)
|
||||
local.properties
|
||||
|
||||
# Android build outputs (relevant once the SDK-gated modules are enabled)
|
||||
*.apk
|
||||
*.aab
|
||||
*.ap_
|
||||
*.dex
|
||||
release/
|
||||
proguard/
|
||||
|
||||
# Secrets — never commit
|
||||
google-services.json
|
||||
**/google-services.json
|
||||
*.keystore
|
||||
*.jks
|
||||
*.p12
|
||||
service-account*.json
|
||||
|
||||
# OS cruft
|
||||
.DS_Store
|
||||
|
||||
# Kover / test reports
|
||||
**/kover/
|
||||
|
||||
# Kotlin compiler session/error scratch (KGP 2.x writes this at the project root)
|
||||
.kotlin/
|
||||
263
android/DEVICE_QA_CHECKLIST.md
Normal file
263
android/DEVICE_QA_CHECKLIST.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# Android client — device-QA checklist (A36 / plan §7)
|
||||
|
||||
Everything below is **device/emulator-only**: it cannot be proven by the JVM suite. The pure logic
|
||||
underneath each item IS unit-tested (757 JVM tests across 10 modules, Kover ≥80% on the pure modules),
|
||||
but the 2026-07-30 audit established the hard lesson this file now exists to enforce — **a green JVM
|
||||
suite says nothing about whether the app works.** Three crash-on-first-keypress defects and a
|
||||
cleartext posture that made bare-LAN connection impossible all sat behind 484 passing tests, because
|
||||
no JVM test instantiates an Android `View` and no JVM test dials a socket.
|
||||
|
||||
**Rules for this file:** tick a box ONLY after observing the behaviour on real hardware (or, where
|
||||
noted, an emulator) — and say what you observed. Never tick something because the code looks right or
|
||||
a unit test covers it. Unticked is the honest default.
|
||||
|
||||
---
|
||||
|
||||
## Observed so far (the only on-device evidence that exists)
|
||||
|
||||
**2026-07-30, minified `benchmark` variant, `webterm` AVD (android-35, arm64, software GPU):**
|
||||
|
||||
- [x] The **R8-minified, resource-shrunk, non-debuggable APK installs and cold-starts.** `am start -W`
|
||||
→ `Status: ok`, `LaunchState: COLD`, `TotalTime: 1085 ms` (software-GPU emulator — not a
|
||||
meaningful performance number, only proof of a clean start). Process stayed alive;
|
||||
`logcat -b crash` empty; no `FATAL EXCEPTION`, `NoClassDefFoundError` or `ClassNotFoundException`.
|
||||
This is the check that R8 keep rules are correct — the class of bug that appears ONLY in release.
|
||||
- [x] **Cold-start route with no paired host → pairing screen**, rendered correctly under R8
|
||||
(`ColdStartPolicy`, A29): title 配对主机, the 手动输入/扫码 segmented control, the
|
||||
`主机地址(http(s)://…)` field and a disabled 下一步 button. Compose + Material 3 + the design-token
|
||||
theme + Hilt injection therefore all survive minification.
|
||||
- [x] **`POST_NOTIFICATIONS` runtime prompt appears** on first launch (API 33+ path, A30).
|
||||
- [x] **ML Kit component registration** no longer fails under R8 (`ComponentDiscovery` clean). Before
|
||||
the `ComponentRegistrar` keep rule it logged
|
||||
`NoSuchMethodException: com.google.mlkit.common.internal.CommonComponentRegistrar.<init> []`,
|
||||
which would have silently disabled **QR barcode scanning in release builds only**.
|
||||
|
||||
**Expected/benign in the same run:** `W FirebaseApp: Default FirebaseApp failed to initialize because
|
||||
no default options were found` — there is no `google-services.json` yet (see the deploy artifacts
|
||||
below). A `System UI isn't responding` dialog also appeared; that is the emulator's own `com.android.systemui`
|
||||
under software GPU, not this app (our process logged no ANR and stayed `topResumedActivity`).
|
||||
|
||||
**Everything else in this file is unobserved.** Notably: no real WebTerm host has ever been connected
|
||||
to from Android, no terminal bytes have ever been rendered on a device, and no push has ever arrived.
|
||||
|
||||
---
|
||||
|
||||
## Deploy artifacts to provide first (not built here)
|
||||
- [ ] **Release signing credentials.** `:app:assembleRelease` deliberately FAILS at packaging until they
|
||||
exist — it will never emit an unsigned APK. Provide `webterm.release.{storeFile,storePassword,keyAlias,keyPassword}`
|
||||
in `android/keystore.properties` (see `keystore.properties.example`; confirm it is gitignored
|
||||
first) or `android/local.properties`, or the `WEBTERM_RELEASE_*` env vars. Keep the keystore
|
||||
out of the repo — a lost signing key cannot be recovered.
|
||||
- [ ] `app/google-services.json` — the Firebase client config for FCM (`PushCoordinator` guards its
|
||||
absence with `runCatching`, so the app runs without it; push just won't register).
|
||||
- [ ] `google-services` Gradle plugin re-enabled once `google-services.json` exists (A13 deliberately
|
||||
omitted it — applying it without the json fails the build).
|
||||
- [ ] `https://terminal.yaojia.wang/.well-known/assetlinks.json` with the **release** signing-cert
|
||||
SHA-256 (for the verified App Link `autoVerify`). Read it with
|
||||
`keytool -list -v -keystore <ks> -alias <alias> | grep SHA256`. Server-side: run `A33`
|
||||
`src/push/fcm.ts` with the `FCM_*` env group (service-account key path + project id).
|
||||
|
||||
---
|
||||
|
||||
## FIRST PRIORITY — confirm the 2026-07-30 blocker fixes on a device
|
||||
|
||||
These were the defects that made the app unusable on hardware. All are fixed and JVM-verified; **none
|
||||
has been exercised on a device.** Until this block is ticked, nothing further in this file is worth
|
||||
running, because every one of these sits on the path to the terminal.
|
||||
|
||||
- [ ] **Typing does not crash.** Soft-keyboard keys, Enter, Backspace via the installed
|
||||
`WebTermTerminalViewClient` / `RemoteTerminalHostView` IME contract. (Was: NPE on the first
|
||||
keypress — stock `TerminalView.onKeyDown` dereferences `mClient`, then `mTermSession`.)
|
||||
- [ ] **Swipe-scrolling inside an alternate-screen TUI does not crash.** Claude Code IS an
|
||||
alternate-screen TUI, so this is ordinary use, not an edge case: drag vertically while a Claude
|
||||
session is running. Also test the fling and, on a device with a mouse/trackpad, wheel scroll
|
||||
(`onGenericMotionEvent`). (Was: `doScroll` → `handleKeyCode` → `mTermSession` NPE.)
|
||||
- [ ] **A password manager does not crash the app.** Open the app with an autofill service enabled and
|
||||
focus the terminal. (Was: unguarded `autofill()` while the view advertised itself autofillable;
|
||||
the subtree is now excluded from the autofill structure.)
|
||||
- [ ] **`resize` actually reaches the PTY.** A full-screen TUI fills the screen instead of rendering
|
||||
into an 80x24 box, and reflows on rotation. (Was: `updateSize` had zero call sites.) Confirm the
|
||||
cols×rows the server reports match what `TerminalResizeDriver` computed from
|
||||
`TerminalRenderer.getFontWidth()/getFontLineSpacing()`.
|
||||
- [ ] **Bare-LAN `ws://` connects.** Pair with `http://<lan-ip>:3000` and attach. (Was:
|
||||
`network_security_config.xml` was a stub denying all cleartext while `HostEndpoint` derives
|
||||
`ws://` from `http://`.)
|
||||
- [ ] **The tunnel domain is still TLS-only.** `http://<anything>.terminal.yaojia.wang` must be refused
|
||||
(the one hostname with `cleartextTrafficPermitted="false"`), and `OkHttpClientFactory` must
|
||||
refuse a plaintext dial to a non-private host even if the config would allow it.
|
||||
- [ ] **§6.4 device-switch reclaim.** Attach the same session from a phone and a tablet; the device you
|
||||
last touched drives the PTY size (latest-writer-wins) and reclaims full screen on
|
||||
pane-show/window-focus without a re-attach.
|
||||
- [ ] **`WEBTERM_TOKEN` gate end-to-end** against a server started WITH the token: REST + the WS
|
||||
upgrade both carry the cookie, it survives a process restart (sealed with Tink AEAD under an
|
||||
AndroidKeyStore key), and a **seal failure persists nothing** rather than falling back to
|
||||
plaintext. Also verify the cookie never appears in `logcat`.
|
||||
⚠️ **Blocked until the cookie jar + cipher are installed in `:app`'s DI** — until then the gate
|
||||
is inert at runtime no matter what the server does.
|
||||
- [ ] **Newly-wired surfaces that had ZERO call sites before this session** — each needs a first-ever
|
||||
look: session-list **preview thumbnails** (`ThumbnailPipeline`), **quick-reply chips + palette**
|
||||
(`QuickReply`), and the tablet **pointer context menu** (`PointerContextMenu`).
|
||||
- [ ] **Newly-decoding server frames.** The `queue` frame ("N queued" badge) and `status.preview` were
|
||||
both being dropped as undecodable — the latter meant remote approvals were **blind**. Confirm a
|
||||
real gate now shows the command being approved, and that a malformed preview does not cost the
|
||||
whole frame (the gate must still appear).
|
||||
|
||||
---
|
||||
|
||||
## KNOWN FEATURE GAP — copy-out via text selection is unavailable (deliberate)
|
||||
|
||||
**Not a bug to be fixed by re-enabling stock selection.** At the pinned Termux v0.118.0, the stock
|
||||
`ActionMode`'s own handlers dereference the `TerminalSession` this app deliberately does not have
|
||||
(`TextSelectionCursorController$1.onActionItemClicked`, offsets 89-100 for Copy and 126-136 for Paste).
|
||||
`TerminalSession` is `final` and its constructor forks a local process over JNI — exactly what a remote
|
||||
terminal must not do (plan §6.1). So making the selection UI reachable would put a guaranteed NPE on
|
||||
the **Copy** button, which is worse than not offering it. Long press is therefore consumed and
|
||||
selection never starts.
|
||||
|
||||
**The real fix** (not built, tracked): a first-party selection layer — hit-test to a cell, own the
|
||||
selection anchors, read the text straight out of `TerminalBuffer`, and write it with `ClipboardManager`.
|
||||
Paste-in is unaffected and already works.
|
||||
|
||||
- [ ] Confirm on a device that long press is inert and does **not** show a broken Copy affordance.
|
||||
|
||||
---
|
||||
|
||||
## A34 — instrumented E2E vs a real `npm start` host
|
||||
|
||||
Infrastructure now EXISTS (`:app` has androidTest deps, a Hilt-aware `testInstrumentationRunner`, and
|
||||
Espresso + Compose-UI-test on the classpath) but **the tests themselves are not written**. See
|
||||
`android/README.md` → "Instrumented tests" for the one runner class that must be authored first.
|
||||
|
||||
- [ ] `attach → attached → output` round-trip timing.
|
||||
- [ ] reconnect replays the ring buffer (F5/F6); no dropped bytes on the multi-MB replay.
|
||||
- [ ] spawn-failure → `exit(-1)` shows the spawn-failure banner copy.
|
||||
- [ ] kill via `DELETE /live-sessions/:id` (swipe-to-kill) removes the row (404 = already-gone = success).
|
||||
- [ ] `POST /hook/decision` resolves a held gate (from a push Allow/Deny).
|
||||
- [ ] **bad Origin rejected** (F9) — the one non-skippable CSWSH defence; a foreign Origin 401s the WS.
|
||||
|
||||
## A35 — one macrobenchmark / Espresso happy path
|
||||
|
||||
The `:macrobenchmark` module exists and assembles (`com.android.test`, instrumenting `:app`'s
|
||||
`benchmark` variant out-of-process); it has **no sources yet**.
|
||||
|
||||
- [ ] pair → attach → type → approve, on a real device.
|
||||
- [ ] cold-start timing on real hardware (`StartupTimingMetric`). The 1085 ms above is a software-GPU
|
||||
emulator number and must not be quoted as a baseline.
|
||||
|
||||
## Terminal (A16/A17/A21)
|
||||
- [ ] glyph rendering + 24-bit true-color + cursor against a live Claude Code TUI (decide the §6.8
|
||||
WebView fallback ONLY if fidelity diverges — not expected).
|
||||
- [ ] IME/CJK composition; http/https link tap. (Selection→clipboard is the gap above, not a test.)
|
||||
- [ ] **real font-metric → grid resize** and resize parity vs web/iOS on the same device sizes (R5).
|
||||
- [ ] **config-change (rotation/fold) re-binds the surviving emulator** with no blank + no replay
|
||||
round-trip (scrollback CONTENT survives; scroll OFFSET resets — accepted); real-background →
|
||||
generation bump → fresh emulator replays.
|
||||
- [ ] key-bar above the IME (soft keyboard never pops on a key-bar tap); hardware chords; DECCKM arrows
|
||||
emit `ESC O A` under app-cursor mode (vim/htop).
|
||||
- [ ] `FLAG_SECURE` + privacy cover blanks the recents thumbnail on `ON_STOP`.
|
||||
- [ ] off-main chunked append does not ANR on a multi-MB replay.
|
||||
|
||||
## Push (A30/A31 · R1/S2)
|
||||
- [ ] **S2 spike:** data-only high-priority delivery latency/loss on ≥2 real handsets (incl. one
|
||||
Xiaomi/Huawei/Samsung) under Doze / OEM battery managers / force-stop — quantify loss, drive the
|
||||
"delivery not guaranteed while backgrounded" user-facing copy.
|
||||
- [ ] **Deny** from the lock screen (BroadcastReceiver, no app open, expedited POST).
|
||||
- [ ] **Allow** → translucent `excludeFromRecents` trampoline hosts `BiometricPrompt` → POST on auth
|
||||
success; cancel/error/no-enrolled-auth → no POST (fail-safe).
|
||||
- [ ] single-use token: a retried-after-success decision POST returns 403 (idempotency).
|
||||
- [ ] token registers to every paired host + self-heals on rotation / new host / removal.
|
||||
- [ ] **In a MINIFIED build specifically** — FCM and ML Kit both discover components reflectively, so
|
||||
re-verify push registration and QR scanning on the `benchmark`/release variant, not just debug.
|
||||
|
||||
## Pairing / cert / storage (A19/A27/A11/A12)
|
||||
- [ ] CameraX QR scan + ML-Kit decode; CAMERA-denied → manual URL entry. **Run this on a minified
|
||||
variant too** (see the `ComponentRegistrar` note above — this path is the one R8 already broke once).
|
||||
- [ ] §5.4 warning tiers on real hosts; tunnel host cert-gate refuses without a device cert (retry can't
|
||||
bypass); public host explicit-ack.
|
||||
- [ ] SAF `.p12` import → **real AndroidKeyStore** (non-exportable) + Tink AEAD; bad passphrase keeps the
|
||||
prior identity (validate-before-persist); rotate presents the new cert on the **next handshake with
|
||||
no relaunch** (re-reading `X509KeyManager` + `connectionPool.evictAll()`); remove.
|
||||
- [ ] **Cert summary does not crash** — see the `javax.naming` defect under "Known gaps"; the issuer-CN /
|
||||
expiry summary is expected to throw `NoClassDefFoundError` on-device TODAY.
|
||||
- [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited.
|
||||
|
||||
## Access token — `WEBTERM_TOKEN` (B5 / ios-completion §1.1) — needs a token-enabled host
|
||||
Run the host as `WEBTERM_TOKEN=<16–512 chars> npm start`. The pure logic is JVM-tested (`AuthProbeTest`,
|
||||
`AccessTokenCookieTest`, `PairingProbeTokenTest`, `OkHttpAccessTokenTest`, `SessionEngineUnauthorizedTest`,
|
||||
`InMemoryAccessTokenStoreTest`); everything below needs real hardware + a real server.
|
||||
- [ ] `TinkAccessTokenStore` instrumented suite on a device:
|
||||
`./gradlew :host-registry:connectedDebugAndroidTest` (real AndroidKeyStore + Tink).
|
||||
- [ ] Pair a token-enabled host with the token typed → paired; the terminal attaches (the **WS upgrade**
|
||||
carries `Cookie: webterm_auth=…`), the session list, projects, diff and thumbnails all load.
|
||||
- [ ] Pair the same host with **no** token typed → the probe 401s → the confirm step comes back asking for
|
||||
the token ("该主机启用了访问令牌"); typing it and retrying pairs.
|
||||
- [ ] Wrong token → "访问令牌不正确" **under the field** (still on the confirm step, nothing stored).
|
||||
- [ ] 11 wrong tries within a minute → "尝试过多" (the server's 10/min/IP `/auth` limiter).
|
||||
- [ ] Pair a host with `WEBTERM_TOKEN` **unset** while typing a token → pairs, and NOTHING is stored
|
||||
(204-without-`Set-Cookie`); the app must not claim to be "authenticated".
|
||||
- [ ] Restart the app → the terminal re-attaches with no re-prompt (the token decrypts from the
|
||||
Keystore-backed store on a cold start).
|
||||
- [ ] Rotate `WEBTERM_TOKEN` on the host and restart it → the WS upgrade 401s → the terminal banner shows
|
||||
the 401 copy, offers **no** "新会话", and there is **no reconnect back-off storm** (verify with
|
||||
`adb logcat` / the server log: exactly one upgrade attempt).
|
||||
- [ ] `adb logcat` during all of the above: the token string appears **nowhere**; no request URL contains
|
||||
it (check the server's access log too).
|
||||
- [ ] `adb backup` / device-to-device transfer excludes the token blob (`allowBackup=false` +
|
||||
`data_extraction_rules.xml`).
|
||||
|
||||
## Nav / deep links / adaptive (A32/A26/A29)
|
||||
- [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the
|
||||
right destination; invalid UUID ignored. (The App Link half needs the release-signed APK and
|
||||
`assetlinks.json`.)
|
||||
- [ ] continue-last-session banner re-opens. (The no-host → pairing half is observed above.)
|
||||
- [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` +
|
||||
`ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600).
|
||||
|
||||
## Projects / git parity (W5 — presenters JVM-tested, Compose device-QA)
|
||||
- [ ] Project card **sync chip**: `↑ahead` / `↓behind` render only when non-zero; no chip when there is
|
||||
no upstream (fields absent).
|
||||
- [ ] Project detail **PR chip**: `availability=ok` → tappable chip opens the PR in the browser ONLY when
|
||||
the url is https (a non-https / junk url is inert, non-clickable); `no-pr` / `not-installed` /
|
||||
`unauthenticated` / `disabled` / `error` each render the degraded copy inertly; check-count colour
|
||||
(fail=red / pending=amber / pass=green).
|
||||
- [ ] Project detail **recent commits**: list renders short-hash + subject inertly; unavailable state on a
|
||||
log failure does NOT hide the rest of the detail (failure-isolated).
|
||||
- [ ] **New worktree** inline form: valid `branch` (+optional `base`) → create → list refreshes; an invalid
|
||||
branch name is rejected with NO network call; a disabled-403 shows the server's safe message.
|
||||
- [ ] Per-worktree **remove**: the button is absent on the `main` worktree; the confirm dialog offers a
|
||||
**Force** checkbox; a dirty-worktree 409 surfaces "force required" inertly; **prune** button works.
|
||||
- [ ] Diff **base-rev** input: entering a rev enters base mode (Working/Staged toggle hidden, `vs <rev>`
|
||||
shown, git-write controls hidden); Clear returns to working/staged; junk rev → server 400 surfaced.
|
||||
- [ ] Diff **stage/unstage**: per-file button (Working→"暂存", Staged→"取消暂存") posts the file and
|
||||
refreshes; **commit** field + button (empty message rejected client-side; Ok shows the short sha) ;
|
||||
**push** button (Ok shows branch→remote; 409 shows the inert server message; 429 shows rate-limited).
|
||||
|
||||
---
|
||||
|
||||
## Known gaps (tracked, non-blocking for QA but must be routed to an owner)
|
||||
|
||||
- [ ] **`:client-tls` uses `javax.naming.ldap.LdapName`, which does not exist on Android.** Found by R8
|
||||
(`Missing class javax.naming.ldap.LdapName ... referenced from CertificateSummaryReader.commonName`).
|
||||
On-device that call throws `NoClassDefFoundError`, an **`Error`** — so it slips straight through the
|
||||
function's `catch (_: Exception)` guard and propagates out of `summarize()`. The module is pure-JVM
|
||||
and its unit tests run on a JDK where `LdapName` DOES exist, which is exactly why the green suite
|
||||
never caught it. Effect: the device-cert screen's issuer-CN/expiry summary (A27) crashes.
|
||||
Fix: hand-parse the RFC2253 DN (or at minimum catch `Throwable`). The `-dontwarn` in
|
||||
`proguard-rules.pro` only unblocks R8; it changes nothing at runtime.
|
||||
- [ ] **The auth cookie jar + cipher are not installed in `:app`'s DI**, so the `WEBTERM_TOKEN` gate is
|
||||
inert at runtime even though every piece is implemented and tested.
|
||||
- [ ] push body-tap opens the app (not yet the specific gate — the notification `openAppIntent` doesn't
|
||||
carry the sessionId; the gate is still visible in the terminal). MEDIUM.
|
||||
- [ ] no "view diff" affordance from ProjectDetail → the `DiffScreen` nav destination has no inbound link.
|
||||
- [ ] no host-remove UI action (so `PushRegistrar.unregisterHost` isn't invoked).
|
||||
- [ ] **Device-to-device transfer** is not covered by `allowBackup=false`; it needs a
|
||||
`dataExtractionRules` `<device-transfer>` xml resource. Until then a D2D migration could copy the
|
||||
sealed auth cookie to another handset.
|
||||
- [ ] AGP's lint crashes with `FirDeclaration was not found for class KtProperty, fir is null` on
|
||||
`ThumbnailPipeline.kt` when run under `--rerun-tasks`. `lintVitalRelease` passes normally and from
|
||||
a cold lint state; this is a lint/K2 internal bug (lint says so itself), not an app defect. Do not
|
||||
use `--rerun-tasks` in a release gate.
|
||||
|
||||
> RESOLVED since the last revision: the A21 reflection shim (`getMethod("getTerminalView")`) is **gone** —
|
||||
> `RemoteTerminalHostView` now holds the stock `com.termux.view.TerminalView` directly, so there is no
|
||||
> reflective getter left to break under R8 and no keep rule is needed for one.
|
||||
509
android/PROGRESS_ANDROID.md
Normal file
509
android/PROGRESS_ANDROID.md
Normal file
@@ -0,0 +1,509 @@
|
||||
# Android Client — Progress (this-session working log)
|
||||
|
||||
> **Why this file exists (temporary):** `docs/PROGRESS_LOG.md` (the canonical cross-session
|
||||
> memory) was being **concurrently edited by another live session** (the tunnel-automation
|
||||
> workstream on branch `feat/tunnel-automation`) while this Android work ran on the SAME working
|
||||
> tree. To avoid a read-modify-write clobber of that session's log, the Android orchestrator
|
||||
> records progress here instead. **FOLD THESE ENTRIES INTO `docs/PROGRESS_LOG.md` once the
|
||||
> concurrent session is done** (they belong under the `🤖 ANDROID CLIENT` section).
|
||||
>
|
||||
> **Git status note:** nothing below is committed. The Android lane (`android/**`,
|
||||
> `src/push/fcm.ts`, `test/push-fcm.test.ts`, `src/server.ts` FCM wiring, `package.json`
|
||||
> google-auth-library) is file-disjoint from the tunnel work, but the shared `.git`/index means
|
||||
> **no `git add -A`** — commit the Android lane by explicit path once branch strategy is settled.
|
||||
|
||||
Plan: [`../docs/ANDROID_CLIENT_PLAN.md`](../docs/ANDROID_CLIENT_PLAN.md). 36 tasks, waves AW0–AW6.
|
||||
Prior state (commit `4ea8f78`/`4fe1981`): AW0 + AW1 pure-Kotlin foundation (218 tests) + Android
|
||||
SDK/AGP 9.2.1 toolchain proven.
|
||||
|
||||
---
|
||||
|
||||
## [x] Wave R1 — A7 + A14 + A33 (DONE, orchestrator-verified 2026-07-08)
|
||||
|
||||
One implementation Workflow (3 TDD builders in parallel) → 6-agent adversarial cross-review (2
|
||||
independent lenses/task) → fix Workflow (must-fixes + regression tests) → adversarial re-verify →
|
||||
**orchestrator independent unified gate** (not just agent self-report).
|
||||
|
||||
**Unified verification (measured):**
|
||||
- `cd android && ./gradlew test` → **BUILD SUCCESSFUL, 250 tests, 0 failures** (wire-protocol 47 /
|
||||
session-core 75 / api-client 69 / client-tls 27 / test-support 15 / **transport-okhttp 17**).
|
||||
- `npx vitest run test/` → **54 files, 1533 tests, 0 failures** (incl. new `test/push-fcm.test.ts`
|
||||
60 tests; no regression of the existing server suite).
|
||||
- `npm run typecheck` (tsc main + web) → clean.
|
||||
|
||||
### [x] A7 `:transport-okhttp` — OkHttp WS + REST transport (pure JVM; MockWebServer-tested)
|
||||
- Files: `transport-okhttp/src/main/.../transport/{OkHttpClientFactory, OkHttpTermTransport,
|
||||
OkHttpWebSocketConnection, OkHttpHttpTransport}.kt` + 3 test files. Implements frozen
|
||||
`TermTransport`/`PingableTermTransport`/`HttpTransport` verbatim (contracts unmodified).
|
||||
- WS: `connect()` stamps `Origin = endpoint.originHeader` on the upgrade (CSWSH; asserted
|
||||
byte-equal); `frames` = `channelFlow` draining an unlimited listener mailbox — clean close →
|
||||
normal completion, `onFailure` → error (distinguishable); `send`→`webSocket.send`,
|
||||
`close`→`close(1000)` (**detach, never kill**). REST: non-2xx RETURNED, transport-failure THROWN,
|
||||
headers verbatim (no self-added Origin). Shared `OkHttpClient` `.cache(null)` (§8) + default
|
||||
system trust; mTLS via a LOCAL `ClientIdentityProvider` fun-interface seam (module depends only
|
||||
on `:wire-protocol`, no `:client-tls` edge).
|
||||
- **Cross-review fixes (regression-tested):** (1) HIGH — handshake socket leak on mid-dial cancel:
|
||||
`openConnection` now tears down the just-created WS on any throwable (incl. `CancellationException`)
|
||||
via an idempotent `cancel()` (`webSocket.cancel()`+`finish(null)`) before rethrowing — proven
|
||||
red→green by revert. (2) HIGH — the redundant transport-level 16 MiB frame cap
|
||||
(`TransportFrameTooLargeException`) was **deleted**: it was unreachable from `:session-core` (which
|
||||
may depend only on `:wire-protocol`), and `SessionEngine` already self-measures frames and emits
|
||||
`REPLAY_TOO_LARGE` — the single authoritative classifier. (3) MEDIUM — pump rethrows
|
||||
`CancellationException` (defensive; kept as a contract lock).
|
||||
|
||||
### [x] A14 `SessionEngine` — pure lifecycle state machine (`:session-core`, runTest virtual time)
|
||||
- Files: `session-core/src/main/.../session/SessionEngine.kt` + `SessionEngineTest.kt` (15 tests).
|
||||
Composes ReconnectMachine/PingScheduler/GateTracker/AwayDigest over an injected `TermTransport` +
|
||||
dispatcher/TimeSource; NO OkHttp/Android imports.
|
||||
- Verified: attach-first ordering, adopt-server-id, connect-now-on-foreground, **close≠kill**,
|
||||
oversized-replay→terminal `REPLAY_TOO_LARGE`, gate-decision epoch drop, backoff ladder
|
||||
1→2→4→8→16→30, ping 25s/2-miss — all under virtual time via the fakes.
|
||||
- **Cross-review fixes (regression-tested):** (1) HIGH (found independently by BOTH reviewers) —
|
||||
`close()` during the dial/attach window failed to detach the freshly-opened connection: engine now
|
||||
re-checks the `closed` flag after dial and after attach, closing + returning terminal with no
|
||||
further emits (2 new tests: close-during-dial, close-during-attach). (4) MEDIUM — gate double-send:
|
||||
`decideGate()` now locally retires the held gate after a successful send so a second same-epoch tap
|
||||
is dropped (new test). (2) MEDIUM — `started` flip moved onto the confined dispatcher (invariant
|
||||
#4). (3) MEDIUM — suspend-call `runCatching` replaced with cancel-rethrowing `ignoringNonCancel`.
|
||||
- **KNOWN follow-up routed to A15 (AW2):** the engine does not close its live WS when its *scope* is
|
||||
cancelled (only on explicit `close()`). Intended teardown is explicit `engine.close()` (per §6.6),
|
||||
so **the `RetainedSessionHolder` MUST call `engine.close()` before cancelling the engine scope**;
|
||||
additionally consider a `finally`-close in the engine for defense-in-depth. Non-blocking for R1
|
||||
(server PTY survives either way; only affects prompt-vs-TCP-timeout detach).
|
||||
|
||||
### [x] A33 server FCM — the ONE additive server change (§4.5)
|
||||
- Files: NEW `src/push/fcm.ts` (`NotifyService` impl behind a seam) + `POST/DELETE /push/fcm-token`
|
||||
route + `initFcm`/`normalizeFcmToken` wiring in `src/server.ts` (combined via
|
||||
`combineNotifyServices` beside web-push/APNs — zero new event paths) + `FCM_*` env
|
||||
(all-or-disabled) + `test/push-fcm.test.ts` (60 tests). Added `google-auth-library@^10` to
|
||||
`package.json` deps (R7 decision).
|
||||
- Data-only high-priority messages; payload minimized to `sessionId`/`cls`/`token` (no
|
||||
notification block, no cwd/command); loose FCM-token validator; token/key never logged; disabled
|
||||
cleanly when `FCM_*` incomplete.
|
||||
- **Cross-review fix (regression-tested):** HIGH — removed `validateStatus:()=>true`, which had
|
||||
silently defeated google-auth-library's built-in 401/403 refresh-and-retry (the whole reason R7
|
||||
chose the lib). Now wraps `client.request` in try/catch, reading `{status,body}` from
|
||||
`GaxiosError.response`; a 401 goes through the lib's refresh before surfacing; a no-response
|
||||
transport error is a send failure (token never falsely pruned). 3 new tests (401→refresh→200,
|
||||
404/UNREGISTERED→prune, no-response→keep).
|
||||
|
||||
**Not run here (deferred per plan §7):** instrumented/device tests (no emulator installed) and the
|
||||
S2 FCM real-handset delivery spike (needs ≥2 physical devices under Doze/OEM battery managers).
|
||||
|
||||
---
|
||||
|
||||
## [~] Wave R2 — A13 (done) · A11 + A12 (building) · S1 → moved to head of AW3
|
||||
|
||||
### [x] A13 `:app` Compose baseline (DONE, orchestrator-verified 2026-07-08)
|
||||
First real full Android app module — establishes the UI-stack version matrix every later Android
|
||||
task reuses. Workflow: TDD builder → kotlin + design cross-review.
|
||||
- Files: `app/build.gradle.kts`, `AndroidManifest.xml`, `WebTermApp.kt` (@HiltAndroidApp),
|
||||
`MainActivity.kt` (@AndroidEntryPoint), `di/AppModule.kt` (minimal Hilt), `designsystem/{DesignSpec,
|
||||
Tokens,Typography,Theme,Primitives}.kt`, `res/{values,xml}/*`, `DesignSpecTest.kt` (5 JVM tests).
|
||||
- **Version matrix PROVEN** (`:app:assembleDebug` → 32.7 MB APK, `:app:testDebugUnitTest` green):
|
||||
AGP 9.2.1 · Kotlin 2.3.21 · compose-compiler plugin 2.3.21 · Compose BOM 2025.11.01 (material3
|
||||
1.4.0, ui 1.9.5, material3.adaptive 1.2.0) · Hilt 2.60.1 via KSP 2.3.9 · **compileSdk 36**
|
||||
(installed `platforms;android-36`; SDK-37 unavailable — cmdline-tools too old), minSdk 29,
|
||||
targetSdk 35. Design system mirrors iOS DS 1:1 (amber-gold #E3A64A/#C9892F, semantic status
|
||||
colors, 8pt scale, tabular mono numerals, dark-first, fixed terminal-canvas #100F0D/#ECE9E3/gold).
|
||||
- **Cross-review fix (orchestrator applied + re-verified assemble green):** both reviewers flagged
|
||||
the primitives (StatusBadge/TelemetryChip/WebTermCard) hardcoding `WebTermColors.dark.*` → would
|
||||
render wrong in light theme. Routed through `MaterialTheme.colorScheme.{onSurfaceVariant,
|
||||
surfaceVariant,outline}` (Theme.kt already maps those). Also fixed preview `.dp` literals →
|
||||
`Spacing.*`. Docs synced: `android/README.md` + `settings.gradle.kts` recipe + `[[android-build-env]]`
|
||||
memory now say compileSdk 36 / android-36.
|
||||
- Deferred (per §7): instrumented/Compose-UI tests (no emulator). Follow-up for AW3: add a
|
||||
`Motion.gated()` helper (reduce-motion) to Tokens.kt when the first animation lands (LocalReduceMotion
|
||||
+ DesignSpec.MOTION_* already present); register a ContentObserver on ANIMATOR_DURATION_SCALE then.
|
||||
|
||||
### [x] A11 `:client-tls-android` — ClientTLS framework half (DONE, orchestrator-verified)
|
||||
Catalog pre-staged (tink 1.15.0, datastore-preferences 1.1.1, androidx-test); enabled in settings.
|
||||
Build workflow → security+kotlin cross-review → fix workflow → adversarial security re-verify →
|
||||
orchestrator applied 3 more fixes the re-verify caught + independent compile gate.
|
||||
- Files: `client-tls-android/src/main/.../tlsandroid/{AndroidKeyStoreImporter, TinkCertStore(+CertStore
|
||||
iface), ReReadingX509KeyManager, IdentityRepository(+ClientSslMaterial), StoredIdentityMetadata}.kt`
|
||||
+ androidTest `{Fixtures, AndroidKeyStoreImporterTest, IdentityRepositoryTest}.kt`.
|
||||
- ONE key home = AndroidKeyStore (non-exportable, per-handshake re-reading `X509KeyManager`); Tink AEAD
|
||||
encrypts ONLY the cert-chain+metadata blob; NO `.p12`/passphrase persisted; validate-before-persist;
|
||||
`connectionPool.evictAll()` on rotate/remove. Exposes `IdentityRepository`/`ClientSslMaterial` as the
|
||||
seam A15 bridges to `:transport-okhttp`'s `ClientIdentityProvider` (NO `:transport-okhttp` dep).
|
||||
- **Security must-fix (rotation safety) — resolved via single-commit + adversarial follow-through:**
|
||||
the re-verify caught the fixer's first attempt still lost the prior identity. Final design:
|
||||
**ping-pong staging slot → one atomic durable `SharedPreferences.commit()` live-pointer flip**
|
||||
(`StoredIdentityMetadata.keyStoreAlias`). Any failure before the flip → prior identity fully live;
|
||||
after → new identity fully live; stores never diverge. Orchestrator then fixed 3 residual bugs the
|
||||
security re-verify empirically proved: **(1 HIGH)** `currentLive()` used `liveOverride?.value ?:
|
||||
initialIdentity` which collapsed `Box(null)` (explicitly removed) into the stale cached identity →
|
||||
a *removed* cert stayed "live" and could be presented with a dangling key; now resolves via Box
|
||||
presence. **(3)** `TinkCertStore.save()/clear()` switched `apply()`→`commit()` (durable + throws on
|
||||
failure) so a crash-window can't leave the pointer naming a just-deleted key (both-identities-lost).
|
||||
**(2)** widened staging-key cleanup to cover the key-readback/encode steps. Added instrumented
|
||||
regression `remove_afterStartupTouch_reportsNoIdentity_notStaleCached`.
|
||||
- Verified (orchestrator): `./gradlew :client-tls-android:assembleDebug :client-tls-android:compileDebugAndroidTestKotlin`
|
||||
→ BUILD SUCCESSFUL (main + instrumented compile). All AndroidKeyStore/Tink tests run on device (§7).
|
||||
|
||||
### [x] A12 `:host-registry` — Host/HostStore + DataStore + LastSessionStore (DONE, both reviewers approved)
|
||||
- Files: `host-registry/src/main/.../hostregistry/{Host, HostStore(+immutable transforms),
|
||||
InMemoryHostStore, DataStoreHostStore, LastSessionStore, DataStoreLastSessionStore, HostCodec}.kt`
|
||||
+ tests (JVM unit: HostStoreTransforms/InMemoryHostStore/HostCodec/InMemoryLastSessionStore) + androidTest.
|
||||
- Immutable HostStore (upsert/remove return new lists, position-preserved, unknown-id no-op);
|
||||
DataStore read-modify-write; HostEndpoint re-validated on load; LastSessionStore set-on-adopted /
|
||||
clear-on-exited. **Orchestrator fix:** `DataStoreLastSessionStore` now guards the persisted id with
|
||||
the frozen v4 `Validation.isValidSessionId` (was `UUID.fromString` format-only).
|
||||
- Verified (orchestrator): `./gradlew :host-registry:assembleDebug :host-registry:testDebugUnitTest`
|
||||
→ BUILD SUCCESSFUL, **17 JVM unit tests green**; DataStore-backed tests are androidTest (device QA).
|
||||
|
||||
### S1 renderer spike — relocated to the head of AW3 (it gates A16, the XL renderer task).
|
||||
|
||||
**Wave R2 complete.** Android modules now: 6 pure-JVM (250 tests) + `:app` + `:host-registry` +
|
||||
`:client-tls-android` (framework, assemble+unit-verified here; instrumented deferred to device QA).
|
||||
|
||||
---
|
||||
|
||||
## [x] Wave AW2 — A15 `:app` wiring + DI FREEZE (DONE, orchestrator-verified 2026-07-09)
|
||||
|
||||
The convergence/contract task that gates all of AW4. Build → arch+coroutine cross-review (BOTH blocked)
|
||||
→ fix (6 must-fixes) → arch+coroutine re-verify (caught 1 more real bug) → residual fix → orchestrator
|
||||
added the final test lock + independent gate. `./gradlew :app:assembleDebug :app:testDebugUnitTest
|
||||
:session-core:test` all green (EventBus 6 tests, RetainedSessionHolder 4, DesignSpec 5, SessionEngine 15).
|
||||
|
||||
- Files: `app/src/main/.../wiring/{EventBus, TerminalSessionController, RetainedSessionHolder,
|
||||
AppEnvironment, ColdStartPolicy, ApiClientFactory, SessionEngineFactory}.kt` + `di/{NetworkModule,
|
||||
TlsModule, StorageModule, SessionModule}.kt` (replaced A13's placeholder AppModule) + tests.
|
||||
- **Frozen contracts (AW4 depends on these):** per-session `EventBus` (NOT @Singleton) with two typed
|
||||
sub-streams `outputBytes(): Flow<ByteArray>` (Output-only, UTF-8 decode off-Main via flowOn) +
|
||||
`controlEvents(): Flow<SessionEvent>` (non-Output); `TerminalSessionController` (start() decoupled
|
||||
from bind(), eager mailbox registration); `RetainedSessionHolder` (@HiltViewModel, config-survival,
|
||||
single-session-for-life BoundKey guard, close-join-before-cancel teardown); `AppEnvironment.warmUp()`
|
||||
(off-Main first identity touch); `ColdStartPolicy`; factories for per-host ApiClient + per-session
|
||||
SessionEngine. mTLS bridge: A11 `IdentityRepository` → A7 `ClientIdentityProvider` → ONE shared
|
||||
`OkHttpClient` (.cache(null), WS+REST) — construction cycle broken via a shared `ConnectionPool`.
|
||||
- **Coordination change (authorized):** `SessionEngine.close()` now returns its `Job` so teardown can
|
||||
`join()` the clean detach BEFORE cancelling the confinement scope (structural close-before-cancel).
|
||||
- **Cross-review caught + fixed:** (HIGH) EventBus was @Singleton → cross-session event bleed → now
|
||||
per-session; (HIGH) bind()-started-engine-before-UI-subscribe → lost `attached`+replay → decoupled
|
||||
start() + eager registration; (HIGH) eager DI did AndroidKeyStore/Tink I/O on Main → dagger.Lazy +
|
||||
warmUp() on IO; (HIGH, re-verify) `register()`'s finally-close made the reused controller's output/
|
||||
events flows DEAD after one config-change (blank terminal on rotation) → mailbox now lives for the
|
||||
session (receiveAsFlow consume=false), released only by `EventBus.close()` at teardown; + typed
|
||||
sub-streams, structural close, bind mis-route guard, @Volatile started. R10 (per-consumer UNLIMITED
|
||||
channels: slow consumer neither stalls nor drops) unit-tested; confinement invariant #4 intact.
|
||||
|
||||
## [~] Wave AW3 — A16 `:terminal-view` DONE · A17 KeyBar + A18 ThumbnailPipeline building
|
||||
|
||||
### [x] S1 + A16 `:terminal-view` (XL) — the renderer, DONE + orchestrator-verified 2026-07-09
|
||||
**S1 gate PASSED headless** (no device): a Termux `TerminalEmulator` (no JNI/subprocess) fed canned WS
|
||||
bytes renders into a readable `TerminalBuffer` ("hello", cursorCol 5); DSR reply routes out via
|
||||
`TerminalOutput.write`→engineSend; DECCKM `ESC[?1h` flips to `ESC OA`. **Termux resolved via JitPack**
|
||||
`com.github.termux.termux-app:{terminal-view,terminal-emulator}:v0.118.0` (only transitive dep
|
||||
androidx.annotation; no guava → R2 shim unneeded; Apache-2.0 scope held — never termux-shared/app).
|
||||
JitPack repo + coordinate added to settings/catalog; `:terminal-view` enabled.
|
||||
- Files: `terminal-view/src/main/.../terminalview/{RemoteTerminalSession, RemoteTerminalView,
|
||||
TerminalGridMath, NoOpTerminalSessionClient, LinkPolicy}.kt` + 5 test suites (17 unit tests).
|
||||
- `RemoteTerminalSession` = the FORK (no subprocess): a single confined-writer via an ordered
|
||||
`Channel<TerminalCommand>` (Feed/Resize/Bind/Unbind); append chunked 4 KiB + yield(); `onScreenUpdated`
|
||||
posted to an injected main dispatcher. pendingOutput queued-then-flushed in order (ESC[0m preserved).
|
||||
Resize math extracted as pure `TerminalGridMath` (R5), JVM-tested. Titles pass RAW (no :session-core
|
||||
edge — :app wires TitleSanitizer); OSC-52 declined; http/https LinkPolicy.
|
||||
- **Sound deviation (§6.1):** at v0.118.0 Termux `TerminalView`/`TerminalSession` are `final` →
|
||||
`RemoteTerminalView` is a COMPOSITION wrapper binding the forked emulator via the public `mEmulator`
|
||||
field (rendering stays 100% stock). Documented.
|
||||
- **Cross-review (correctness approved; threading blocked→fixed):** HIGH — bind published `mEmulator`
|
||||
to the renderer SYNC before the pendingOutput flush → bind-time UI-read-vs-confined-append race;
|
||||
fixed by routing the publish through the Bind command (flush on confined thread, THEN post
|
||||
publish+onScreenUpdated to Main together; test captures buffer state AT publish). MEDIUM — confined
|
||||
command loop now try/catches (rethrows Cancellation) so a hostile escape byte can't freeze output.
|
||||
LOW — added `kotlinx-coroutines-android` (else `Dispatchers.Main` crashes on-device). Verified:
|
||||
`:terminal-view:assembleDebug` + `testDebugUnitTest` 17/17 green.
|
||||
- **Accepted (not a defect):** steady-state append runs concurrently with the UI-thread `onDraw` — this
|
||||
is the intended §6.2 single-WRITER model (matches upstream Termux; torn read self-corrects next frame).
|
||||
- Deferred to device QA (§7): glyph/true-color rendering, IME/CJK, selection→clipboard, link-tap,
|
||||
rotation-rebind, real font-metric→grid (TerminalRenderer metrics are package-private → measured
|
||||
on-device, fed to the tested TerminalGridMath).
|
||||
|
||||
### [x] A17 KeyBar (DONE, green) — `app/.../components/KeyBar.kt` + test (12 tests)
|
||||
Compose mobile key-bar overlay porting `public/keybar.ts` (17 buttons, order/glyphs 1:1). Each tap →
|
||||
`KeyByteMap.bytes(key)` sent VERBATIM via an injected `onSend` (A21 wires `controller::sendInput`),
|
||||
bypassing IME/BasicTextField (soft keyboard never pops). Pinned above IME via
|
||||
`windowInsetsPadding(WindowInsets.ime.union(navigationBars))`, horizontally scrollable, hidden when
|
||||
`screenWidthDp>768` OR a hardware keyboard is present. **DECCKM split** = pure
|
||||
`HardwareKeyRouter.resolve()`: ⇧Tab→ESC[Z, Esc→ESC, Ctrl+{C,R,O,L,T,B,D}→control byte; everything else
|
||||
(arrows/Enter/Tab/unmapped) → `DeferToTerminal` so Termux `KeyHandler` emits DECCKM-correct `ESC OA`
|
||||
(never hardcoded `ESC[A`). Verified `:app` 27 tests green (KeyBar 12). Cross-reviewed: reviewers
|
||||
stalled (infra), but the byte contract is unit-locked. Device-QA: layout/IME-inset/real key delivery.
|
||||
A21 install: `remote.onKeyCommand = { kc,ev -> HardwareKeyRouter.handle(kc,ev,controller::sendInput) }`
|
||||
+ `KeyBar(onSend = controller::sendInput)`.
|
||||
|
||||
### [x] A18 ThumbnailPipeline (DONE, green — review deferred) — `app/.../wiring/ThumbnailPipeline.kt` + test (5 tests)
|
||||
Off-screen preview rasterisation (§6.7): `LruCache` keyed `(sessionId, lastOutputAt)` (unchanged ⇒
|
||||
cache hit, no re-render); fair `Semaphore(2)` FIFO fetch+render cap; in-flight dedup via
|
||||
`Map<Key,Deferred<Bitmap>>` under a `Mutex` (2nd same-key request awaits the 1st); failure caches a
|
||||
PLACEHOLDER (no retry storm); preview fetched over the shared **mTLS** `ApiClient` (`GET
|
||||
/live-sessions/:id/preview`, 256 KiB cap); off-screen `TerminalEmulator` (no view) → `Canvas` cell
|
||||
painter behind a `Rasterizer` seam (bitmap draw device-QA'd; concurrency/cache logic JVM-tested).
|
||||
**NOTE:** A18's build agent + all 4 AW3 reviewers stalled ~3.5h (infra degradation); the agent had
|
||||
written the file first. Orchestrator fixed a 1-char test-name error (illegal `;` in a backtick name),
|
||||
re-verified `:app:assembleDebug + testDebugUnitTest` → **32 tests green**. A18's formal cross-review was
|
||||
NOT run — flag it for the AW6 acceptance pass (concurrency code warrants a second look).
|
||||
|
||||
**Wave AW3 complete.** `:terminal-view` (17) + `:app` (32) + 6 pure-JVM (250) all green. The terminal
|
||||
render path — the plan's dominant risk — is proven and hardened.
|
||||
|
||||
---
|
||||
|
||||
## [~] Wave AW4 — 11 UI screens (A19–A29)
|
||||
|
||||
### [x] A21 Terminal screen + reconnect/EXIT banner + new-session-in-cwd (built, green — integration pass in flight)
|
||||
Files: `components/ReconnectBanner.kt` (pure `bannerModel` reducer: exited/failed OUTRANK
|
||||
connecting/reconnecting; exit -1 spawn-failure; REPLAY_TOO_LARGE no-spinner+new-session), `wiring/
|
||||
TerminalSessionControllerImpl.kt` (wraps the holder controller `by base`, wires output→feedRemote
|
||||
before start, OSC title→TitleSanitizer), `screens/TerminalScreen.kt` (generation-keyed AndroidView,
|
||||
KeyBar/HardwareKeyRouter, FLAG_SECURE privacy shade, new-session-in-cwd → attach(null,cwd)). `:app` 64
|
||||
tests (ReconnectBanner 8 + NewSessionInCwd 3).
|
||||
|
||||
### [x] A22 Gate/cockpit surfaces (built, green, reviewer approved)
|
||||
Files: `viewmodels/GateViewModel.kt` (two-line epoch stale-guard, decide via controller.decideGate,
|
||||
approve.mode top-level) + `components/{GateBanner (tool 2-btn), PlanGateSheet (plan 3-way sheet),
|
||||
TelemetryChips, AwayDigestView}.kt`. Built but not yet composed into the terminal screen (→ integration).
|
||||
|
||||
### [x] Terminal-path integration + A15 seam refinement (DONE, orchestrator-verified — `:app` 66 tests)
|
||||
Both re-verifiers confirmed (arch 7/0 broken cosmetic; kotlin 7/0). Frozen-contract changes:
|
||||
`TerminalSessionController.events` (single mailbox) → `controlEvents()` (per-consumer mailbox, no
|
||||
split); `RetainedSessionHolder.generation` → `mutableIntStateOf`; holder now owns
|
||||
`TerminalSessionControllerImpl` + `RemoteTerminalSession` (config-survival §6.6 — emulator+scrollback
|
||||
content survive rotation, no replay round-trip); GateViewModel + gate surfaces composed into
|
||||
TerminalScreen (haptic reduce-motion gated); warmUp error/retry pane. Confinement invariant #4 intact.
|
||||
Residual (cosmetic, tracked for AW6 doc pass): dangling `events` KDoc refs; scroll *offset* (not
|
||||
content) resets on rotation. Tests: TerminalSessionControllerTest (2, multi-consumer no-split),
|
||||
RetainedSessionHolder (4), GateViewModel (10).
|
||||
|
||||
### [x] AW4b — A19 Pairing + A20 Session list + A24 Diff + A25 Quick-reply (DONE, `:app` 111 tests green)
|
||||
Pre-staged CameraX (1.4.1) + ML Kit barcode (17.3.0) for A19 QR. All 4 reviewers approve/warn, 0 must-fix.
|
||||
- **A19 Pairing** — QR (CameraX+MLKit) / manual, both through the ONE `HostEndpoint.fromBaseUrl`
|
||||
validator; confirm-before-network; §5.4 tiers (public explicit-ack; tunnel cert-gated choke point
|
||||
retry can't bypass; fail-safe unknown→PUBLIC; tunnel-TLS→client-cert copy); no cert import; inert.
|
||||
- **A20 Session list** — STARTED-scoped poll, status/telemetry/thumbnail/cols×rows/sanitized-title/
|
||||
unread-dot rows, swipe-kill (optimistic, 404=success), multi-host switch, host menu (配对新主机/设备证书).
|
||||
SessionListViewModelTest 11.
|
||||
- **A24 Diff** — staged flag as STRING "1"/"0", files→hunks→lines flatten, lossy decode, inert read-only.
|
||||
A24's builder died mid-run (API drop) leaving a missing `LaunchedEffect` import (blocked :app compile)
|
||||
+ no test; orchestrator added the import + wrote DiffViewModelTest (8 tests: fromWire, flatten order,
|
||||
lossy decode, VM phase transitions + staged re-fetch).
|
||||
- **A25 Quick-reply** — DataStore CRUD palette (immutable), verbatim send, float-while-gate-held.
|
||||
|
||||
### [x] AW4c — A23 Projects + A27 CertScreen + A28 Timeline + A29 Cold-start (DONE, `:app` 164 tests green)
|
||||
Reviews: A27 approve; A23/A29 warn; all 0 must-fix except A28's "wired to away-digest expand" (a
|
||||
composition/nav wiring → tracked for the app-assembly pass below, not a code defect).
|
||||
- **A23 Projects** — ProjectGrouping BYTE-IDENTICAL group keys to web/iOS (" active"/" other" sentinels,
|
||||
first-seen-cased namespace keys, MIN_GROUP_SIZE=2); favourites/collapse via `/prefs` unknown-key-
|
||||
preserving (R11: never PUT if never loaded); detail page; open-Claude-here → attach(null,cwd,"claude\r");
|
||||
grid column seam (A26 owns full adaptive). ProjectsViewModelTest 12.
|
||||
- **A27 ClientCertScreen** — import(SAF .p12)/rotate/remove over `:client-tls-android` IdentityRepository;
|
||||
validate-before-persist keeps prior cert on bad passphrase; summary (issuer/subject-CN/expiry/EXPIRED);
|
||||
passphrase scrubbed, never logged; confirm-gated remove; inert. ClientCertViewModelTest 14.
|
||||
- **A28 Timeline** — TimelineSheet(ModalBottomSheet) + TimelineViewModel over `/events`, lossy decode,
|
||||
tokenized event colors. TimelineViewModelTest 16. (away-digest→sheet wiring → app-assembly.)
|
||||
- **A29 Cold-start** — SessionActivityBridge (set LastSessionStore on adopted / clear on exited),
|
||||
ContinueLastBanner (stack+sidebar), ColdStartPolicy route (no host→pairing). Tests: bridge 6 + policy 2.
|
||||
|
||||
### [x] A26 Adaptive large-screen + nav shell (DONE, review warn 0 must-fix)
|
||||
Pure `LayoutPolicy.mode(WindowWidth)` (compact→STACK, medium/expanded→LIST_DETAIL) + `PointerMenuPolicy.
|
||||
enabled(mode, sw>=600)` — both JVM-tested (LayoutPolicyTest 8). `AdaptiveHome` = NavigationSuiteScaffold +
|
||||
ListDetailPaneScaffold keyed on `currentWindowAdaptiveInfo()`. `PointerContextMenu` = `pointerInput`
|
||||
secondary-click → `DropdownMenu` (NOT ContextMenuArea), gated. Full NavGraph wiring → A32/app-assembly.
|
||||
|
||||
**Wave AW4 COMPLETE — all 11 screens (A19–A29) built + verified; `:app` green.**
|
||||
|
||||
---
|
||||
|
||||
## [~] Wave AW5 — push + deep-links (built; wiring → app-assembly)
|
||||
|
||||
### [x] A30 FCM client (green — `:app` 222 tests, PushDecisionTest 14)
|
||||
`push/{FcmService, NotificationBuilder, DenyBroadcastReceiver, AllowTrampolineActivity, PushPayload,
|
||||
PushDecisionSubmitter, PushTokenSink}.kt`. Data-only payload → notification built LOCALLY; **Deny** =
|
||||
BroadcastReceiver (goAsync + expedited POST, no UI, auth-free); **Allow** = translucent
|
||||
excludeFromRecents FragmentActivity hosting `BiometricPrompt` → POST (R1). Token single-use, only in
|
||||
FLAG_IMMUTABLE extras → ApiClient, never persisted/logged (test-asserted); payload minimized (no
|
||||
cwd/command/bytes). Multi-host: tries each paired host until 204 (foreign host 403s harmlessly).
|
||||
Biometric = BIOMETRIC_STRONG+negativeButton (minSdk-29 valid combo). Manifest (service/receiver/activity)
|
||||
+ `Theme.WebTerm.Translucent` **applied by orchestrator; `:app` assembles**.
|
||||
|
||||
### [x] A31 PushRegistrar (green — PushRegistrarTest 7)
|
||||
`push/PushRegistrar.kt` — POST /push/fcm-token to each paired host, self-heal (token rotation / new host
|
||||
/ removal→DELETE), token not logged. Review HIGH: not yet invoked in the app → **app-assembly** (DI:
|
||||
@Binds PushTokenSink + on-start/host-change invocation).
|
||||
|
||||
### [x] A32 DeepLinkRouter + NavGraph (green, review approve — DeepLinkRouterTest)
|
||||
`nav/{DeepLinkRouter, NavGraph}.kt` — pure `webterminal://open?host=&join=` + verified App-Links parser,
|
||||
v4-UUID whitelist on host+join (invalid ignored+counted, never partial), one router for cold/warm/push.
|
||||
Manifest intent-filters (custom scheme + `autoVerify` https) **applied by orchestrator; `:app` assembles**.
|
||||
|
||||
### [x] APP-ASSEMBLY — the app is a RUNNABLE whole (DONE, `:app` 227 tests, APK builds)
|
||||
`MainActivity` (@AndroidEntryPoint) → `WebTermNavHost` composing all screens; start destination from
|
||||
`ColdStartPolicy`; inter-screen callbacks wired (row/project/continue → terminal, host-menu →
|
||||
pairing/cert, away-digest → timeline); `AdaptiveHome` for large screens; PushRegistrar DI (@Binds
|
||||
PushTokenSink + on-start/host-change invocation); deep links via DeepLinkRouter (onCreate+onNewIntent);
|
||||
warmUp off-Main; SessionActivityBridge drives LastSessionStore. Hilt graph valid+acyclic. Manifest
|
||||
(push components + deep-link intent-filters) applied by orchestrator.
|
||||
- **Review HIGH fixed by orchestrator:** cold-start deep links were dropped by a composition race
|
||||
(navigate before the NavHost graph was set) → moved `DeepLinkEffect` inside the `route != null`
|
||||
branch so it runs only after `WebTermNavHost` composed (graph set). Re-verified `:app` 227 green.
|
||||
- Minor gaps tracked (see DEVICE_QA_CHECKLIST): push-tap→specific-gate, diff inbound link, host-remove
|
||||
UI, the A21 Termux-view reflection shim.
|
||||
|
||||
**Wave AW5 COMPLETE.**
|
||||
|
||||
---
|
||||
|
||||
## [x] Wave AW6 — acceptance (DONE, orchestrator-verified 2026-07-09)
|
||||
- **[x] A36 coverage gate:** Kover 0.9.1 applied to the 4 pure modules with an enforced `koverVerify`
|
||||
`minBound(80)` rule. Measured line coverage: **wire-protocol 91.0%** (added a HostClassifier/TimelineEvent
|
||||
test — the type lives here but was only tested cross-module, so its own report read 77% → 91%),
|
||||
**session-core 95.2%, api-client 89.2%, client-tls 96.4%**. `./gradlew koverVerify` GREEN.
|
||||
- **[x] Device-QA checklist:** `android/DEVICE_QA_CHECKLIST.md` — captures **A34** (instrumented E2E vs
|
||||
real `npm start`: attach→attached→output, reconnect replay, kill, hook/decision, **bad-Origin reject
|
||||
F9**) + **A35** (pair→attach→type→approve macrobenchmark) + the **S2** FCM real-handset spike + every
|
||||
per-task device-deferred item + the deploy artifacts (google-services.json, assetlinks.json, FCM_* env).
|
||||
A34/A35 are device/instrumented by definition (plan §7) — deferred to real hardware, not run here.
|
||||
- **[x] FINAL UNIFIED GATE (orchestrator, measured):** `./gradlew test :app:assembleDebug
|
||||
:app:testDebugUnitTest koverVerify` → **BUILD SUCCESSFUL**. ~**484 JVM tests** (257 pure/transport +
|
||||
227 `:app`) + Kover ≥80% + the full `:app` APK + `:terminal-view`/`:host-registry`/`:client-tls-android`
|
||||
all assemble. Server side (A33 FCM) unchanged since R1 (1533 server tests green).
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ CORRECTION (2026-07-30): "COMPLETE" below meant COMPILES, not WORKS
|
||||
|
||||
The section that follows claimed all 36 tasks had landed. That was true at the level it was measured —
|
||||
the modules built, the APK assembled and ~484 JVM tests were green — but NOTHING had ever run on an
|
||||
Android device or emulator, and the first thing that would have happened on one is a crash. An audit on
|
||||
2026-07-30 found, and a repair pass fixed, the following. Read this before trusting anything below.
|
||||
|
||||
**Three defects made the app unusable on real hardware.** Each was invisible to the JVM suite for the
|
||||
same structural reason: no JVM test instantiates an Android `View`.
|
||||
|
||||
1. **Every key press crashed.** `RemoteTerminalView` bound the forked emulator but never installed a
|
||||
`TerminalViewClient`. The pinned Termux `TerminalView` dereferences `mClient` with no null guard on
|
||||
the first line of the paths a user actually hits (`onKeyDown` offset 82, `onCreateInputConnection`
|
||||
offset 0, `onKeyUp`, `onKeyPreIme`). The KDoc claimed "A17 sets it" — A17 never did; only the
|
||||
`HardwareKeyRouter` and the KeyBar shipped, and the KeyBar works precisely because it bypasses the
|
||||
view. Installing a client is not sufficient: returning false continues into `mTermSession.write()`
|
||||
at offset 150, and `mTermSession` is null forever (`TerminalSession` is final and forking a process
|
||||
is exactly what this app must not do, §6.1). Fixed by a client that consumes the key itself, with
|
||||
`KeyRouting` deliberately having NO defer-to-stock branch, so the crash is unrepresentable rather
|
||||
than merely avoided. `KeyHandler` remains the authority for the key tables, so DECCKM still emits
|
||||
`ESC O A`.
|
||||
2. **No `resize` was ever sent.** `RemoteTerminalSession.updateSize` had zero call sites and the stock
|
||||
fallback is dead (`TerminalView.updateSize` early-returns without a session), so the PTY stayed at
|
||||
the server's 80x24 and every full-screen TUI rendered into the wrong box. `TerminalResizeDriver`
|
||||
now drives it from real cell metrics, read via the PUBLIC `TerminalRenderer.getFontWidth()` /
|
||||
`getFontLineSpacing()` accessors — no reflection (silently breaks under R8) and no re-measured
|
||||
`Paint` (drifts from what the renderer actually draws, the §R5 risk).
|
||||
3. **Bare-LAN connections were impossible.** `network_security_config.xml` was a self-labelled STUB
|
||||
denying all cleartext, while `HostEndpoint` accepts `http` and derives `ws://`. Its own comment
|
||||
promised a CIDR allowlist the platform cannot express. See plan §6.9 for the corrected posture.
|
||||
|
||||
**Adversarial review then found three more, and got one wrong:**
|
||||
|
||||
- **Swipe-scrolling inside an alternate-screen TUI still crashed.** The touch path bypasses
|
||||
`TerminalViewClient` entirely: `doScroll` converts a scroll to `handleKeyCode` whenever the
|
||||
alternate buffer is active, which dereferences `mTermSession`. Claude Code IS an alternate-screen
|
||||
TUI, so this was a crash during ordinary use on the app's main screen. `TerminalScrollGesture` now
|
||||
claims the vertical drag first and reproduces all three `doScroll` branches, closing the fling
|
||||
runnable and `onGenericMotionEvent` with it.
|
||||
- **`autofill()` dereferences `mTermSession` unguarded** while the view advertises itself
|
||||
autofillable, so any password manager crashed the app. The subtree is excluded from the autofill
|
||||
structure.
|
||||
- The review also demanded stock text selection be RESTORED after the focus rework made it
|
||||
unreachable. It cannot be: the ActionMode's own Copy and Paste handlers dereference the absent
|
||||
session (`TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 / 126-136), so a
|
||||
reachable selection UI has an NPE on its Copy button. **Long press stays consumed and copy-out is
|
||||
a recorded FEATURE GAP** — a first-party selection layer over `TerminalBuffer` + `ClipboardManager`
|
||||
is the real fix. This is the one place the reviewer was wrong and the builder right.
|
||||
|
||||
**Silently-lost server data, now decoded:** the W2 `queue` frame had no `ServerMessageType` member so
|
||||
every one was dropped as undecodable (the "N queued" badge could never appear), and W1
|
||||
`status.preview` was not modelled at all, which made remote approvals **BLIND** — the user was asked to
|
||||
approve a `Bash` call without seeing the command. Both now decode, with the rule that a broken preview
|
||||
never costs the frame (`pending` is what makes the gate appear).
|
||||
|
||||
**Dead code that shipped as features:** `ThumbnailPipeline` (session-list previews), `QuickReply`
|
||||
(chips + palette) and `PointerContextMenu` were fully implemented and unit-tested with ZERO production
|
||||
call sites. All three are now wired. `ThumbnailPipeline`'s formal cross-review — explicitly skipped at
|
||||
the time, as the AW3 entry below admits — has finally been run.
|
||||
|
||||
**Credential handling:** the `WEBTERM_TOKEN` cookie is now carried on REST and the WS upgrade, sealed
|
||||
with Tink AEAD under an AndroidKeyStore key, **fail-closed** (a seal failure persists nothing rather
|
||||
than falling back to plaintext), and `allowBackup=false` keeps a 30-day shell credential out of Google
|
||||
Drive and D2D transfer.
|
||||
|
||||
**RESOLVED LATER THE SAME DAY** (this list was accurate when written; it is not any more):
|
||||
- **A34 / A35 now exist and RUN.** `:app/src/androidTest/**` holds 67 instrumented tests — the four
|
||||
crash regressions, the A34 E2E set (attach→output, ring-buffer replay, spawn failure via a second
|
||||
deliberately-broken server, a gate resolved through `POST /hook/decision`, and the F9 bad-Origin
|
||||
rejection), and the Compose UI tests plan §7 named — plus `macrobenchmark/` for A35. All 67 execute
|
||||
green on `emulator-5554` against this repo's own server. Reaching the host needs
|
||||
`ALLOWED_ORIGINS=http://10.0.2.2:<port>` (the server derives allowed origins from NIC IPs, and
|
||||
10.0.2.2 is the emulator's alias for the host, not an interface address).
|
||||
- **Device QA is no longer ~0%.** The blocker fixes are device-verified, not just JVM-verified.
|
||||
- Release signing, versioning and minification are done: `assembleRelease` runs R8 and now REFUSES to
|
||||
emit an unsigned artifact instead of quietly producing one.
|
||||
- **Copy-out is done** — a first-party selection layer over `TerminalBuffer.getSelectedText` +
|
||||
`ClipboardManager`, since the stock ActionMode's Copy handler dereferences the absent session.
|
||||
- The six audit leftovers are closed too: silent certificate rotation (+ `/device/:id/recover`), the W2
|
||||
inject-queue routes, unread-watermark persistence, host-removal UI, and `/config/ui` finally honoured
|
||||
(fail-closed).
|
||||
|
||||
**Still genuinely open:**
|
||||
- **S2** — the FCM real-handset delivery spike. Needs ≥2 physical devices under Doze/OEM battery
|
||||
managers; an emulator cannot answer it.
|
||||
- One instrumented test (`wheelInsideTheAlternateBuffer_emitsThreeArrowsPerNotch`) was seen to kill the
|
||||
test process when the suite was driven WITHOUT the host arguments on a heavily-recycled emulator. It
|
||||
passes reproducibly under the documented invocation on a clean install. Re-check on real hardware
|
||||
before trusting it either way.
|
||||
- Everything else device-observable in `DEVICE_QA_CHECKLIST.md` that no automated test covers.
|
||||
|
||||
---
|
||||
|
||||
## ✅ ANDROID CLIENT COMPLETE — all 36 plan tasks (A1–A36 + S1) landed
|
||||
|
||||
Modules: `:wire-protocol :session-core :api-client :client-tls :test-support :transport-okhttp` (pure
|
||||
JVM) + `:app :terminal-view :host-registry :client-tls-android` (framework). The Android app **builds to
|
||||
an APK** with functional parity to the iOS P0+P1 scope; the Termux renderer seam (the plan's dominant
|
||||
risk) is proven working headless. S2 (FCM real-device delivery) is the one spike inherently requiring
|
||||
≥2 physical handsets — documented in the checklist. Everything device-observable is deferred to
|
||||
`DEVICE_QA_CHECKLIST.md` per plan §7 (no emulator/Firebase/host in this env).
|
||||
|
||||
**Method:** every wave ran a Workflow (TDD builders → 2-lens adversarial cross-review → fix workflow →
|
||||
adversarial re-verify → orchestrator-independent gate). Cross-validation caught + fixed ~20 real defects
|
||||
before they reached a screen (transport socket leaks, engine close-during-dial + gate double-send, an
|
||||
FCM token-refresh regression, a security bug where a REMOVED client cert stayed live in TLS, cross-session
|
||||
event bleed, blank-terminal-on-rotation, terminal-freeze-on-hostile-bytes, cold-start deep-link drop, …).
|
||||
|
||||
**GIT / not committed:** all Android work is on-disk + test-verified but UNCOMMITTED — a concurrent
|
||||
session was running the tunnel-automation workstream on this same `feat/tunnel-automation` working tree,
|
||||
so the orchestrator held ALL git ops and never touched `docs/PROGRESS_LOG.md`. **TODO for the user:**
|
||||
reconcile the two sessions, then commit the Android lane by explicit path and fold this file into
|
||||
`docs/PROGRESS_LOG.md`.
|
||||
|
||||
### Tracked APP-ASSEMBLY items (a final wiring pass composes screens into the NavGraph + connects
|
||||
### callbacks): away-digest expand → TimelineSheet (A28); host menu → pairing/cert (A20→A19/A27);
|
||||
### project open → terminal (A23→A21); continue-last → terminal (A29); session row → terminal (A20→A21);
|
||||
### and the A21 reflection shim → `libs.termux.terminal.view` on `:app` (dep now available to add).
|
||||
Cross-review of A21/A22 surfaced interlocking frozen-seam gaps being fixed together: (1 HIGH)
|
||||
`controller.events` was a single mailbox but banner+gate both collect → event split → made
|
||||
multi-consumer (`controlEvents()` per consumer); (2 HIGH) `holder.generation` made Compose-observable
|
||||
(mutableIntStateOf) so a real-background bump re-keys the AndroidView; (3 HIGH) RemoteTerminalSession/
|
||||
controller moved into the config-surviving RetainedSessionHolder so §6.6 rotation keeps the emulator+
|
||||
scrollback; (4) A22 gate surfaces composed into TerminalScreen; (5) warmUp error handling. A21 also
|
||||
flagged for later: the reflection shim to reach the impl-scoped Termux `TerminalView` from `:app`
|
||||
(clean fix = add `libs.termux.terminal.view` to `:app`); adopted-session-id survival across
|
||||
real-background → A29's LastSessionStore.
|
||||
|
||||
### Remaining AW4 screens (independent of the terminal path, next batches):
|
||||
A19 Pairing (QR/confirm/tiers) · A20 Session list+dashboard+host menu · A23 Projects+grouping+detail ·
|
||||
A24 Diff viewer · A25 Quick-reply chips+palette · A27 ClientCertScreen · A28 Timeline sheet (wires to
|
||||
A22 away-digest expand) · A29 Cold-start UX (ColdStartPolicy + LastSessionStore lifecycle) · A26
|
||||
Adaptive large-screen (LAST — deps A20+A21).
|
||||
- **[ ] AW2** A15 wiring/DI freeze (carry the A14 scope-close follow-up above).
|
||||
- **[ ] AW3** A16/A17/A18 · **[ ] AW4** A19–A29 · **[ ] AW5** A30–A32 · **[ ] AW6** A34–A36.
|
||||
344
android/README.md
Normal file
344
android/README.md
Normal file
@@ -0,0 +1,344 @@
|
||||
# WebTerm — Android client
|
||||
|
||||
A native Android client for the WebTerm browser-terminal server, targeting functional
|
||||
parity with the shipped **iOS** client. See the full design in
|
||||
[`docs/ANDROID_CLIENT_PLAN.md`](../docs/ANDROID_CLIENT_PLAN.md) (stack §2, module
|
||||
architecture §3, server contract §4, task waves §5).
|
||||
|
||||
This directory is a **Gradle multi-module** project. The module set mirrors the iOS
|
||||
SPM package set and inherits its rule: *dependencies only flow down; nothing points
|
||||
upward* (ARCHITECTURE §1).
|
||||
|
||||
> **State, honestly:** the app builds, minifies, signs (given a keystore) and cold-starts
|
||||
> to the pairing screen on an emulator. It has **never talked to a real WebTerm host from
|
||||
> a device**, and almost nothing in
|
||||
> [`DEVICE_QA_CHECKLIST.md`](DEVICE_QA_CHECKLIST.md) is ticked. Version is
|
||||
> `0.1.0-alpha01` for exactly that reason. Read that checklist before calling anything
|
||||
> here done.
|
||||
|
||||
## Build environment (SDK installed — all modules build)
|
||||
|
||||
The Android SDK **is installed** and every module — pure Kotlin/JVM and Android-framework
|
||||
alike — builds and unit-tests here. AGP 9.2.1 (built-in Kotlin) + Gradle 9.6.1 build
|
||||
against SDK 35/36.
|
||||
|
||||
- **Pure Kotlin/JVM (`./gradlew test`):** `:wire-protocol`, `:session-core`, `:api-client`,
|
||||
`:client-tls`, `:test-support`, `:transport-okhttp`.
|
||||
- **Android-framework (online in [`settings.gradle.kts`](settings.gradle.kts)):**
|
||||
`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`.
|
||||
- **Instrumented-only:** `:macrobenchmark` (`com.android.test`; assembles here, runs only
|
||||
on a device/emulator).
|
||||
|
||||
Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools`;
|
||||
`google()` is in `pluginManagement`/`dependencyResolutionManagement`. Green gate:
|
||||
`./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest koverVerify`.
|
||||
|
||||
## Module map (mirror of the iOS SPM packages — plan §3)
|
||||
|
||||
| iOS SPM package | Android module | Kind | Status |
|
||||
|------------------------|------------------------|-------------------------------|--------|
|
||||
| WireProtocol | `:wire-protocol` | pure Kotlin/JVM | ✅ built |
|
||||
| SessionCore (reducers) | `:session-core` | pure Kotlin/JVM | ✅ built |
|
||||
| APIClient | `:api-client` | pure Kotlin/JVM | ✅ built |
|
||||
| ClientTLS (pure half) | `:client-tls` | pure Kotlin/JVM | ✅ built |
|
||||
| TestSupport | `:test-support` | pure Kotlin/JVM (fakes) | ✅ built |
|
||||
| ClientTLS (fwk half) | `:client-tls-android` | Android (AndroidKeyStore/Tink)| ✅ built |
|
||||
| HostRegistry | `:host-registry` | Android (DataStore) | ✅ built |
|
||||
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built |
|
||||
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ✅ built |
|
||||
| `URLSession*Transport` | `:transport-okhttp` | pure Kotlin/JVM (OkHttp) | ✅ built |
|
||||
| — (no iOS counterpart) | `:macrobenchmark` | `com.android.test` harness | ⬜ scaffolded, no sources |
|
||||
|
||||
> `:transport-okhttp` (A7) holds the OkHttp `TermTransport`/`HttpTransport`
|
||||
> implementations that the two iOS `URLSession*Transport`s consolidate into
|
||||
> (plan §3 framing note). OkHttp is a plain JVM library, so the module builds and
|
||||
> unit-tests (MockWebServer) with **no** Android SDK — including the
|
||||
> access-token cookie on the WS upgrade (`OkHttpAccessTokenTest`) and the
|
||||
> public-host cleartext refusal (`CleartextGuardTest`).
|
||||
|
||||
`:macrobenchmark` (A35) is the one module with no iOS counterpart. It is a
|
||||
`com.android.test` module — a **separate APK** that drives `:app` out of process via
|
||||
UiAutomator, which is the only way startup/frame timings are real. It therefore cannot
|
||||
violate "dependencies only flow down": nothing depends on it, and it reaches `:app`
|
||||
through `targetProjectPath`, not a `project()` dependency. It instruments `:app`'s
|
||||
`benchmark` variant (see "Build types" below). The benchmark sources themselves are not
|
||||
written yet.
|
||||
|
||||
### Dependency graph (arrows = "depends on")
|
||||
|
||||
```
|
||||
:app
|
||||
┌───────────────┬───┴────┬──────────────┬───────────────┐
|
||||
▼ ▼ ▼ ▼ ▼
|
||||
:terminal-view :session-core :api-client :host-registry :client-tls-android
|
||||
│ │ │ │
|
||||
│ │ │ ▼
|
||||
│ │ │ :client-tls (pure)
|
||||
└──────┬───────┴──────────┴──────────────┬────────────────┘
|
||||
▼ ▼
|
||||
:wire-protocol ◀──────────── :transport-okhttp
|
||||
▲
|
||||
└──────── :test-support → test source sets only
|
||||
```
|
||||
|
||||
`:wire-protocol` is the **frozen shared contract** (Android analogue of
|
||||
`src/types.ts` + WireProtocol) — `ClientMessage`/`ServerMessage`, `MessageCodec`,
|
||||
`Validation`, `WireConstants`, `HostEndpoint` (the single Origin/wsURL derivation),
|
||||
`AuthCookie`/`AccessTokenRule`/`AccessTokenSource` (the single access-token-cookie
|
||||
derivation), and the `TermTransport` / `HttpTransport` / `PingableTermTransport`
|
||||
boundary interfaces. New wire types are added **only** here (a coordination point).
|
||||
|
||||
## Access token (`WEBTERM_TOKEN`) — how the Android client authenticates
|
||||
|
||||
A server started with `WEBTERM_TOKEN=<16–512 chars of [A-Za-z0-9._~+/=-]>` gates **every**
|
||||
HTTP route *and* the WebSocket upgrade behind a `webterm_auth` cookie. The Android
|
||||
client therefore:
|
||||
|
||||
- **hand-writes `Cookie: webterm_auth=<t>` itself** for a token it already holds — one
|
||||
derivation point (`AuthCookie`), stamped in exactly three places: `:api-client`'s
|
||||
`ApiRoute.toHttpRequest` (all 12+ routes, RO GETs included), the pairing probe's two
|
||||
hand-built legs, and `:transport-okhttp`'s WS upgrade. The hand-built
|
||||
`GET /projects/diff` fetcher in `:app` stamps it too.
|
||||
- **also installs one `AuthCookieJar`** on the single shared `OkHttpClient`, which captures
|
||||
the `Set-Cookie` a live pairing (`POST /auth`) hands back and replays it on REST *and* on
|
||||
the WS upgrade (OkHttp's `BridgeInterceptor` runs for WebSocket calls). The two are
|
||||
orthogonal: the hand-written header covers a token restored from the Keystore store, the
|
||||
jar covers a cookie the server just issued. A host with neither sends no cookie at all.
|
||||
- keeps the header **orthogonal to `Origin`**: the token never replaces the CSWSH
|
||||
Origin check (the server tests Origin first, then the cookie), and read-only routes
|
||||
still carry no Origin.
|
||||
- validates a typed token ONCE at pairing time with `POST /auth`
|
||||
(`{"token":"…"}`, `Accept: application/json` — an `Accept` containing `text/html`
|
||||
makes the server answer 302 instead of 204/401). Four outcomes: 204+`Set-Cookie` =
|
||||
correct → store it; 204 **without** `Set-Cookie` = that host has no auth → store
|
||||
nothing; 401 = wrong token; 429 = rate-limited (10/min/IP).
|
||||
- stores it per host (keyed by the canonical origin) in `TinkAccessTokenStore`:
|
||||
Tink AEAD under an **AndroidKeystore-wrapped, non-exportable** master key, in an
|
||||
app-private `SharedPreferences` file, with `android:allowBackup="false"` +
|
||||
`data_extraction_rules.xml` excluding cloud backup and device transfer. The token is
|
||||
**never logged, never in a URL, never in app UI state**.
|
||||
- treats a **401 on the WS upgrade as terminal** (`FailureReason.UNAUTHORIZED`): no
|
||||
reconnect back-off loop, and the banner tells the user to re-enter the token. A 401 on
|
||||
any REST route is the typed `ApiClientError.Unauthorized`.
|
||||
|
||||
A host with no `WEBTERM_TOKEN` is byte-identical to before the feature: no token stored ⇒
|
||||
no `Cookie` header at all (LAN zero-config preserved).
|
||||
|
||||
## Toolchain
|
||||
|
||||
- **Gradle** 9.6.1 (via the committed wrapper — always use `./gradlew`).
|
||||
- **Kotlin** 2.3.21 (matches the Kotlin embedded in Gradle 9.6.1).
|
||||
- **JVM toolchain** 17 (`jvmToolchain(17)` in every module).
|
||||
- Versions are pinned in the version catalog
|
||||
[`gradle/libs.versions.toml`](gradle/libs.versions.toml): kotlinx-serialization-json,
|
||||
kotlinx-coroutines-core/-test, JUnit5 (Jupiter), Turbine, MockK.
|
||||
|
||||
Pure modules apply `kotlin("jvm")` + `kotlin("plugin.serialization")`, wire the
|
||||
`libs.bundles.unit-test` bundle into `testImplementation`, and run tests on the
|
||||
JUnit Platform (`tasks.test { useJUnitPlatform() }`).
|
||||
|
||||
## Build & test
|
||||
|
||||
```bash
|
||||
# Use the committed wrapper for everything.
|
||||
./gradlew help # sanity: the build configures
|
||||
./gradlew projects # lists every module
|
||||
./gradlew test # JVM unit tests (JUnit5 + coroutines-test + Turbine + MockK)
|
||||
./gradlew :app:assembleDebug # the installable debug APK
|
||||
./gradlew koverVerify # the ≥80% gate on the pure modules
|
||||
|
||||
# Release path (needs a keystore — see "Release signing")
|
||||
./gradlew :app:assembleRelease # R8 + resource shrinking + signing
|
||||
./gradlew :app:lintVitalRelease # the release-blocking lint subset; must be clean
|
||||
|
||||
# Minified build WITHOUT a keystore — the practical way to test R8 keep rules
|
||||
./gradlew :app:assembleBenchmark # same shrinking as release, debug-signed → installable
|
||||
```
|
||||
|
||||
> Testing target: **≥80% Kover coverage** on the pure modules (`:wire-protocol`,
|
||||
> `:session-core`, `:api-client`, `:client-tls` pure half — the four modules that
|
||||
> apply the Kover plugin, and therefore the only ones `koverVerify` gates). TDD,
|
||||
> immutable data, small focused files — same discipline as the rest of the repo.
|
||||
>
|
||||
> **Do not use `--rerun-tasks` in a release gate.** It trips an AGP lint/K2 internal bug
|
||||
> (`FirDeclaration was not found for class KtProperty, fir is null`, on
|
||||
> `ThumbnailPipeline.kt`). `lintVitalRelease` passes normally and from a cold lint state.
|
||||
|
||||
## Build types
|
||||
|
||||
| Type | Minified | Shrunk res | Debuggable | Signed with | Purpose |
|
||||
|---|---|---|---|---|---|
|
||||
| `debug` | no | no | yes | debug key | development; stable applicationId for deep-link tests (A32) |
|
||||
| `release` | **yes** | **yes** | no | **release key (required)** | the shipping artifact |
|
||||
| `benchmark` | **yes** | **yes** | no | debug key | `initWith(release)` + `isProfileable`; what `:macrobenchmark` measures, and the only way to exercise R8 without a keystore |
|
||||
|
||||
`benchmark` exists because a benchmark must measure the code that actually ships. It sets
|
||||
`isProfileable = true`, which makes AGP inject `<profileable android:shell="true"/>` into
|
||||
the merged manifest — done there rather than in `AndroidManifest.xml` so the shipping
|
||||
manifest carries no benchmark-only tag.
|
||||
|
||||
## Versioning
|
||||
|
||||
Current: `versionCode = 1`, `versionName = "0.1.0-alpha01"`.
|
||||
|
||||
- **`versionCode`** is a plain monotonic counter — **+1 for every artifact handed to anyone**
|
||||
(a Play track, an APK sent to a tester, an archived benchmark build). It is deliberately
|
||||
NOT derived from `versionName`; keeping them independent is what lets a hotfix ship
|
||||
without renumbering. It is still `1` because no artifact has ever left the build machine;
|
||||
the first distributed build takes `2`.
|
||||
- **`versionName`** is `<server-line>-alphaNN`. The client tracks the server's `0.1.x` line
|
||||
(root `package.json` is `0.1.0`). The `-alpha01` suffix is a factual claim about device
|
||||
verification, not marketing:
|
||||
- `-alphaNN` — builds, minifies, JVM-tested; **device QA essentially unstarted**. ← today
|
||||
- `-betaNN` — the A34/A35 blocks of `DEVICE_QA_CHECKLIST.md` pass on real hardware.
|
||||
- `0.1.0` — the whole checklist is ticked.
|
||||
|
||||
Bump the suffix on any user-visible change while still in alpha; move to `0.1.1-alphaNN`
|
||||
only when the server line moves.
|
||||
|
||||
## Release signing
|
||||
|
||||
There is **no keystore in this repository and there must never be one.** Credentials are
|
||||
read at configuration time from the first of these that has them:
|
||||
|
||||
1. `android/keystore.properties` — the conventional path. Copy
|
||||
[`keystore.properties.example`](keystore.properties.example).
|
||||
⚠️ **Confirm `keystore.properties` is listed in `android/.gitignore` before creating it.**
|
||||
The existing rules cover `*.jks` / `*.keystore` / `*.p12` but not this filename.
|
||||
2. `android/local.properties` — already gitignored, so it needs no new rule. Same four keys.
|
||||
3. `WEBTERM_RELEASE_STORE_FILE` / `_STORE_PASSWORD` / `_KEY_ALIAS` / `_KEY_PASSWORD` — for CI.
|
||||
|
||||
Keys: `webterm.release.storeFile` (absolute, or relative to `android/`),
|
||||
`.storePassword`, `.keyAlias`, `.keyPassword`.
|
||||
|
||||
**Degradation contract:** with no credentials, `debug`, `benchmark`, `assembleDebugAndroidTest`
|
||||
and every test still work, and `:app:assembleRelease` **fails at `packageRelease`** with
|
||||
instructions. It fails at *packaging*, deliberately after R8, so an unconfigured machine
|
||||
still gets a full, verifiable R8 run. It never silently emits `app-release-unsigned.apk`
|
||||
(which is exactly what it used to do).
|
||||
|
||||
**Archive `app/build/outputs/mapping/release/mapping.txt` with every distributed artifact** —
|
||||
release builds keep line numbers but obfuscate names, so without the mapping file a crash
|
||||
report cannot be retraced.
|
||||
|
||||
### R8 / keep rules
|
||||
|
||||
`app/proguard-rules.pro` is deliberately short and every rule is justified in place; most
|
||||
of the stack (kotlinx.serialization, Hilt, Tink, Firebase, OkHttp, Compose, CameraX)
|
||||
ships its own consumer rules and must not be re-declared. Two rules are load-bearing and
|
||||
were both derived from **observed** failures, not guesswork:
|
||||
|
||||
- `com.termux.**` is kept whole, because `:terminal-view` binds to non-API internals of the
|
||||
pinned v0.118.0 (the public `TerminalView.mEmulator` field, `TerminalRenderer.getFontWidth()`)
|
||||
and the JitPack artifacts ship no consumer rules.
|
||||
- `implements com.google.firebase.components.ComponentRegistrar { <init>(); }`, because
|
||||
without it R8 strips the reflectively-invoked constructors of ML Kit's registrars and
|
||||
**QR scanning silently stops working in release builds only**.
|
||||
|
||||
Before adding a rule, read the merged configuration R8 actually used:
|
||||
`app/build/outputs/mapping/<variant>/configuration.txt`. To validate rules, install the
|
||||
`benchmark` APK and exercise the feature — a wrong keep rule is invisible in debug.
|
||||
|
||||
## Instrumented tests
|
||||
|
||||
`:app` has an androidTest classpath (androidx.test + Espresso + Compose UI test +
|
||||
`hilt-android-testing` with `kspAndroidTest`) and `testInstrumentationRunner` is set to
|
||||
`wang.yaojia.webterm.HiltTestRunner`.
|
||||
|
||||
`app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt` supplies it:
|
||||
|
||||
```kotlin
|
||||
class HiltTestRunner : AndroidJUnitRunner() {
|
||||
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application =
|
||||
super.newApplication(cl, HiltTestApplication::class.java.name, context)
|
||||
}
|
||||
```
|
||||
|
||||
A custom runner is **required**, not stylistic: `newApplication` is the only hook that can
|
||||
replace the app-under-test's `Application` with the generated `HiltTestApplication`. Setting
|
||||
`android:name` in the androidTest manifest cannot do it — that manifest is merged into the
|
||||
*test* APK, not into the app under test. `assembleDebugAndroidTest` builds fine without the
|
||||
class (the runner name is just a manifest value); an actual instrumentation *run* needs it.
|
||||
|
||||
The suite has been **run green on real hardware** (67/0 instrumented tests against this
|
||||
repo's own server), so `src/androidTest/**` is verified, not aspirational — but it still
|
||||
needs a connected device/emulator and is therefore **not** part of the `./gradlew test`
|
||||
gate; run it with `connectedAndroidTest`. An emulator additionally needs
|
||||
`ALLOWED_ORIGINS=http://10.0.2.2:<port>` on the server, because the server derives its
|
||||
allowed origins from NIC IPs and `10.0.2.2` (the emulator's alias for the host) is not one.
|
||||
|
||||
### What is still NOT verified here
|
||||
|
||||
- **DEFERRED — end-to-end access token**: the `WEBTERM_TOKEN` path is covered by
|
||||
unit tests (`OkHttpAccessTokenTest`, `AuthProbeTest`, `AccessTokenCookieTest`, the
|
||||
`:api-client` route tests, the `AuthCookie` derivation tests) and by
|
||||
`TinkAccessTokenStoreTest` on device, but **no automated leg on the Android side starts
|
||||
a real server with `WEBTERM_TOKEN` set and completes a cookie-gated WS upgrade** — that
|
||||
end-to-end coverage exists only on the iOS side (`ios/IntegrationTests`).
|
||||
- **DEFERRED — the rest of `DEVICE_QA_CHECKLIST.md`**: FCM push delivery under Doze on two
|
||||
physical handsets (S2) and the lock-screen Allow/Deny walkthrough have no automated cover.
|
||||
|
||||
## Android SDK setup (proven working)
|
||||
|
||||
The pure JVM modules need only a JDK + Gradle. The **Android-framework** modules
|
||||
(`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android` — plan AW2+)
|
||||
need the Android SDK. This machine is set up and the toolchain is **proven** (an
|
||||
AGP library module compiled against SDK 35 and produced an AAR):
|
||||
|
||||
- **SDK location:** `/usr/local/share/android-commandlinetools`
|
||||
(installed via `brew install --cask android-commandlinetools`).
|
||||
- **Installed packages:** `platform-tools`, `platforms;android-35`, `platforms;android-36`,
|
||||
`build-tools;35.0.0`, `build-tools;36.0.0`. (`:app` compiles against SDK **36** — the
|
||||
Kotlin-2.3.21-contemporaneous androidx/Compose line refuses SDK 35; `platforms;android-37`
|
||||
is not fetchable here as the cmdline-tools are too old to parse the v4 repo XML.)
|
||||
- **`android/local.properties`** (gitignored) points Gradle at it:
|
||||
`sdk.dir=/usr/local/share/android-commandlinetools`.
|
||||
- **Shell env** (for `sdkmanager`/`adb`): `export ANDROID_HOME=/usr/local/share/android-commandlinetools`.
|
||||
|
||||
### Wiring an Android module (the working recipe)
|
||||
|
||||
- Repos: `google()` is in both `pluginManagement` and `dependencyResolutionManagement`
|
||||
in `settings.gradle.kts` (needed to resolve AGP + androidx).
|
||||
- Plugin: **AGP 9.2.1** (`libs.plugins.android.library` / `.android.application`),
|
||||
compatible with Gradle 9.6.1.
|
||||
- **Gotcha:** AGP 9 has **built-in Kotlin** — apply ONLY the android plugin. Adding
|
||||
`org.jetbrains.kotlin.android` errors with "no longer required since AGP 9.0".
|
||||
- Module block: `android { namespace = "…"; compileSdk = 36; defaultConfig { minSdk = 29 } }`.
|
||||
(Framework modules target `compileSdk = 36`; `targetSdk` stays `35` per plan §2.)
|
||||
- **`:app` UI-stack version matrix** (A13, proven `:app:assembleDebug` green): AGP 9.2.1 ·
|
||||
Kotlin 2.3.21 · Compose-compiler plugin `org.jetbrains.kotlin.plugin.compose` = 2.3.21 ·
|
||||
Compose BOM `2025.11.01` (→ material3 1.4.0, ui/foundation 1.9.5, material3.adaptive 1.2.0,
|
||||
material3-adaptive-navigation-suite 1.4.0) · Hilt (dagger) 2.60.1 via KSP `2.3.9` ·
|
||||
androidx core-ktx 1.17.0 / activity-compose 1.12.4 / lifecycle 2.10.0. Apply plugins:
|
||||
`android.application` + `kotlin.plugin.compose` + `ksp` + `dagger.hilt.android` (NEVER
|
||||
`kotlin.android`). Bump these together with `compileSdk 37` once platform 37 is installable.
|
||||
|
||||
- **A `com.android.test` module cannot use a versioned plugin alias.** The root build
|
||||
script already puts AGP on the classpath via the `android.library`/`android.application`
|
||||
aliases, so a third versioned request for the same artifact fails with *"plugin is already
|
||||
on the classpath with an unknown version"*. `:macrobenchmark` therefore applies
|
||||
`id("com.android.test")` bare — same as `:terminal-view` with `com.android.library`. The
|
||||
version is still pinned once, as `agp` in the catalog.
|
||||
|
||||
## Emulator (installed)
|
||||
|
||||
An AVD named **`webterm`** exists (android-35, arm64, software GPU) and is what produced the
|
||||
on-device evidence recorded in [`DEVICE_QA_CHECKLIST.md`](DEVICE_QA_CHECKLIST.md):
|
||||
|
||||
```bash
|
||||
export ANDROID_HOME=/usr/local/share/android-commandlinetools
|
||||
$ANDROID_HOME/emulator/emulator -avd webterm -gpu swiftshader_indirect &
|
||||
$ANDROID_HOME/platform-tools/adb devices -l
|
||||
|
||||
# validate the MINIFIED build (this is what catches bad keep rules)
|
||||
./gradlew :app:assembleBenchmark
|
||||
adb install -r app/build/outputs/apk/benchmark/app-benchmark.apk
|
||||
adb logcat -c && adb shell am start -W -n wang.yaojia.webterm/.MainActivity
|
||||
adb logcat -d --pid=$(adb shell pidof wang.yaojia.webterm) | grep -E "FATAL|NoSuchMethod|NoClassDefFound"
|
||||
```
|
||||
|
||||
Note it is a **software-GPU** emulator: it is fine for crash/keep-rule/route validation and
|
||||
useless for performance numbers. Real macrobenchmark figures need physical hardware.
|
||||
|
||||
To add more SDK pieces later:
|
||||
`sdkmanager "system-images;android-35;google_apis;arm64-v8a" "emulator"`.
|
||||
39
android/api-client/build.gradle.kts
Normal file
39
android/api-client/build.gradle.kts
Normal file
@@ -0,0 +1,39 @@
|
||||
// :api-client — pure REST client logic (12 routes, tolerant decode, Origin-iff-
|
||||
// guarded, strict query encoding, prefs unknown-key preservation, pairing probe /
|
||||
// PairingError / HostClassifier tiers). Consumes HttpTransport by interface only.
|
||||
// Depends only on :wire-protocol.
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.kover)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":wire-protocol"))
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
testImplementation(project(":test-support"))
|
||||
testImplementation(libs.bundles.unit.test)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
// A36 acceptance gate: >=80% line coverage on this pure module (plan §7).
|
||||
kover {
|
||||
reports {
|
||||
verify {
|
||||
rule {
|
||||
minBound(80)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package wang.yaojia.webterm.api.enroll
|
||||
|
||||
/**
|
||||
* B4 · Manual, canonical-DER encoder for a P-256 PKCS#10 `CertificationRequest` — a byte-for-byte
|
||||
* port of the iOS `ClientTLS.CertificateSigningRequest`.
|
||||
*
|
||||
* Built by hand (no JCA CSR helper) so the exact bytes are under our control and the request is
|
||||
* signed by the [CsrSigner] (an AndroidKeyStore hardware key in production, a software P-256 key in
|
||||
* tests) via `SHA256withECDSA`. The output must satisfy the control-plane `verifyCsrPoPEc`: an EC
|
||||
* P-256 `SubjectPublicKeyInfo` (`id-ecPublicKey` + `prime256v1`), an `ecdsa-with-SHA256`
|
||||
* self-signature, and a valid PoP. Encoding is strictly canonical DER (minimal lengths) so the
|
||||
* server's re-serialization of `CertificationRequestInfo` matches the bytes we signed.
|
||||
*
|
||||
* ```
|
||||
* CertificationRequest ::= SEQUENCE {
|
||||
* certificationRequestInfo CertificationRequestInfo,
|
||||
* signatureAlgorithm AlgorithmIdentifier, -- ecdsa-with-SHA256
|
||||
* signature BIT STRING } -- X9.62 DER ECDSA-Sig
|
||||
*
|
||||
* CertificationRequestInfo ::= SEQUENCE {
|
||||
* version INTEGER { v1(0) },
|
||||
* subject Name,
|
||||
* subjectPKInfo SubjectPublicKeyInfo,
|
||||
* attributes [0] IMPLICIT SET OF Attribute } -- empty
|
||||
* ```
|
||||
*/
|
||||
public object CertificateSigningRequest {
|
||||
/** P-256 uncompressed public point is `0x04 || X(32) || Y(32)` = 65 bytes. */
|
||||
private const val UNCOMPRESSED_P256_POINT_LENGTH = 65
|
||||
|
||||
/**
|
||||
* Build and self-sign a P-256 PKCS#10 CSR DER for [signer]'s key.
|
||||
*
|
||||
* @param subjectCommonName the CSR subject CN. The device leaf's identity is driven server-side
|
||||
* by the ownership-verified subdomain SAN, so this is descriptive only; it must be non-empty.
|
||||
* @param signer the P-256 hardware key that provides the public key and signs the
|
||||
* `CertificationRequestInfo`.
|
||||
* @throws CsrException.InvalidSubject on an empty CN; [CsrException.InvalidPublicKey] if the
|
||||
* signer's public key is not a 65-byte X9.63 P-256 point.
|
||||
*/
|
||||
public fun der(subjectCommonName: String, signer: CsrSigner): ByteArray {
|
||||
if (subjectCommonName.isEmpty()) throw CsrException.InvalidSubject
|
||||
|
||||
val publicPoint = signer.publicKeyX963()
|
||||
if (publicPoint.size != UNCOMPRESSED_P256_POINT_LENGTH || publicPoint[0].toInt() != 0x04) {
|
||||
throw CsrException.InvalidPublicKey
|
||||
}
|
||||
|
||||
val requestInfo = certificationRequestInfo(subjectCommonName, publicPoint)
|
||||
val signature = signer.sign(requestInfo)
|
||||
|
||||
return DerWriter.sequence(
|
||||
listOf(
|
||||
requestInfo,
|
||||
ECDSA_WITH_SHA256_ALGORITHM_IDENTIFIER,
|
||||
DerWriter.bitString(signature),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ── CertificationRequestInfo ────────────────────────────────────────────────────────────
|
||||
|
||||
private fun certificationRequestInfo(subjectCommonName: String, publicPoint: ByteArray): ByteArray =
|
||||
DerWriter.sequence(
|
||||
listOf(
|
||||
DerWriter.INTEGER_0, // version v1(0)
|
||||
name(subjectCommonName),
|
||||
subjectPublicKeyInfo(publicPoint),
|
||||
DerWriter.EMPTY_ATTRIBUTES_CONTEXT0, // [0] IMPLICIT SET OF Attribute (empty)
|
||||
),
|
||||
)
|
||||
|
||||
/** `Name ::= SEQUENCE OF RelativeDistinguishedName` with a single CN RDN. */
|
||||
private fun name(commonName: String): ByteArray {
|
||||
val attribute = DerWriter.sequence(
|
||||
listOf(DerWriter.oid(Oid.COMMON_NAME), DerWriter.utf8String(commonName)),
|
||||
)
|
||||
val rdn = DerWriter.set(listOf(attribute))
|
||||
return DerWriter.sequence(listOf(rdn))
|
||||
}
|
||||
|
||||
/**
|
||||
* `SubjectPublicKeyInfo` for an EC P-256 key: `id-ecPublicKey` + `prime256v1` named curve, then
|
||||
* the uncompressed point as a BIT STRING.
|
||||
*/
|
||||
private fun subjectPublicKeyInfo(publicPoint: ByteArray): ByteArray {
|
||||
val algorithm = DerWriter.sequence(
|
||||
listOf(DerWriter.oid(Oid.EC_PUBLIC_KEY), DerWriter.oid(Oid.PRIME256V1)),
|
||||
)
|
||||
return DerWriter.sequence(listOf(algorithm, DerWriter.bitString(publicPoint)))
|
||||
}
|
||||
|
||||
/**
|
||||
* `AlgorithmIdentifier` for `ecdsa-with-SHA256` — no parameters (absent, per RFC 5758), which is
|
||||
* exactly what the server's verifier expects.
|
||||
*/
|
||||
private val ECDSA_WITH_SHA256_ALGORITHM_IDENTIFIER: ByteArray =
|
||||
DerWriter.sequence(listOf(DerWriter.oid(Oid.ECDSA_WITH_SHA256)))
|
||||
}
|
||||
|
||||
/** Object identifiers (DER content bytes; tag/length added by [DerWriter.oid]). */
|
||||
private object Oid {
|
||||
/** 1.2.840.10045.2.1 — id-ecPublicKey. */
|
||||
val EC_PUBLIC_KEY = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x02, 0x01)
|
||||
|
||||
/** 1.2.840.10045.3.1.7 — prime256v1 / secp256r1. */
|
||||
val PRIME256V1 = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x03, 0x01, 0x07)
|
||||
|
||||
/** 1.2.840.10045.4.3.2 — ecdsa-with-SHA256. */
|
||||
val ECDSA_WITH_SHA256 = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x04, 0x03, 0x02)
|
||||
|
||||
/** 2.5.4.3 — id-at-commonName. */
|
||||
val COMMON_NAME = byteArrayOf(0x55, 0x04, 0x03)
|
||||
}
|
||||
|
||||
/**
|
||||
* A tiny canonical-DER encoder. Every helper returns a fully-formed TLV so callers just concatenate
|
||||
* children — canonical minimal-length encoding throughout. Internal so its byte layout is
|
||||
* unit-testable in isolation.
|
||||
*/
|
||||
internal object DerWriter {
|
||||
private const val TAG_INTEGER: Byte = 0x02
|
||||
private const val TAG_BIT_STRING: Byte = 0x03
|
||||
private const val TAG_OID: Byte = 0x06
|
||||
private const val TAG_UTF8_STRING: Byte = 0x0C
|
||||
private const val TAG_SEQUENCE: Byte = 0x30
|
||||
private const val TAG_SET: Byte = 0x31
|
||||
private const val TAG_CONTEXT0_CONSTRUCTED: Byte = 0xA0.toByte()
|
||||
|
||||
/** `INTEGER 0` — the fixed PKCS#10 version v1(0). */
|
||||
val INTEGER_0: ByteArray = byteArrayOf(TAG_INTEGER, 0x01, 0x00)
|
||||
|
||||
/** `[0] IMPLICIT SET OF Attribute`, empty — `A0 00`. */
|
||||
val EMPTY_ATTRIBUTES_CONTEXT0: ByteArray = byteArrayOf(TAG_CONTEXT0_CONSTRUCTED, 0x00)
|
||||
|
||||
fun sequence(children: List<ByteArray>): ByteArray = tlv(TAG_SEQUENCE, concat(children))
|
||||
|
||||
fun set(children: List<ByteArray>): ByteArray = tlv(TAG_SET, concat(children))
|
||||
|
||||
fun oid(content: ByteArray): ByteArray = tlv(TAG_OID, content)
|
||||
|
||||
fun utf8String(value: String): ByteArray = tlv(TAG_UTF8_STRING, value.encodeToByteArray())
|
||||
|
||||
/** BIT STRING with zero unused bits (all our bit strings are byte-aligned). */
|
||||
fun bitString(content: ByteArray): ByteArray = tlv(TAG_BIT_STRING, byteArrayOf(0x00) + content)
|
||||
|
||||
/** Tag-Length-Value with canonical DER length encoding. */
|
||||
private fun tlv(tag: Byte, value: ByteArray): ByteArray = byteArrayOf(tag) + length(value.size) + value
|
||||
|
||||
/** DER length: short form (<128) or long form (`0x80 | byteCount`, big-endian). */
|
||||
private fun length(count: Int): ByteArray {
|
||||
if (count < 0x80) return byteArrayOf(count.toByte())
|
||||
var value = count
|
||||
val bytes = ArrayDeque<Byte>()
|
||||
while (value > 0) {
|
||||
bytes.addFirst((value and 0xFF).toByte())
|
||||
value = value ushr 8
|
||||
}
|
||||
return byteArrayOf((0x80 or bytes.size).toByte()) + bytes.toByteArray()
|
||||
}
|
||||
|
||||
private fun concat(chunks: List<ByteArray>): ByteArray {
|
||||
val total = chunks.sumOf { it.size }
|
||||
val out = ByteArray(total)
|
||||
var offset = 0
|
||||
for (chunk in chunks) {
|
||||
System.arraycopy(chunk, 0, out, offset, chunk.size)
|
||||
offset += chunk.size
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package wang.yaojia.webterm.api.enroll
|
||||
|
||||
/**
|
||||
* B4 · The signing key abstraction the PKCS#10 CSR encoder ([CertificateSigningRequest]) drives —
|
||||
* the Android analogue of iOS `P256HardwareKey`.
|
||||
*
|
||||
* In production this is backed by a NON-EXPORTABLE P-256 key living inside AndroidKeyStore
|
||||
* (StrongBox → TEE), so `sign` runs inside secure hardware and the private key never leaves it
|
||||
* (`:client-tls-android` `HardwareBackedKey`). In JVM unit tests it is backed by a software P-256
|
||||
* key via the SAME `Signature("SHA256withECDSA")` path, so the CSR-encoding bytes are exercised
|
||||
* identically without an emulator.
|
||||
*/
|
||||
public interface CsrSigner {
|
||||
/**
|
||||
* The public key in ANSI X9.63 uncompressed form: `0x04 || X(32) || Y(32)` (65 bytes for
|
||||
* P-256). This is exactly what wraps into the CSR's `SubjectPublicKeyInfo` BIT STRING.
|
||||
*/
|
||||
public fun publicKeyX963(): ByteArray
|
||||
|
||||
/**
|
||||
* ECDSA-sign `message` over SHA-256, returning the X9.62 DER signature
|
||||
* (`SEQUENCE { r INTEGER, s INTEGER }`) — exactly the shape a PKCS#10 `signature` BIT STRING
|
||||
* and the server's `verifyCsrPoPEc` expect. The digest is computed by the algorithm
|
||||
* (`SHA256withECDSA`), so callers pass the raw message (the DER of `CertificationRequestInfo`),
|
||||
* NOT a pre-hash.
|
||||
*/
|
||||
public fun sign(message: ByteArray): ByteArray
|
||||
}
|
||||
|
||||
/** Structural failures building a CSR — the client refuses to emit a malformed request. */
|
||||
public sealed class CsrException(message: String) : Exception(message) {
|
||||
/** The signer's public key was not the expected 65-byte X9.63 uncompressed P-256 point. */
|
||||
public data object InvalidPublicKey : CsrException("CSR public key is not a 65-byte X9.63 P-256 point")
|
||||
|
||||
/** The subject CN was empty (not encodable / rejected by the server). */
|
||||
public data object InvalidSubject : CsrException("CSR subject common name must not be empty")
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package wang.yaojia.webterm.api.enroll
|
||||
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import wang.yaojia.webterm.wire.HttpRequest
|
||||
import wang.yaojia.webterm.wire.HttpResponse
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
|
||||
/**
|
||||
* B4 · Talks to the control-plane device-enrollment API over the shared [HttpTransport] seam (the
|
||||
* same seam `:transport-okhttp` implements and `:test-support` fakes), so the enroll flow rides the
|
||||
* app's normal HTTP stack. Android analogue of iOS `DeviceEnrollmentClient`, extended with the login
|
||||
* step (B4 pinned contract):
|
||||
*
|
||||
* `POST /auth/login` `{ password }` → 201 `{ enrollToken, accountId, expiresIn }`
|
||||
* `POST /device/enroll` [Bearer enrollToken] `{ csr, keyAlg:'ec-p256', subdomain,
|
||||
* deviceName, attestation? }` → 201 `{ deviceId, cert, caChain,
|
||||
* notBefore, notAfter, renewAfter }`
|
||||
* `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 `{ cert, caChain,
|
||||
* notAfter }`. The server schema is `.strict()`; NO
|
||||
* keyAlg/subdomain/deviceName.
|
||||
* `POST /device/:id/recover` [NO client cert — plain HTTPS] `{ cert, csr }` → 201 `{ cert,
|
||||
* caChain, notAfter }`. The EXPIRED leaf's escape hatch.
|
||||
*
|
||||
* Deliberately logic-free about TLS/keys: it only builds requests and maps responses. The `csr` is
|
||||
* sent as standard base64(DER), which the server's `decodeCsrWire` accepts directly; response DERs
|
||||
* are standard-base64 (`bytesToBase64` = Node `Buffer.toString('base64')`).
|
||||
*
|
||||
* Immutable: constructed once with a [baseUrl] + [http]; the short-lived enroll bearer is passed
|
||||
* per-call and never held/logged (leaked-bearer blast radius).
|
||||
*/
|
||||
public class DeviceEnrollmentClient(
|
||||
baseUrl: String,
|
||||
private val http: HttpTransport,
|
||||
) {
|
||||
/**
|
||||
* Base control-plane URL with any trailing slash removed, so `baseUrl + path` is well-formed.
|
||||
* Readable so a rotation driver can persist WHICH control plane a device enrolled with and renew
|
||||
* against that same one (a URL is not a credential).
|
||||
*/
|
||||
public val baseUrl: String = baseUrl.trim().trimEnd('/')
|
||||
|
||||
/**
|
||||
* One-time operator login → a short-lived `device:enroll` bearer. An empty [password] is
|
||||
* rejected client-side (`InvalidRequest`) before any network I/O — never send a blank credential.
|
||||
*/
|
||||
public suspend fun login(password: String): LoginResult {
|
||||
if (password.isEmpty()) throw DeviceEnrollmentError.InvalidRequest
|
||||
val body = EnrollJson.encodeToString(LoginRequestBody.serializer(), LoginRequestBody(password))
|
||||
val response = http.send(jsonRequest(HttpMethod.POST, PATH_LOGIN, body.encodeToByteArray(), bearer = null))
|
||||
val dto = decodeOn201(response, LoginResponseDto.serializer())
|
||||
return LoginResult(
|
||||
enrollToken = dto.enrollToken,
|
||||
accountId = dto.accountId,
|
||||
expiresInSeconds = dto.expiresIn,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enroll a freshly-generated hardware key: POST the [csrDer] under the enroll [bearerToken],
|
||||
* receive the leaf. Required fields are validated client-side (`InvalidRequest`) before any I/O.
|
||||
*/
|
||||
public suspend fun enroll(
|
||||
bearerToken: String,
|
||||
csrDer: ByteArray,
|
||||
subdomain: String,
|
||||
deviceName: String,
|
||||
attestation: String? = null,
|
||||
): EnrollmentResult {
|
||||
if (bearerToken.isEmpty() || csrDer.isEmpty() || subdomain.isEmpty() || deviceName.isEmpty()) {
|
||||
throw DeviceEnrollmentError.InvalidRequest
|
||||
}
|
||||
val body = EnrollJson.encodeToString(
|
||||
EnrollRequestBody.serializer(),
|
||||
EnrollRequestBody(
|
||||
csr = base64(csrDer),
|
||||
keyAlg = KEY_ALG_EC_P256,
|
||||
subdomain = subdomain,
|
||||
deviceName = deviceName,
|
||||
attestation = attestation,
|
||||
),
|
||||
)
|
||||
val response = http.send(jsonRequest(HttpMethod.POST, PATH_ENROLL, body.encodeToByteArray(), bearerToken))
|
||||
return toResult(decodeOn201(response, EnrollResponseDto.serializer()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Renew against the SAME hardware key: a fresh CSR to `/device/:id/renew` (silent-rotation seam).
|
||||
*
|
||||
* The renew endpoint is authenticated by the CURRENT device certificate over mTLS — the body is
|
||||
* `{ csr }` ONLY and NO bearer is sent (mirrors iOS, which passes `bearerToken: nil`). [bearerToken]
|
||||
* is therefore OPTIONAL and defaults to absent; the `Authorization` header is omitted when it is
|
||||
* null/blank. The seam still accepts a bearer for a hypothetical bearer-authenticated renew, but the
|
||||
* production caller passes none. [deviceId] and [csrDer] are validated client-side before any I/O.
|
||||
*/
|
||||
public suspend fun renew(deviceId: String, csrDer: ByteArray, bearerToken: String? = null): EnrollmentResult {
|
||||
if (deviceId.isEmpty() || csrDer.isEmpty()) {
|
||||
throw DeviceEnrollmentError.InvalidRequest
|
||||
}
|
||||
// Body is `{ csr }` ONLY — the renew endpoint authenticates by the presented mTLS device cert
|
||||
// and its schema is `.strict()`, so any enroll-only extra (keyAlg/subdomain/deviceName) is
|
||||
// rejected. Identity/key come from the current cert + registry record, never the body.
|
||||
val body = EnrollJson.encodeToString(
|
||||
RenewRequestBody.serializer(),
|
||||
RenewRequestBody(csr = base64(csrDer)),
|
||||
)
|
||||
val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/renew"
|
||||
val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearerToken))
|
||||
// The renew 201 is `{ cert, caChain, notAfter }` — it does NOT echo deviceId, so the id we
|
||||
// addressed the request to is the authoritative one.
|
||||
return toReissueResult(decodeOn201(response, RenewResponseDto.serializer()), deviceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover an EXPIRED leaf: `POST /device/:id/recover` carrying the lapsed [expiredCertDer] in the
|
||||
* BODY plus a fresh [csrDer] over the SAME hardware key.
|
||||
*
|
||||
* **This call must NOT ride an mTLS transport.** `/renew` is authenticated by the very leaf it
|
||||
* renews, so a lapsed leaf cannot renew itself — and no TLS terminator will even forward an expired
|
||||
* client certificate (nginx answers a bare 400 under `ssl_verify_client optional`; `optional_no_ca`
|
||||
* tolerates only CHAIN errors, never `X509_V_ERR_CERT_HAS_EXPIRED`). The caller therefore supplies a
|
||||
* PLAIN [HttpTransport] with no client identity; possession is proven by the CSR self-signature,
|
||||
* which the control plane checks together with `CSR key == registered key`.
|
||||
*
|
||||
* No bearer is accepted on this path at all — the body is the whole credential story. Everything
|
||||
* else the server enforces is unchanged: real X.509 path validation to the device-CA, SPIFFE parse,
|
||||
* `notBefore` (never graced), `:id` must match the cert, and revocation still applies. Only
|
||||
* `notAfter` is graced (30 days), so a leaf lapsed beyond that answers 401.
|
||||
*/
|
||||
public suspend fun recover(deviceId: String, expiredCertDer: ByteArray, csrDer: ByteArray): EnrollmentResult {
|
||||
if (deviceId.isEmpty() || expiredCertDer.isEmpty() || csrDer.isEmpty()) {
|
||||
throw DeviceEnrollmentError.InvalidRequest
|
||||
}
|
||||
val body = EnrollJson.encodeToString(
|
||||
RecoverRequestBody.serializer(),
|
||||
RecoverRequestBody(cert = base64(expiredCertDer), csr = base64(csrDer)),
|
||||
)
|
||||
val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/recover"
|
||||
val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearer = null))
|
||||
return toReissueResult(decodeOn201(response, RenewResponseDto.serializer()), deviceId)
|
||||
}
|
||||
|
||||
// ── Request/response plumbing ────────────────────────────────────────────────────────────
|
||||
|
||||
private fun jsonRequest(
|
||||
method: HttpMethod,
|
||||
path: String,
|
||||
jsonBody: ByteArray,
|
||||
bearer: String?,
|
||||
): HttpRequest {
|
||||
val headers = LinkedHashMap<String, String>()
|
||||
headers[HEADER_CONTENT_TYPE] = CONTENT_TYPE_JSON
|
||||
// Omit Authorization entirely when there is no bearer (the mTLS-only renew path) — an empty
|
||||
// string must never emit a bare "Bearer " header.
|
||||
if (!bearer.isNullOrEmpty()) headers[HEADER_AUTHORIZATION] = "$BEARER_PREFIX$bearer"
|
||||
return HttpRequest(method = method, url = baseUrl + path, headers = headers, body = jsonBody)
|
||||
}
|
||||
|
||||
/** 201 → decode with [serializer]; else → [DeviceEnrollmentError.Http] with the server's `error`
|
||||
* code (never the raw body); an undecodable 201 body → [DeviceEnrollmentError.MalformedResponse]. */
|
||||
private fun <T> decodeOn201(response: HttpResponse, serializer: kotlinx.serialization.KSerializer<T>): T {
|
||||
if (response.status != HTTP_CREATED) {
|
||||
throw DeviceEnrollmentError.Http(response.status, errorCode(response.body))
|
||||
}
|
||||
return runCatching { EnrollJson.decodeFromString(serializer, response.body.decodeToString()) }
|
||||
.getOrNull() ?: throw DeviceEnrollmentError.MalformedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a re-issuance (renew / recover) 201, whose body carries no `deviceId`, using the [deviceId]
|
||||
* the request was addressed to. `renewAfter` is normally absent here — the rotation policy
|
||||
* re-derives it from the leaf's own validity window (`RotationPolicy.renewAfterFor`).
|
||||
*/
|
||||
private fun toReissueResult(dto: RenewResponseDto, deviceId: String): EnrollmentResult =
|
||||
EnrollmentResult(
|
||||
deviceId = deviceId,
|
||||
certificate = decodeDerOrThrow(dto.cert),
|
||||
caChain = dto.caChain.map { decodeDerOrThrow(it) },
|
||||
notBefore = parseInstantOrNull(dto.notBefore),
|
||||
notAfter = parseInstantOrNull(dto.notAfter),
|
||||
renewAfter = parseInstantOrNull(dto.renewAfter),
|
||||
)
|
||||
|
||||
private fun decodeDerOrThrow(base64Der: String): ByteArray =
|
||||
decodeBase64OrNull(base64Der) ?: throw DeviceEnrollmentError.MalformedResponse
|
||||
|
||||
private fun toResult(dto: EnrollResponseDto): EnrollmentResult {
|
||||
val certificate = decodeBase64OrNull(dto.cert) ?: throw DeviceEnrollmentError.MalformedResponse
|
||||
val chain = dto.caChain.map { entry ->
|
||||
decodeBase64OrNull(entry) ?: throw DeviceEnrollmentError.MalformedResponse
|
||||
}
|
||||
return EnrollmentResult(
|
||||
deviceId = dto.deviceId,
|
||||
certificate = certificate,
|
||||
caChain = chain,
|
||||
notBefore = parseInstantOrNull(dto.notBefore),
|
||||
notAfter = parseInstantOrNull(dto.notAfter),
|
||||
renewAfter = parseInstantOrNull(dto.renewAfter),
|
||||
)
|
||||
}
|
||||
|
||||
private fun errorCode(body: ByteArray): String? =
|
||||
runCatching { EnrollJson.decodeFromString(ErrorDto.serializer(), body.decodeToString()).error }.getOrNull()
|
||||
|
||||
private companion object {
|
||||
const val PATH_LOGIN = "/auth/login"
|
||||
const val PATH_ENROLL = "/device/enroll"
|
||||
const val PATH_DEVICE = "/device"
|
||||
const val KEY_ALG_EC_P256 = "ec-p256"
|
||||
const val HTTP_CREATED = 201
|
||||
|
||||
const val HEADER_CONTENT_TYPE = "Content-Type"
|
||||
const val HEADER_AUTHORIZATION = "Authorization"
|
||||
const val CONTENT_TYPE_JSON = "application/json"
|
||||
const val BEARER_PREFIX = "Bearer "
|
||||
|
||||
private val BASE64_ENCODER = java.util.Base64.getEncoder()
|
||||
private val BASE64_DECODER = java.util.Base64.getDecoder()
|
||||
|
||||
fun base64(bytes: ByteArray): String = BASE64_ENCODER.encodeToString(bytes)
|
||||
|
||||
fun decodeBase64OrNull(text: String): ByteArray? =
|
||||
runCatching { BASE64_DECODER.decode(text) }.getOrNull()
|
||||
|
||||
/** Percent-encode a `:id` path segment's non-unreserved bytes (defence: device ids are
|
||||
* server-minted UUIDs, but never build a URL from an unescaped field). */
|
||||
fun encodePathSegment(value: String): String {
|
||||
val sb = StringBuilder()
|
||||
for (byte in value.encodeToByteArray()) {
|
||||
val code = byte.toInt() and 0xFF
|
||||
val ch = code.toChar()
|
||||
if (ch in UNRESERVED) sb.append(ch) else sb.append('%').append(HEX[code ushr 4]).append(HEX[code and 0x0F])
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private val UNRESERVED: Set<Char> =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~".toSet()
|
||||
private val HEX = "0123456789ABCDEF".toCharArray()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package wang.yaojia.webterm.api.enroll
|
||||
|
||||
import java.math.BigInteger
|
||||
import java.security.interfaces.ECPublicKey
|
||||
|
||||
/**
|
||||
* B4 · Pure encoder from a JCA [ECPublicKey] to the ANSI X9.63 uncompressed point
|
||||
* `0x04 || X || Y` that a P-256 `SubjectPublicKeyInfo` BIT STRING carries.
|
||||
*
|
||||
* Kept in the pure `:api-client` module (no Android dependency) so it is reused by BOTH the
|
||||
* JVM-unit-test software signer AND the framework `HardwareBackedKey` (`:client-tls-android`),
|
||||
* and so this security-load-bearing byte layout is unit-tested at JVM speed.
|
||||
*/
|
||||
public object EcPointEncoding {
|
||||
/** P-256 field element width in bytes (256 bits). */
|
||||
public const val P256_COORDINATE_BYTES: Int = 32
|
||||
|
||||
/** Uncompressed-point prefix (`0x04`) per SEC 1 §2.3.3. */
|
||||
private const val UNCOMPRESSED_PREFIX: Byte = 0x04
|
||||
|
||||
/**
|
||||
* Encode [publicKey]'s affine (x, y) as `0x04 || X(32) || Y(32)` (65 bytes). Each coordinate is
|
||||
* an unsigned big-endian integer left-padded (or, defensively, high-byte-trimmed) to exactly
|
||||
* [P256_COORDINATE_BYTES]. Throws [IllegalArgumentException] if a coordinate genuinely does not
|
||||
* fit 32 bytes (i.e. the key is not on a 256-bit curve).
|
||||
*/
|
||||
public fun x963(publicKey: ECPublicKey): ByteArray {
|
||||
val point = publicKey.w
|
||||
val x = toFixedLengthUnsigned(point.affineX, P256_COORDINATE_BYTES)
|
||||
val y = toFixedLengthUnsigned(point.affineY, P256_COORDINATE_BYTES)
|
||||
val out = ByteArray(1 + P256_COORDINATE_BYTES * 2)
|
||||
out[0] = UNCOMPRESSED_PREFIX
|
||||
System.arraycopy(x, 0, out, 1, P256_COORDINATE_BYTES)
|
||||
System.arraycopy(y, 0, out, 1 + P256_COORDINATE_BYTES, P256_COORDINATE_BYTES)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a non-negative [value] to a big-endian byte array of exactly [length] bytes. A
|
||||
* `BigInteger` may carry a leading 0x00 sign byte (drop it) or be shorter than [length]
|
||||
* (left-pad with zeros). A value that needs MORE than [length] significant bytes is rejected —
|
||||
* silently truncating a coordinate would corrupt the key.
|
||||
*/
|
||||
internal fun toFixedLengthUnsigned(value: BigInteger, length: Int): ByteArray {
|
||||
require(value.signum() >= 0) { "EC coordinate must be non-negative" }
|
||||
val raw = value.toByteArray() // big-endian, possibly with a leading 0x00 sign byte
|
||||
val start = if (raw.size > length && raw[0].toInt() == 0) 1 else 0
|
||||
val significant = raw.size - start
|
||||
require(significant <= length) { "EC coordinate does not fit $length bytes (got $significant)" }
|
||||
val out = ByteArray(length)
|
||||
System.arraycopy(raw, start, out, length - significant, significant)
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package wang.yaojia.webterm.api.enroll
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* B4 · Typed result of the one-time operator login (`POST /auth/login`). The [enrollToken] is a
|
||||
* short-lived `device:enroll` bearer — hold it ONLY for the immediately-following enroll call and
|
||||
* NEVER persist or log it. [accountId] identifies the tenant the device will be scoped under.
|
||||
*/
|
||||
public data class LoginResult(
|
||||
val enrollToken: String,
|
||||
val accountId: String,
|
||||
val expiresInSeconds: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* B4 · Typed result of a successful `POST /device/enroll` (or `/device/:id/renew`): the issued leaf
|
||||
* plus its issuer chain and rotation timing. The private key is NOT here — it stays non-exportable
|
||||
* in AndroidKeyStore. Mirrors iOS `EnrollmentResult`.
|
||||
*
|
||||
* NOTE: [certificate]/[caChain] are `ByteArray`, so the generated `data class` equality is by
|
||||
* reference (transient DER carriers, not value-equality keys) — compare with `contentEquals`.
|
||||
*/
|
||||
public data class EnrollmentResult(
|
||||
val deviceId: String,
|
||||
/** Leaf certificate DER (decoded from the response's base64). */
|
||||
val certificate: ByteArray,
|
||||
/** Issuer chain DERs (device-CA etc.), leaf excluded. */
|
||||
val caChain: List<ByteArray>,
|
||||
val notBefore: Instant?,
|
||||
val notAfter: Instant?,
|
||||
/** When to renew from the same hardware key (~2/3 of the lifetime). */
|
||||
val renewAfter: Instant?,
|
||||
) {
|
||||
/**
|
||||
* The rotation seam: is the leaf due for renewal as of [now]? A missing [renewAfter] never
|
||||
* triggers (fail-safe — the TLS stack is the real gate; the scheduler only pre-empts expiry).
|
||||
*/
|
||||
public fun isRenewalDue(now: Instant = Instant.now()): Boolean {
|
||||
val due = renewAfter ?: return false
|
||||
return !now.isBefore(due) // now >= renewAfter
|
||||
}
|
||||
}
|
||||
|
||||
/** Typed failures for the device-enrollment surface. Transport-level errors propagate UNWRAPPED. */
|
||||
public sealed class DeviceEnrollmentError(message: String) : Exception(message) {
|
||||
/**
|
||||
* A non-success HTTP status with the server's uniform `{ error }` code, if any (401
|
||||
* missing/rejected token, 403 subdomain-not-owned, 429 rate_limited, 400 rejected
|
||||
* CSR/subdomain). Never leaks the response body.
|
||||
*/
|
||||
public data class Http(val status: Int, val code: String?) :
|
||||
DeviceEnrollmentError("device enrollment rejected: HTTP $status" + (code?.let { " ($it)" } ?: ""))
|
||||
|
||||
/** A success body that did not decode to the expected shape (or an undecodable base64 cert). */
|
||||
public data object MalformedResponse :
|
||||
DeviceEnrollmentError("device enrollment response was not the expected shape")
|
||||
|
||||
/** A required request field was empty — rejected client-side BEFORE any network I/O. */
|
||||
public data object InvalidRequest :
|
||||
DeviceEnrollmentError("device enrollment request was missing a required field")
|
||||
}
|
||||
|
||||
// ── Wire DTOs + JSON config (internal to the enroll package) ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* ENCODE omits absent optionals (`encodeDefaults = false` drops the default-null `attestation`;
|
||||
* `explicitNulls = false` never writes an explicit `null`) and DECODE is tolerant of unknown keys
|
||||
* (the server is untrusted at this boundary). `keyAlg` carries NO default, so it is ALWAYS encoded.
|
||||
*/
|
||||
internal val EnrollJson: Json = Json {
|
||||
encodeDefaults = false
|
||||
explicitNulls = false
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
@Serializable
|
||||
internal data class LoginRequestBody(val password: String)
|
||||
|
||||
@Serializable
|
||||
internal data class EnrollRequestBody(
|
||||
val csr: String,
|
||||
val keyAlg: String,
|
||||
val subdomain: String,
|
||||
val deviceName: String,
|
||||
val attestation: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* The `/device/:id/renew` request body. The endpoint authenticates by the presented mTLS device cert
|
||||
* and its server schema is `{ csr }` ONLY (`.strict()`), so it carries the single new CSR and NO
|
||||
* enroll-only fields (keyAlg/subdomain/deviceName) — an extra key would be rejected as a 400.
|
||||
*/
|
||||
@Serializable
|
||||
internal data class RenewRequestBody(val csr: String)
|
||||
|
||||
/**
|
||||
* The `/device/:id/recover` request body — the EXPIRED leaf plus a fresh CSR over the same key.
|
||||
*
|
||||
* The lapsed certificate travels in the BODY (not as an mTLS client cert) because no TLS terminator
|
||||
* forwards an expired client certificate: nginx answers a bare 400 under `ssl_verify_client optional`,
|
||||
* and `optional_no_ca` tolerates only CHAIN errors, never `X509_V_ERR_CERT_HAS_EXPIRED`. Nothing is
|
||||
* lost: the CSR is self-signed by the same private key and the control-plane signer enforces CSR
|
||||
* proof-of-possession plus `CSR key == registered key`, so possession is proven exactly as the
|
||||
* handshake used to prove it. The server schema is `.strict()` on these two keys.
|
||||
*/
|
||||
@Serializable
|
||||
internal data class RecoverRequestBody(val cert: String, val csr: String)
|
||||
|
||||
@Serializable
|
||||
internal data class LoginResponseDto(
|
||||
val enrollToken: String,
|
||||
val accountId: String,
|
||||
val expiresIn: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class EnrollResponseDto(
|
||||
val deviceId: String,
|
||||
val cert: String,
|
||||
val caChain: List<String> = emptyList(),
|
||||
val notBefore: String? = null,
|
||||
val notAfter: String? = null,
|
||||
val renewAfter: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* The `/device/:id/renew` and `/device/:id/recover` 201 body — `{ cert, caChain, notAfter }` ONLY.
|
||||
*
|
||||
* Deliberately NOT [EnrollResponseDto]: the re-issuance routes do not echo `deviceId` (nor
|
||||
* `notBefore`/`renewAfter`) — only `/device/enroll` does (`control-plane/src/api/renew.ts` vs
|
||||
* `device-enroll.ts`). Decoding a renew 201 against the enroll DTO, whose `deviceId` is required,
|
||||
* failed EVERY silent rotation with `MalformedResponse`. The device id is supplied by the caller (it
|
||||
* is the path segment the request was addressed to, which is the authoritative value anyway), and the
|
||||
* absent `renewAfter` is re-derived from the leaf by `RotationPolicy.renewAfterFor`.
|
||||
*/
|
||||
@Serializable
|
||||
internal data class RenewResponseDto(
|
||||
val cert: String,
|
||||
val caChain: List<String> = emptyList(),
|
||||
val notBefore: String? = null,
|
||||
val notAfter: String? = null,
|
||||
val renewAfter: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class ErrorDto(val error: String? = null)
|
||||
|
||||
/** Parse an ISO-8601 instant, degrading an absent/unparseable value to null (dates are advisory). */
|
||||
internal fun parseInstantOrNull(text: String?): Instant? {
|
||||
if (text == null) return null
|
||||
return runCatching { Instant.parse(text) }.getOrNull()
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package wang.yaojia.webterm.api.enroll
|
||||
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Rotation timing of the installed device identity — the input to [RotationPolicy]. Android analogue
|
||||
* of iOS `DeviceRenewalState`.
|
||||
*
|
||||
* [notAfter] is the leaf's HARD expiry (past it the TLS stack rejects the cert outright) and
|
||||
* [renewAfter] is the advisory "renew from the same key now" instant. Only non-secret timing plus the
|
||||
* non-secret [deviceId] (which is a URL path segment) surface here: the private key never leaves
|
||||
* AndroidKeyStore and the cert bytes are never carried through this type, so it is safe to log.
|
||||
*/
|
||||
public data class DeviceRenewalState(
|
||||
/** The enrolled device id — the `:id` in `POST /device/:id/renew` and `/device/:id/recover`. */
|
||||
val deviceId: String,
|
||||
val notAfter: Instant?,
|
||||
val renewAfter: Instant?,
|
||||
) {
|
||||
/**
|
||||
* Is the leaf due for renewal as of [now]? A missing [renewAfter] NEVER triggers (fail-safe — the
|
||||
* TLS stack is the real gate; the scheduler only pre-empts expiry). Mirrors
|
||||
* [EnrollmentResult.isRenewalDue] and iOS `DeviceRenewalState.isRenewalDue`.
|
||||
*/
|
||||
public fun isRenewalDue(now: Instant): Boolean {
|
||||
val due = renewAfter ?: return false
|
||||
return !now.isBefore(due) // now >= renewAfter
|
||||
}
|
||||
}
|
||||
|
||||
/** What a scheduler must do for one rotation pass — the total answer of [RotationPolicy.decide]. */
|
||||
public enum class RotationDecision {
|
||||
/** Nothing to do: the renew window has not opened (the cheap, common case). */
|
||||
NOT_DUE,
|
||||
|
||||
/** An attempt IS due but the previous one failed too recently — wait, do not hammer. */
|
||||
BACKING_OFF,
|
||||
|
||||
/** Still valid: re-CSR from the same key and renew over mTLS (`POST /device/:id/renew`). */
|
||||
RENEW,
|
||||
|
||||
/**
|
||||
* EXPIRED but inside the recovery grace: the leaf can no longer authenticate its own re-issuance,
|
||||
* so recover over PLAIN HTTPS with the lapsed cert in the body (`POST /device/:id/recover`).
|
||||
*/
|
||||
RECOVER,
|
||||
|
||||
/** TERMINAL — expired past the grace window. No request can succeed; only a fresh enroll can. */
|
||||
RE_ENROLL_REQUIRED,
|
||||
}
|
||||
|
||||
/**
|
||||
* The pure, JVM-testable rotation decision table (repo precedent: `ReconnectMachine`, `LayoutPolicy`,
|
||||
* `TerminalGridMath`). Given "now", the leaf's timing and the last failed attempt it answers
|
||||
* renew / recover / wait / re-enroll — no clock, no keystore, no network, so every branch is a unit
|
||||
* test rather than a device observation.
|
||||
*
|
||||
* Ported from iOS `CertificateRotationScheduler`'s timing rules, plus the two branches the phone
|
||||
* track needs and iOS lacks (it can only renew, so an iOS device whose leaf lapses is stranded):
|
||||
* - [RotationDecision.RECOVER] — the `/device/:id/recover` escape hatch, because no TLS terminator
|
||||
* forwards an expired client certificate, so a lapsed leaf can never authenticate its own renewal.
|
||||
* - [RotationDecision.RE_ENROLL_REQUIRED] — reported ONCE as terminal instead of retrying forever
|
||||
* (the host-track agent logged the same failure 6380 times before this rule existed).
|
||||
*/
|
||||
public object RotationPolicy {
|
||||
/**
|
||||
* The control plane issues `renewAfter = notBefore + 2/3 · lifetime`
|
||||
* (`control-plane/src/api/device-enroll.ts` `DEFAULT_RENEW_FRACTION`). The client mirrors the
|
||||
* fraction as exact integer duration math so [renewAfterFor] lands on the same instant.
|
||||
*/
|
||||
private const val RENEW_FRACTION_NUMERATOR: Long = 2L
|
||||
private const val RENEW_FRACTION_DENOMINATOR: Long = 3L
|
||||
|
||||
/**
|
||||
* How long after `notAfter` a lapsed leaf may still be swapped via `/device/:id/recover`
|
||||
* (30 days) — the same window the control plane's `DEFAULT_EXPIRED_RENEW_GRACE_MS` allows. Asking
|
||||
* outside it is pointless: the server answers 401.
|
||||
*/
|
||||
public val DEFAULT_EXPIRED_RECOVERY_GRACE: Duration = Duration.ofDays(30)
|
||||
|
||||
/**
|
||||
* Minimum gap after a FAILED attempt before another is made. A pass runs on every app
|
||||
* foreground, and the control plane rate-limits renewals to 30/hour per identity, so an
|
||||
* un-throttled retry converts one transient failure into a 429 storm.
|
||||
*/
|
||||
public val DEFAULT_FAILURE_BACKOFF: Duration = Duration.ofMinutes(15)
|
||||
|
||||
/**
|
||||
* Derive the advisory renew instant from the leaf's own validity window.
|
||||
*
|
||||
* This derivation is LOAD-BEARING, not a convenience: `POST /device/:id/renew` and
|
||||
* `POST /device/:id/recover` answer `{cert, caChain, notAfter}` only — neither returns
|
||||
* `renewAfter` (only `/device/enroll` does). A client that persisted the enroll-time value and
|
||||
* never recomputed it would rotate exactly once and then report "not due" until the cert died.
|
||||
*
|
||||
* Returns null when either bound is unknown or the window is not a lifetime (notAfter <=
|
||||
* notBefore) — fail-safe, because [decide] treats a null [DeviceRenewalState.renewAfter] as
|
||||
* never-due rather than inventing a past instant that would renew-storm.
|
||||
*/
|
||||
public fun renewAfterFor(notBefore: Instant?, notAfter: Instant?): Instant? {
|
||||
if (notBefore == null || notAfter == null) return null
|
||||
if (!notBefore.isBefore(notAfter)) return null
|
||||
val lifetime = Duration.between(notBefore, notAfter)
|
||||
return notBefore.plus(
|
||||
lifetime.multipliedBy(RENEW_FRACTION_NUMERATOR).dividedBy(RENEW_FRACTION_DENOMINATOR),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole decision table, in priority order:
|
||||
* 1. expired past [expiredRecoveryGrace] → [RotationDecision.RE_ENROLL_REQUIRED] (terminal; it
|
||||
* outranks the backoff because no amount of waiting can help),
|
||||
* 2. otherwise pick the desired action — expired → RECOVER, [DeviceRenewalState.isRenewalDue] →
|
||||
* RENEW, else NOT_DUE,
|
||||
* 3. an idle leaf stays [RotationDecision.NOT_DUE] (there is no attempt to throttle),
|
||||
* 4. a desired action within [failureBackoff] of [lastFailureAt] → [RotationDecision.BACKING_OFF].
|
||||
*
|
||||
* @param lastFailureAt when the last attempt FAILED (null = none, or the last one succeeded).
|
||||
*/
|
||||
public fun decide(
|
||||
state: DeviceRenewalState,
|
||||
now: Instant,
|
||||
lastFailureAt: Instant? = null,
|
||||
expiredRecoveryGrace: Duration = DEFAULT_EXPIRED_RECOVERY_GRACE,
|
||||
failureBackoff: Duration = DEFAULT_FAILURE_BACKOFF,
|
||||
): RotationDecision {
|
||||
require(!expiredRecoveryGrace.isNegative) { "expiredRecoveryGrace must not be negative" }
|
||||
require(!failureBackoff.isNegative) { "failureBackoff must not be negative" }
|
||||
|
||||
val notAfter = state.notAfter
|
||||
// Terminal first: past `notAfter + grace` nothing can succeed. A non-negative grace makes this
|
||||
// strictly stronger than "expired", so it needs no separate expiry guard.
|
||||
if (notAfter != null && now.isAfter(notAfter.plus(expiredRecoveryGrace))) {
|
||||
return RotationDecision.RE_ENROLL_REQUIRED
|
||||
}
|
||||
val isExpired = notAfter != null && now.isAfter(notAfter)
|
||||
|
||||
val desired = when {
|
||||
isExpired -> RotationDecision.RECOVER
|
||||
state.isRenewalDue(now) -> RotationDecision.RENEW
|
||||
else -> RotationDecision.NOT_DUE
|
||||
}
|
||||
if (desired == RotationDecision.NOT_DUE) return RotationDecision.NOT_DUE
|
||||
if (lastFailureAt != null && now.isBefore(lastFailureAt.plus(failureBackoff))) {
|
||||
return RotationDecision.BACKING_OFF
|
||||
}
|
||||
return desired
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package wang.yaojia.webterm.api.models
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* One commit from `git log` (`src/types.ts` `CommitLogEntry`). [hash] and [at] are REQUIRED — a
|
||||
* commit missing either is dropped by the list-lossy [CommitLogEntryListSerializer] (its siblings
|
||||
* survive). [subject] defaults to empty so a subject-less commit still decodes. `at` = `%ct * 1000`
|
||||
* (epoch millis). All fields are rendered INERT (plain text; no autolink) at the screen (plan §8).
|
||||
*/
|
||||
@Serializable
|
||||
public data class CommitLogEntry(
|
||||
val hash: String,
|
||||
val at: Long,
|
||||
val subject: String = "",
|
||||
/**
|
||||
* w6/G4: reachable from HEAD but NOT from `@{u}` — i.e. this commit has not been pushed.
|
||||
*
|
||||
* Server-computed from `rev-list`, and deliberately NOT "the first N rows": `git log` is
|
||||
* date-ordered, so merging an older branch interleaves unpushed commits BELOW pushed ones. The
|
||||
* row-count shortcut fails in the dangerous direction — it would call an unpushed commit pushed.
|
||||
* Absent ⇒ unknown (no upstream to compare against), which must not render as "pushed".
|
||||
*/
|
||||
val unpushed: Boolean? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /projects/log` result (`src/types.ts` `GitLogResult`). [truncated] = more commits exist
|
||||
* beyond the server cap. The commit list decodes lossily (drop-one-keep-rest).
|
||||
*/
|
||||
@Serializable
|
||||
public data class GitLogResult(
|
||||
@Serializable(with = CommitLogEntryListSerializer::class)
|
||||
val commits: List<CommitLogEntry> = emptyList(),
|
||||
val truncated: Boolean = false,
|
||||
/**
|
||||
* w6/G4: upstream short name used to label the pushed/unpushed boundary. Null ⇒ nothing to compare
|
||||
* against, so **no boundary may be drawn at all** — not a boundary drawn at position zero.
|
||||
*/
|
||||
val upstream: String? = null,
|
||||
) {
|
||||
/**
|
||||
* Index of the LAST unpushed commit, or null when no boundary may be drawn.
|
||||
*
|
||||
* The boundary is drawn ONCE, after this index — never once per unpushed commit, and never at all
|
||||
* when [upstream] is absent. Returns null rather than -1 so a caller cannot accidentally treat
|
||||
* "no boundary" as "boundary before everything".
|
||||
*/
|
||||
public val upstreamBoundaryIndex: Int?
|
||||
get() {
|
||||
if (upstream == null) return null
|
||||
val last = commits.indexOfLast { it.unpushed == true }
|
||||
return last.takeIf { it >= 0 }
|
||||
}
|
||||
|
||||
/** How many commits are waiting to be pushed; 0 when unknown or nothing is pending. */
|
||||
public val unpushedCount: Int get() = commits.count { it.unpushed == true }
|
||||
}
|
||||
|
||||
/** Drops a commit missing `hash`/`at`, keeps the rest (nested list-lossy, like worktrees). */
|
||||
internal object CommitLogEntryListSerializer :
|
||||
KSerializer<List<CommitLogEntry>> by LossyListSerializer(CommitLogEntry.serializer())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user