Compare commits
137 Commits
| 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 |
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
|
||||||
153
.github/workflows/ios.yml
vendored
153
.github/workflows/ios.yml
vendored
@@ -6,8 +6,9 @@
|
|||||||
# the test binary (a known flaw, fix assigned to T-iOS-16). The corrected
|
# 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
|
# per-package filter lives in ios/IntegrationTests/scripts/coverage-gate.sh
|
||||||
# (jq keeps only Packages/<P>/Sources/, excluding *Placeholder*).
|
# (jq keeps only Packages/<P>/Sources/, excluding *Placeholder*).
|
||||||
# 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle,
|
# 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle) on
|
||||||
# iPhone 16 simulator): ViewModels/components of the app glue layer.
|
# 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
|
# 3. integration-tests — Swift Testing against the REAL Node server. The
|
||||||
# ServerHarness self-bootstraps `tsx src/server.ts` on an ephemeral
|
# ServerHarness self-bootstraps `tsx src/server.ts` on an ephemeral
|
||||||
# loopback port (127.0.0.1:<free-port> is always in the derived Origin
|
# loopback port (127.0.0.1:<free-port> is always in the derived Origin
|
||||||
@@ -35,14 +36,15 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Layer 1: pure-SwiftPM package tests + the 80% own-sources coverage gate.
|
# Layer 1: pure-SwiftPM package tests + the 80% own-sources coverage gate.
|
||||||
# The gate covers exactly the 4 gated packages (plan §9); TestSupport runs
|
# The gate covers the 4 packages of plan §9 PLUS ClientTLS (added by B4 — the
|
||||||
# tests below without a gate (test doubles are excluded from the gate).
|
# 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:
|
package-tests:
|
||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
package: [WireProtocol, SessionCore, HostRegistry, APIClient]
|
package: [WireProtocol, SessionCore, HostRegistry, APIClient, ClientTLS]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Select Xcode 16.3
|
- name: Select Xcode 16.3
|
||||||
@@ -56,47 +58,66 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Select Xcode 16.3
|
- name: Select Xcode 16.3
|
||||||
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
||||||
- name: swift test (TestSupport — no coverage gate, plan §9 gates 4 packages)
|
- name: swift test (TestSupport — no coverage gate; it IS the test doubles)
|
||||||
run: swift test --package-path ios/Packages/TestSupport
|
run: swift test --package-path ios/Packages/TestSupport
|
||||||
|
|
||||||
# Layer 2: app-target unit tests (WebTermTests, hosted by the app).
|
# 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:
|
app-tests:
|
||||||
runs-on: macos-15
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Select Xcode 16.3
|
- name: Select Xcode 16.3
|
||||||
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
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
|
- name: Install XcodeGen
|
||||||
run: brew install xcodegen
|
run: brew install xcodegen
|
||||||
- name: Generate project
|
- name: Generate project
|
||||||
run: cd ios && xcodegen generate
|
run: cd ios && xcodegen generate
|
||||||
- name: xcodebuild test (WebTermTests, iPhone 16 simulator)
|
- name: xcodebuild test (WebTermTests, ${{ matrix.device }} simulator)
|
||||||
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests exercises the
|
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests exercises the
|
||||||
# real data-protection keychain, which returns -34018 for UNSIGNED test
|
# real data-protection keychain, which returns -34018 for UNSIGNED test
|
||||||
# hosts; simulator ad-hoc signing needs no certificates. (W5-fix
|
# hosts; simulator ad-hoc signing needs no certificates. (W5-fix
|
||||||
# handoff finding, verified locally in the ui-test leg runs.)
|
# handoff finding, verified locally in the ui-test leg runs.)
|
||||||
run: |
|
run: |
|
||||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
-destination 'platform=iOS Simulator,name=${{ matrix.device }}' \
|
||||||
test
|
${{ matrix.extraArgs }} \
|
||||||
|
|
||||||
# iPad adaptation (T-iPad-1): run the same app suite on an iPad simulator so
|
|
||||||
# the adaptive layout (regular size class / NavigationSplitView, T-iPad-2) is
|
|
||||||
# exercised in CI, not only the compact iPhone path.
|
|
||||||
ipad-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: Install XcodeGen
|
|
||||||
run: brew install xcodegen
|
|
||||||
- name: Generate project
|
|
||||||
run: cd ios && xcodegen generate
|
|
||||||
- name: xcodebuild test (WebTermTests, iPad Pro 11-inch simulator)
|
|
||||||
run: |
|
|
||||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
|
||||||
-destination 'platform=iOS Simulator,name=iPad Pro 11-inch (M4)' \
|
|
||||||
test
|
test
|
||||||
|
|
||||||
# Layer 3: contract tests against the real Node server (T-iOS-16 test list).
|
# Layer 3: contract tests against the real Node server (T-iOS-16 test list).
|
||||||
@@ -121,9 +142,27 @@ jobs:
|
|||||||
# runner process reads WEBTERM_SERVER_URL (delivered via xcodebuild's
|
# runner process reads WEBTERM_SERVER_URL (delivered via xcodebuild's
|
||||||
# TEST_RUNNER_ env prefix) and makes its own HTTP assertions against the
|
# TEST_RUNNER_ env prefix) and makes its own HTTP assertions against the
|
||||||
# server (/live-sessions, /live-sessions/:id/preview, held /hook/permission).
|
# 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:
|
ui-test:
|
||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
timeout-minutes: 45
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Select Xcode 16.3
|
- name: Select Xcode 16.3
|
||||||
@@ -159,7 +198,7 @@ jobs:
|
|||||||
# inputAccessoryView — it only exists while the SOFT keyboard is up.
|
# inputAccessoryView — it only exists while the SOFT keyboard is up.
|
||||||
- name: Disable simulator hardware keyboard (KeyBar rides the soft keyboard)
|
- name: Disable simulator hardware keyboard (KeyBar rides the soft keyboard)
|
||||||
run: defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false
|
run: defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false
|
||||||
- name: xcodebuild test (WebTermUITests, iPhone 16 simulator)
|
- name: xcodebuild test (WebTermUITests, ${{ matrix.device }} simulator)
|
||||||
# TEST_RUNNER_<VAR> must be an ENV VAR of the xcodebuild process (it
|
# TEST_RUNNER_<VAR> must be an ENV VAR of the xcodebuild process (it
|
||||||
# strips the prefix and injects <VAR> into the test-runner process);
|
# strips the prefix and injects <VAR> into the test-runner process);
|
||||||
# passing it as a KEY=VALUE argument makes it a build setting, which
|
# passing it as a KEY=VALUE argument makes it a build setting, which
|
||||||
@@ -172,7 +211,8 @@ jobs:
|
|||||||
TEST_RUNNER_WEBTERM_SERVER_URL: http://127.0.0.1:3217
|
TEST_RUNNER_WEBTERM_SERVER_URL: http://127.0.0.1:3217
|
||||||
run: |
|
run: |
|
||||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTermUITests \
|
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTermUITests \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 16' test
|
-destination 'platform=iOS Simulator,name=${{ matrix.device }}' \
|
||||||
|
${{ matrix.suites }} test
|
||||||
- name: Server log + shutdown
|
- name: Server log + shutdown
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
@@ -181,15 +221,21 @@ jobs:
|
|||||||
|
|
||||||
# Device-matrix floor (plan §9: "iOS 17 最低目标模拟器各一轮"): run the
|
# Device-matrix floor (plan §9: "iOS 17 最低目标模拟器各一轮"): run the
|
||||||
# WebTermTests unit bundle on an iOS 17.x simulator runtime.
|
# WebTermTests unit bundle on an iOS 17.x simulator runtime.
|
||||||
# CI-ONLY LEG: GitHub macOS runner images ship older Xcode versions whose
|
#
|
||||||
# iOS 17.x simulator runtime is registered system-wide with CoreSimulator, so
|
# CI-ONLY LEG: local machines are NOT expected to hold the ~7 GB iOS 17
|
||||||
# Xcode 16.x can build against it. Local machines are NOT expected to
|
# runtime, so this leg is never reproducible on a dev box.
|
||||||
# download the ~8 GB runtime. If the runner image drops the 17.x runtime,
|
#
|
||||||
# the leg skips WITH A LOUD NOTICE; if the runtime exists, test failures
|
# NO SILENT SKIP (B4 fix): this step used to `exit 0` with a ::notice when the
|
||||||
# fail the job (no silent pass).
|
# 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:
|
ios17-floor-tests:
|
||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
timeout-minutes: 45
|
timeout-minutes: 90 # the runtime download alone can take ~15 min
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Select Xcode 16.3 (build toolchain)
|
- name: Select Xcode 16.3 (build toolchain)
|
||||||
@@ -198,23 +244,40 @@ jobs:
|
|||||||
run: brew install xcodegen
|
run: brew install xcodegen
|
||||||
- name: Generate project
|
- name: Generate project
|
||||||
run: cd ios && xcodegen generate
|
run: cd ios && xcodegen generate
|
||||||
- name: Create iOS 17.x simulator (skip-with-notice if runtime absent)
|
- name: Ensure an iOS 17.x simulator runtime (download if the image lacks it)
|
||||||
id: sim17
|
id: sim17
|
||||||
|
env:
|
||||||
|
IOS17_FALLBACK_VERSION: "17.5"
|
||||||
run: |
|
run: |
|
||||||
runtime="$(xcrun simctl list runtimes | grep -Eo 'com\.apple\.CoreSimulator\.SimRuntime\.iOS-17-[0-9]+' | tail -n 1 || true)"
|
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
|
if [ -z "$runtime" ]; then
|
||||||
echo "::notice title=iOS 17 floor leg skipped::no iOS 17.x simulator runtime on this runner image (image drift) — the deployment-floor round did NOT run"
|
echo "::warning title=iOS 17 runtime absent::downloading iOS ${IOS17_FALLBACK_VERSION} simulator runtime (runner image drift)"
|
||||||
echo "runtime=" >> "$GITHUB_OUTPUT"
|
sudo xcodebuild -downloadPlatform iOS -buildVersion "$IOS17_FALLBACK_VERSION" || true
|
||||||
exit 0
|
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
|
fi
|
||||||
udid="$(xcrun simctl create 'iPhone 15 iOS17' 'iPhone 15' "$runtime")"
|
udid="$(xcrun simctl create 'iPhone 15 iOS17' 'iPhone 15' "$runtime")"
|
||||||
echo "created $udid with $runtime"
|
echo "created $udid with $runtime"
|
||||||
echo "runtime=$runtime" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "udid=$udid" >> "$GITHUB_OUTPUT"
|
echo "udid=$udid" >> "$GITHUB_OUTPUT"
|
||||||
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests needs a signed
|
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests needs a signed
|
||||||
# (ad-hoc, certificate-free on simulator) app host — unsigned = -34018.
|
# (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)
|
- name: xcodebuild test (WebTermTests on the iOS 17.x floor)
|
||||||
if: steps.sim17.outputs.runtime != ''
|
|
||||||
run: |
|
run: |
|
||||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
||||||
-destination "platform=iOS Simulator,id=${{ steps.sim17.outputs.udid }}" test
|
-destination "platform=iOS Simulator,id=${{ steps.sim17.outputs.udid }}" \
|
||||||
|
-skip-testing:WebTermTests/LiveServerSmokeTests \
|
||||||
|
test
|
||||||
|
|||||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -3,6 +3,13 @@ node_modules/
|
|||||||
|
|
||||||
# build output
|
# build output
|
||||||
dist/
|
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/
|
public/build/
|
||||||
desktop/build/
|
desktop/build/
|
||||||
desktop/dist-app/
|
desktop/dist-app/
|
||||||
@@ -10,6 +17,9 @@ desktop/dist-app/
|
|||||||
# local Claude Code settings (not shared)
|
# local Claude Code settings (not shared)
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# per-session git worktrees (EnterWorktree) — live on disk, never committed
|
||||||
|
.claude/worktrees/
|
||||||
|
|
||||||
# logs / OS cruft
|
# logs / OS cruft
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
@@ -18,3 +28,6 @@ npm-debug.log*
|
|||||||
# test coverage
|
# test coverage
|
||||||
coverage/
|
coverage/
|
||||||
.gstack/
|
.gstack/
|
||||||
|
|
||||||
|
# deploy secrets (RELAY-PHASE1) — .env.example is committed, .env is not
|
||||||
|
deploy/.env
|
||||||
|
|||||||
22
CLAUDE.md
22
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*.
|
**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)
|
## 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:
|
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.
|
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)
|
## 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.
|
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.
|
||||||
|
|||||||
63
README.md
63
README.md
@@ -4,7 +4,7 @@ A self-hosted, browser-based terminal **and** Claude-Code session/project workbe
|
|||||||
|
|
||||||
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.
|
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 & deployment](#security--deployment).
|
> ⚠️ **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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -24,6 +24,14 @@ Sessions survive disconnects: the shell (and whatever's running in it) keeps goi
|
|||||||
- **Sessions ↔ Projects toggle** — a segmented control on the home screen flips between the running-sessions view and the projects view.
|
- **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.
|
- **⌂ 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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
### Claude Code cockpit
|
### 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`.
|
- **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).
|
- **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).
|
||||||
@@ -34,7 +42,8 @@ Sessions survive disconnects: the shell (and whatever's running in it) keeps goi
|
|||||||
### Projects (v0.6)
|
### 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.
|
- **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).
|
- **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 a **create-worktree** action.
|
- **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)
|
### 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.
|
- **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.
|
||||||
@@ -51,19 +60,35 @@ Sessions survive disconnects: the shell (and whatever's running in it) keeps goi
|
|||||||
|
|
||||||
## Clients
|
## Clients
|
||||||
|
|
||||||
The browser is the reference client; two native clients and a remote-access
|
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
|
service are also in the repo (all consume the same wire protocol — the server
|
||||||
stays a byte-shuttle).
|
stays a byte-shuttle).
|
||||||
|
|
||||||
- **iOS / iPad app** ([`ios/`](ios/), branch `feat/ios-client`) — a native
|
- **iOS / iPad app** ([`ios/`](ios/)) — a native SwiftUI + SwiftTerm **pure
|
||||||
SwiftUI + SwiftTerm **pure remote client** built for the walk-away loop:
|
remote client** built for the walk-away loop: native terminal with scrollback
|
||||||
native terminal with scrollback replay, QR/manual pairing (Keychain), the
|
replay, QR/manual pairing (Keychain), the remote **Approve / Reject** gate +
|
||||||
remote **Approve / Reject** gate + three-way plan gate, **APNs push with
|
three-way plan gate, **APNs push with lock-screen Allow / Deny behind Face ID**,
|
||||||
lock-screen Allow / Deny behind Face ID**, deep links, Projects, multi-session
|
deep links (including the web's `?join=` share link), Projects with a **git
|
||||||
switcher, timeline, diff, quick-reply, and an **adaptive iPhone/iPad split
|
panel + worktree lifecycle**, `claude --resume` history, multi-session switcher,
|
||||||
layout**. Looks like the desktop (amber-gold, dark-first). P0 needs zero server
|
timeline, diff, quick-reply, **terminal find bar**, **voice push-to-talk with a
|
||||||
changes; P1 adds two declared additive touch-points. See
|
confirm step**, theme (system/dark/light) + Dynamic Type, and an **adaptive
|
||||||
[`ios/README.md`](ios/README.md). *(Not yet merged.)*
|
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
|
- **Desktop app** ([`desktop/`](desktop/)) — a Mac/Windows **Electron shell that
|
||||||
embeds this Node server + node-pty** (all-in-one), for native notifications,
|
embeds this Node server + node-pty** (all-in-one), for native notifications,
|
||||||
tray, deep links and launch-at-login. Design in
|
tray, deep links and launch-at-login. Design in
|
||||||
@@ -107,11 +132,13 @@ npm run setup-hooks # adds the hooks + statusLine to ~/.claude/s
|
|||||||
```
|
```
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
> **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`.
|
||||||
|
|
||||||
`USE_TMUX=1 npm start` keeps sessions alive across a server restart.
|
`USE_TMUX=1 npm start` keeps sessions alive across a server restart.
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
```bash
|
```bash
|
||||||
npm test # vitest, all modules (~470 tests, 80% coverage gate)
|
npm test # vitest, all modules (~1600 tests, 80% coverage gate)
|
||||||
npm run typecheck # tsc (backend + frontend)
|
npm run typecheck # tsc (backend + frontend)
|
||||||
npm run build # compile backend to dist/
|
npm run build # compile backend to dist/
|
||||||
```
|
```
|
||||||
@@ -136,6 +163,7 @@ All config is via environment variables (no hardcoding). Invalid values fail fas
|
|||||||
| `MAX_MSGS_PER_SEC` | `2000` | Per-connection WS frame-rate cap; over-limit frames are dropped (not disconnected). |
|
| `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. |
|
| `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`. |
|
| `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. |
|
| `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. |
|
| `REAP_INTERVAL_MS` | `60000` | Idle-reaper / stuck-sweep tick interval. |
|
||||||
| `PREVIEW_BYTES` | `24576` (24 KB) | Tail of scrollback served for live preview thumbnails. |
|
| `PREVIEW_BYTES` | `24576` (24 KB) | Tail of scrollback served for live preview thumbnails. |
|
||||||
@@ -181,14 +209,15 @@ All config is via environment variables (no hardcoding). Invalid values fail fas
|
|||||||
|
|
||||||
## Security & deployment
|
## Security & deployment
|
||||||
|
|
||||||
This is a **no-auth, LAN-only** tool by design — it hands a full shell to anyone who can reach the port. The defenses that matter:
|
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.
|
- **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`) requires an allowed Origin (403 otherwise). Read-only discovery routes don't.
|
- **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.
|
- **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.
|
- **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).
|
- **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).
|
||||||
- **No authentication yet.** 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.
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -198,6 +227,6 @@ The server is a **byte-shuttle, not a terminal**: `node-pty` gives the shell a r
|
|||||||
|
|
||||||
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.
|
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** (~470 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.
|
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).
|
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).
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"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": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
"@types/ws": "^8.5.12",
|
"@types/ws": "^8.5.12",
|
||||||
"@vitest/coverage-v8": "^4.1.9",
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
|
"esbuild": "^0.28.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^4.1.9"
|
"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(''),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,15 +13,48 @@ import type { AgentConfig } from '../config/agentConfig.js'
|
|||||||
import type { AgentIdentity } from '../keys/identity.js'
|
import type { AgentIdentity } from '../keys/identity.js'
|
||||||
import type { Keystore } from '../keys/keystore.js'
|
import type { Keystore } from '../keys/keystore.js'
|
||||||
import type { TimerLike } from '../transport/seams.js'
|
import type { TimerLike } from '../transport/seams.js'
|
||||||
|
import { createBackoff, type BackoffPolicy } from '../transport/backoff.js'
|
||||||
import { buildCsr } from '../enroll/csr.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
|
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 {
|
export interface CertRotator {
|
||||||
start(): void
|
start(): void
|
||||||
stop(): void
|
stop(): void
|
||||||
onRotated(cb: () => void): void
|
onRotated(cb: () => void): void
|
||||||
onRevoked(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'
|
export type RenewOutcome = 'rotated' | 'revoked'
|
||||||
@@ -31,6 +64,23 @@ export function renewalUrlFor(cfg: AgentConfig): string {
|
|||||||
return cfg.enrollUrl.replace(/\/enroll$/, '/renew')
|
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. */
|
/** Ms until (validTo − renewBeforeMs), clamped to ≥ 0. */
|
||||||
export function computeRenewDelayMs(
|
export function computeRenewDelayMs(
|
||||||
certPem: string,
|
certPem: string,
|
||||||
@@ -52,20 +102,49 @@ export async function renewCert(
|
|||||||
id: AgentIdentity,
|
id: AgentIdentity,
|
||||||
ks: Keystore,
|
ks: Keystore,
|
||||||
fetchImpl: typeof fetch,
|
fetchImpl: typeof fetch,
|
||||||
|
opts: { url?: string } = {},
|
||||||
): Promise<RenewOutcome> {
|
): Promise<RenewOutcome> {
|
||||||
const csr = buildCsr(id, cfg.subdomain ?? 'web-terminal-agent')
|
const csr = buildCsr(id, cfg.subdomain ?? 'web-terminal-agent')
|
||||||
const res = await fetchImpl(renewalUrlFor(cfg), {
|
const res = await fetchImpl(opts.url ?? renewalUrlFor(cfg), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ csr }),
|
body: JSON.stringify({ csr }),
|
||||||
})
|
})
|
||||||
if (res.status === 403) return 'revoked'
|
if (res.status === 403) return 'revoked'
|
||||||
if (!res.ok) throw new Error(`cert renewal failed: HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`cert renewal failed: HTTP ${res.status}`)
|
||||||
const json = (await res.json()) as { cert?: string; caChain?: string }
|
// The control-plane returns cert=base64(DER) + caChain=base64(DER)[]; normalize to PEM for the
|
||||||
if (typeof json.cert !== 'string' || typeof json.caChain !== 'string') {
|
// keystore + frpc (same shape as native enroll).
|
||||||
throw new Error('cert renewal response missing cert/caChain')
|
const json = (await res.json()) as { cert?: unknown; caChain?: unknown }
|
||||||
}
|
const { certPem, caChainPem } = certResponseToPem(json.cert, json.caChain)
|
||||||
ks.saveCert(json.cert, json.caChain) // atomic whole-file install
|
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'
|
return 'rotated'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +158,12 @@ export function createCertRotator(
|
|||||||
fetchImpl?: typeof fetch
|
fetchImpl?: typeof fetch
|
||||||
now?: () => Date
|
now?: () => Date
|
||||||
parseCert?: (pem: string) => 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 {
|
): CertRotator {
|
||||||
const renewBeforeMs = opts.renewBeforeMs ?? DEFAULT_RENEW_BEFORE_MS
|
const renewBeforeMs = opts.renewBeforeMs ?? DEFAULT_RENEW_BEFORE_MS
|
||||||
@@ -90,10 +175,15 @@ export function createCertRotator(
|
|||||||
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
|
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
|
||||||
}
|
}
|
||||||
const doFetch = opts.fetchImpl ?? fetch
|
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 now = opts.now ?? (() => new Date())
|
||||||
|
const retryBackoff = opts.retryBackoff ?? createBackoff({ jitter: true })
|
||||||
let handle: unknown = null
|
let handle: unknown = null
|
||||||
let rotatedCb: (() => void) | null = null
|
let rotatedCb: (() => void) | null = null
|
||||||
let revokedCb: (() => 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 {
|
function schedule(): void {
|
||||||
const certs = ks.loadCert()
|
const certs = ks.loadCert()
|
||||||
@@ -102,19 +192,49 @@ export function createCertRotator(
|
|||||||
handle = timer.setTimeout(runRenewal, delay)
|
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 {
|
function runRenewal(): void {
|
||||||
void renewCert(cfg, id, ks, doFetch)
|
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) => {
|
.then((outcome) => {
|
||||||
if (outcome === 'revoked') {
|
if (outcome === 'revoked') {
|
||||||
revokedCb?.()
|
revokedCb?.()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
retryBackoff.reset() // a healthy renewal clears the retry backoff for the next cycle
|
||||||
rotatedCb?.()
|
rotatedCb?.()
|
||||||
schedule()
|
schedule()
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((err: unknown) => {
|
||||||
// network error: retry after renewBeforeMs; the tunnel stays up meanwhile.
|
// Network/HTTP failure (never a 403 revoke): surface it (caller logs, no secret) and retry
|
||||||
handle = timer.setTimeout(runRenewal, renewBeforeMs)
|
// 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())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,5 +252,11 @@ export function createCertRotator(
|
|||||||
onRevoked(cb): void {
|
onRevoked(cb): void {
|
||||||
revokedCb = cb
|
revokedCb = cb
|
||||||
},
|
},
|
||||||
|
onError(cb): void {
|
||||||
|
errorCb = cb
|
||||||
|
},
|
||||||
|
onExhausted(cb): void {
|
||||||
|
exhaustedCb = cb
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* CLI entrypoint — PLAN_RELAY_AGENT T5. `pair | run | status | install | uninstall`.
|
* CLI entrypoint — PLAN_RELAY_AGENT T5, extended for the native tunnel (PLAN_TUNNEL_AUTOMATION B5).
|
||||||
* All side effects (network/FS/tunnel) are injected via `CliDeps` so tests avoid real IO.
|
* `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).
|
* `status` prints host_id/subdomain/online ONLY — never key/cert material (INV9).
|
||||||
*/
|
*/
|
||||||
import type { AgentConfig } from './config/agentConfig.js'
|
import type { AgentConfig } from './config/agentConfig.js'
|
||||||
import type { AgentIdentity } from './keys/identity.js'
|
import type { AgentIdentity } from './keys/identity.js'
|
||||||
import type { Keystore } from './keys/keystore.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'
|
import type { EnrollResult } from 'relay-contracts'
|
||||||
|
|
||||||
export type CliCommand = 'pair' | 'run' | 'status' | 'install' | 'uninstall'
|
export type CliCommand = 'pair' | 'run' | 'status' | 'install' | 'uninstall'
|
||||||
@@ -24,13 +33,41 @@ export class CliUsageError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
export interface CliDeps {
|
||||||
loadConfig(): AgentConfig
|
loadConfig(): AgentConfig
|
||||||
openKeystore(stateDir: string): Keystore
|
openKeystore(stateDir: string): Keystore
|
||||||
|
/** Ed25519 identity for the legacy relay path. */
|
||||||
generateIdentity(): AgentIdentity
|
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>
|
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>
|
runTunnel(cfg: AgentConfig, ks: Keystore): Promise<number>
|
||||||
installService(cfg: AgentConfig): Promise<void>
|
/** 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>
|
uninstallService(): Promise<void>
|
||||||
print(line: string): void
|
print(line: string): void
|
||||||
}
|
}
|
||||||
@@ -60,23 +97,60 @@ export function parseArgs(argv: readonly string[]): CliArgs {
|
|||||||
return args
|
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). */
|
/** Dispatch a parsed CliArgs; returns a process exit code (0 = success). */
|
||||||
export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
|
export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
|
||||||
const cfg = deps.loadConfig()
|
const cfg = deps.loadConfig()
|
||||||
const ks = deps.openKeystore(cfg.stateDir)
|
const ks = deps.openKeystore(cfg.stateDir)
|
||||||
switch (args.command) {
|
switch (args.command) {
|
||||||
case 'pair': {
|
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()
|
const id = ks.loadIdentity() ?? deps.generateIdentity()
|
||||||
ks.saveIdentity(id)
|
ks.saveIdentity(id)
|
||||||
const enroll = await deps.redeem(cfg, args.code!, id, ks)
|
const enroll = await deps.redeem(cfg, args.code!, id, ks)
|
||||||
deps.print(`paired: host ${enroll.hostId} subdomain ${enroll.subdomain}`)
|
deps.print(`paired: host ${enroll.hostId} subdomain ${enroll.subdomain}`)
|
||||||
if (args.flags['install']) await deps.installService(cfg)
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
case 'run': {
|
case 'run': {
|
||||||
if (ks.loadIdentity() === null || ks.loadCert() === null) {
|
const id = ks.loadIdentity()
|
||||||
|
if (id === null || ks.loadCert() === null) {
|
||||||
throw new CliUsageError('not enrolled — run `web-terminal-agent pair <CODE>` first')
|
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)
|
return deps.runTunnel(cfg, ks)
|
||||||
}
|
}
|
||||||
case 'status': {
|
case 'status': {
|
||||||
@@ -88,7 +162,7 @@ export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
case 'install':
|
case 'install':
|
||||||
await deps.installService(cfg)
|
await deps.installService(cfg, deps.resolveInstallOptions())
|
||||||
return 0
|
return 0
|
||||||
case 'uninstall':
|
case 'uninstall':
|
||||||
await deps.uninstallService()
|
await deps.uninstallService()
|
||||||
|
|||||||
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`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
import { homedir } from 'node:os'
|
import { homedir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { isLoopbackHostLiteral } from '../net/loopbackLiteral.js'
|
||||||
|
|
||||||
export interface AgentConfig {
|
export interface AgentConfig {
|
||||||
readonly relayUrl: string
|
readonly relayUrl: string
|
||||||
@@ -17,11 +18,21 @@ export interface AgentConfig {
|
|||||||
readonly localTargetUrl: string
|
readonly localTargetUrl: string
|
||||||
readonly subdomain: string | null
|
readonly subdomain: string | null
|
||||||
readonly hostId: 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
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOOPBACK_HOSTNAMES: readonly string[] = ['localhost', '127.0.0.1', '::1', '[::1]']
|
/**
|
||||||
|
* True iff `url` is ws:// to a loopback host (a well-formed 127.0.0.0/8 IPv4 literal, localhost, or
|
||||||
/** True iff `url` is ws:// to a loopback host (127.0.0.0/8, localhost, or ::1). */
|
* ::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 {
|
export function isLoopbackWsUrl(url: string): boolean {
|
||||||
let parsed: URL
|
let parsed: URL
|
||||||
try {
|
try {
|
||||||
@@ -30,8 +41,7 @@ export function isLoopbackWsUrl(url: string): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (parsed.protocol !== 'ws:') return false
|
if (parsed.protocol !== 'ws:') return false
|
||||||
const host = parsed.hostname
|
return isLoopbackHostLiteral(parsed.hostname)
|
||||||
return LOOPBACK_HOSTNAMES.includes(host) || host.startsWith('127.')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasScheme(url: string, scheme: string): boolean {
|
function hasScheme(url: string, scheme: string): boolean {
|
||||||
@@ -56,6 +66,11 @@ export const AgentConfigSchema = z
|
|||||||
.refine(isLoopbackWsUrl, 'localTargetUrl must be a ws:// loopback URL (anti-SSRF)'),
|
.refine(isLoopbackWsUrl, 'localTargetUrl must be a ws:// loopback URL (anti-SSRF)'),
|
||||||
subdomain: z.string().min(1).nullable(),
|
subdomain: z.string().min(1).nullable(),
|
||||||
hostId: 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()
|
.strict()
|
||||||
.readonly()
|
.readonly()
|
||||||
@@ -82,6 +97,7 @@ export function loadAgentConfig(
|
|||||||
localTargetUrl: argv.localTargetUrl ?? env.LOCAL_TARGET_URL ?? DEFAULT_LOCAL_TARGET,
|
localTargetUrl: argv.localTargetUrl ?? env.LOCAL_TARGET_URL ?? DEFAULT_LOCAL_TARGET,
|
||||||
subdomain: argv.subdomain ?? env.SUBDOMAIN ?? null,
|
subdomain: argv.subdomain ?? env.SUBDOMAIN ?? null,
|
||||||
hostId: argv.hostId ?? env.HOST_ID ?? null,
|
hostId: argv.hostId ?? env.HOST_ID ?? null,
|
||||||
|
recoverUrl: argv.recoverUrl ?? env.RECOVER_URL ?? null,
|
||||||
}
|
}
|
||||||
return AgentConfigSchema.parse(merged)
|
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'],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,9 +52,11 @@ const OID = 0x06
|
|||||||
const UTF8_STRING = 0x0c
|
const UTF8_STRING = 0x0c
|
||||||
const CONTEXT_0 = 0xa0
|
const CONTEXT_0 = 0xa0
|
||||||
|
|
||||||
// OID 2.5.4.3 (commonName) and 1.3.101.112 (Ed25519) as pre-encoded DER value bytes.
|
// 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_CN = Uint8Array.from([0x55, 0x04, 0x03])
|
||||||
const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70])
|
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). */
|
/** SubjectPublicKeyInfo DER for a raw Ed25519 public key (fixed 44-byte structure). */
|
||||||
function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
|
function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
|
||||||
@@ -63,6 +65,24 @@ function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
|
|||||||
return tlv(SEQUENCE, concat([algId, pubBits]))
|
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. */
|
/** X.501 Name with a single CN=<subject> RDN. */
|
||||||
function nameFromCn(cn: string): Uint8Array {
|
function nameFromCn(cn: string): Uint8Array {
|
||||||
const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))]))
|
const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))]))
|
||||||
@@ -77,18 +97,20 @@ function toPem(der: Uint8Array, label: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a PKCS#10 CSR (PEM) for `id` with subject CN=`subject`, signed by the Ed25519 key.
|
* Build a PKCS#10 CSR (PEM) for `id` with subject CN=`subject`, signed by the identity's key. The
|
||||||
* The private key is used in-process only; never serialized into the output (INV4).
|
* 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 {
|
export function buildCsr(id: AgentIdentity, subject: string): string {
|
||||||
const version = tlv(INTEGER, Uint8Array.from([0x00]))
|
const version = tlv(INTEGER, Uint8Array.from([0x00]))
|
||||||
const name = nameFromCn(subject)
|
const name = nameFromCn(subject)
|
||||||
const spki = spkiFromRawEd25519(id.publicKey)
|
const spki = spkiFor(id)
|
||||||
const attributes = tlv(CONTEXT_0, new Uint8Array(0)) // [0] IMPLICIT empty SET OF Attribute
|
const attributes = tlv(CONTEXT_0, new Uint8Array(0)) // [0] IMPLICIT empty SET OF Attribute
|
||||||
const requestInfo = tlv(SEQUENCE, concat([version, name, spki, attributes]))
|
const requestInfo = tlv(SEQUENCE, concat([version, name, spki, attributes]))
|
||||||
|
|
||||||
const signature = id.sign(requestInfo) // Ed25519 over CertificationRequestInfo
|
const signature = id.sign(requestInfo) // over CertificationRequestInfo, per id.alg
|
||||||
const sigAlg = tlv(SEQUENCE, tlv(OID, OID_ED25519))
|
const sigAlg = sigAlgFor(id)
|
||||||
const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature]))
|
const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature]))
|
||||||
|
|
||||||
const csr = tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits]))
|
const csr = tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits]))
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { EnrollResult } from 'relay-contracts'
|
|||||||
import type { AgentIdentity } from '../keys/identity.js'
|
import type { AgentIdentity } from '../keys/identity.js'
|
||||||
import type { Keystore } from '../keys/keystore.js'
|
import type { Keystore } from '../keys/keystore.js'
|
||||||
import { buildCsr } from './csr.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. */
|
/** v0.8 shared-token gate vs v0.9+ per-host Ed25519. Default is `'ed25519'` from v0.9. */
|
||||||
export type EnrollMode = 'token' | 'ed25519'
|
export type EnrollMode = 'token' | 'ed25519'
|
||||||
@@ -53,6 +54,12 @@ export interface RedeemOptions {
|
|||||||
readonly agentToken?: string
|
readonly agentToken?: string
|
||||||
readonly unwrapContentSecret?: UnwrapContentSecret
|
readonly unwrapContentSecret?: UnwrapContentSecret
|
||||||
readonly subject?: string
|
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 {
|
interface EnrollResponseJson {
|
||||||
@@ -63,10 +70,32 @@ interface EnrollResponseJson {
|
|||||||
hostContentSecret: string // base64url over the wire
|
hostContentSecret: string // base64url over the wire
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseEnrollResult(json: unknown): EnrollResult {
|
function parseEnrollResult(json: unknown, allowMissingContentSecret = false): EnrollResult {
|
||||||
const j = json as Partial<EnrollResponseJson>
|
const j = json as Partial<EnrollResponseJson>
|
||||||
if (typeof j.hostContentSecret !== 'string') {
|
if (typeof j.hostContentSecret !== 'string') {
|
||||||
throw new EnrollError('enroll response missing hostContentSecret')
|
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 = {
|
const candidate = {
|
||||||
hostId: j.hostId,
|
hostId: j.hostId,
|
||||||
@@ -128,10 +157,13 @@ export async function redeemPairingCode(
|
|||||||
throw new EnrollError(`enroll response was not JSON: ${(err as Error).message}`)
|
throw new EnrollError(`enroll response was not JSON: ${(err as Error).message}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const enroll = parseEnrollResult(json)
|
const enroll = parseEnrollResult(json, opts.allowMissingContentSecret ?? false)
|
||||||
ks.saveCert(enroll.cert, enroll.caChain)
|
ks.saveCert(enroll.cert, enroll.caChain)
|
||||||
// FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored).
|
// FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored).
|
||||||
const unwrapped = unwrap(enroll.hostContentSecret, id)
|
// Native frp-client enroll has no content secret (empty sentinel) → nothing to unwrap/store.
|
||||||
ks.saveContentSecret(unwrapped)
|
if (enroll.hostContentSecret.length > 0) {
|
||||||
|
const unwrapped = unwrap(enroll.hostContentSecret, id)
|
||||||
|
ks.saveContentSecret(unwrapped)
|
||||||
|
}
|
||||||
return enroll
|
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,37 @@
|
|||||||
/**
|
/**
|
||||||
* Agent identity — PLAN_RELAY_AGENT T3 (INV4: the private key NEVER leaves the host).
|
* Agent identity — PLAN_RELAY_AGENT T3 (INV4: the private key NEVER leaves the host).
|
||||||
*
|
*
|
||||||
* Ed25519 keypair generated locally with node:crypto. `AgentIdentity` exposes ONLY the public
|
* Two key algorithms share one `AgentIdentity` shape (discriminated by `alg`):
|
||||||
* key, the §4.2 enroll fingerprint, and an in-process `sign()`; there is NO API that returns or
|
* - `ed25519` — the relay E2E rendezvous path (unchanged).
|
||||||
* serializes the private key. The raw private key material is held in a module-private closure.
|
* - `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 { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto'
|
||||||
import type { KeyObject } from 'node:crypto'
|
import type { KeyObject } from 'node:crypto'
|
||||||
import { encodeBase64UrlBytes } from 'relay-contracts'
|
import { encodeBase64UrlBytes } from 'relay-contracts'
|
||||||
|
|
||||||
|
/** Which key algorithm an identity carries. Drives CSR SPKI + signatureAlgorithm encoding. */
|
||||||
|
export type KeyAlg = 'ed25519' | 'p256'
|
||||||
|
|
||||||
export interface AgentIdentity {
|
export interface AgentIdentity {
|
||||||
/** Ed25519 raw 32-byte public key → stored in host registry (§4.2 agent_pubkey). */
|
/** 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
|
readonly publicKey: Uint8Array
|
||||||
/** §4.2 enroll_fpr: base64url(SHA-256(publicKey)) — pinned by the browser E2E TOFU (§4.4). */
|
/** §4.2 enroll_fpr: base64url(SHA-256(publicKey)) — pinned by the browser E2E TOFU (§4.4). */
|
||||||
readonly enrollFpr: string
|
readonly enrollFpr: string
|
||||||
/** Ed25519 signature over `message`, using the in-process private key. Never returns the key. */
|
/**
|
||||||
|
* 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
|
sign(message: Uint8Array): Uint8Array
|
||||||
/** Export the PRIVATE key as PKCS#8 PEM — for on-disk 0600 persistence ONLY (keystore). */
|
/** Export the PRIVATE key as PKCS#8 PEM — for on-disk 0600 persistence ONLY (keystore). */
|
||||||
exportPrivatePkcs8Pem(): string
|
exportPrivatePkcs8Pem(): string
|
||||||
@@ -39,6 +56,7 @@ function buildIdentity(privateKey: KeyObject, publicKey: KeyObject): AgentIdenti
|
|||||||
const rawPub = rawPublicKey(publicKey)
|
const rawPub = rawPublicKey(publicKey)
|
||||||
const enrollFpr = computeEnrollFpr(rawPub)
|
const enrollFpr = computeEnrollFpr(rawPub)
|
||||||
return {
|
return {
|
||||||
|
alg: 'ed25519',
|
||||||
publicKey: rawPub,
|
publicKey: rawPub,
|
||||||
enrollFpr,
|
enrollFpr,
|
||||||
sign(message: Uint8Array): Uint8Array {
|
sign(message: Uint8Array): Uint8Array {
|
||||||
@@ -53,19 +71,61 @@ function buildIdentity(privateKey: KeyObject, publicKey: KeyObject): AgentIdenti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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). */
|
/** Generate a fresh Ed25519 identity; the private key is held in-memory only (INV4). */
|
||||||
export function generateIdentity(): AgentIdentity {
|
export function generateIdentity(): AgentIdentity {
|
||||||
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
|
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
|
||||||
return buildIdentity(privateKey, publicKey)
|
return buildIdentity(privateKey, publicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reconstruct an identity from a stored PKCS#8 PEM private key (keystore load path). */
|
/**
|
||||||
|
* 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 {
|
export function identityFromPrivatePem(pem: string): AgentIdentity {
|
||||||
const privateKey = createPrivateKey(pem)
|
const privateKey = createPrivateKey(pem)
|
||||||
const publicKey = createPublicKey(privateKey)
|
const publicKey = createPublicKey(privateKey)
|
||||||
return buildIdentity(privateKey, publicKey)
|
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. */
|
/** Verify an Ed25519 signature against a raw public key — helper for tests/handshake checks. */
|
||||||
export function verifySignature(
|
export function verifySignature(
|
||||||
publicKey: Uint8Array,
|
publicKey: Uint8Array,
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ import {
|
|||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from 'node:fs'
|
} from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
|
import { createPrivateKey } from 'node:crypto'
|
||||||
import type { AgentIdentity } from './identity.js'
|
import type { AgentIdentity } from './identity.js'
|
||||||
import { identityFromPrivatePem } from './identity.js'
|
import { identityFromPrivatePem, p256IdentityFromPrivatePem } from './identity.js'
|
||||||
|
|
||||||
const SECRET_MODE = 0o600
|
const SECRET_MODE = 0o600
|
||||||
const DIR_MODE = 0o700
|
const DIR_MODE = 0o700
|
||||||
@@ -54,6 +55,32 @@ function ensureDir(stateDir: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
function writeSecret(path: string, data: string | Uint8Array): void {
|
||||||
writeFileSync(path, data, { mode: SECRET_MODE })
|
writeFileSync(path, data, { mode: SECRET_MODE })
|
||||||
// Enforce 0600 even if a prior umask/file left it wider.
|
// Enforce 0600 even if a prior umask/file left it wider.
|
||||||
@@ -80,7 +107,7 @@ export function openKeystore(stateDir: string): Keystore {
|
|||||||
throw new KeystoreError(`failed to read identity key: ${(err as Error).message}`)
|
throw new KeystoreError(`failed to read identity key: ${(err as Error).message}`)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return identityFromPrivatePem(pem)
|
return identityFromStoredPem(pem)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new KeystoreError(`corrupt identity key file: ${(err as Error).message}`)
|
throw new KeystoreError(`corrupt identity key file: ${(err as Error).message}`)
|
||||||
}
|
}
|
||||||
|
|||||||
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})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,153 @@
|
|||||||
/**
|
/**
|
||||||
* Service install dispatcher — PLAN_RELAY_AGENT T17. Detects the platform, writes the unit, and
|
* Service install dispatcher — PLAN_RELAY_AGENT T17, re-targeted for the native tunnel
|
||||||
* loads it. REFUSES to install as root (EXPLORE §4d least privilege). All IO is injected so the
|
* (PLAN_TUNNEL_AUTOMATION B5). Detects the platform and emits TWO durable units — the base-app and
|
||||||
* logic is unit-testable without touching the real system.
|
* 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 type { AgentConfig } from '../config/agentConfig.js'
|
||||||
import {
|
import {
|
||||||
|
agentLabel,
|
||||||
|
baseAppLabel,
|
||||||
buildLaunchdPlist,
|
buildLaunchdPlist,
|
||||||
launchdLoadCommand,
|
launchdLoadCommand,
|
||||||
launchdPlistPath,
|
launchdPlistPath,
|
||||||
launchdUnloadCommand,
|
launchdUnloadCommand,
|
||||||
|
type ServiceEnv,
|
||||||
} from './launchd.js'
|
} from './launchd.js'
|
||||||
import {
|
import {
|
||||||
|
agentUnitName,
|
||||||
|
baseAppUnitName,
|
||||||
buildSystemdUnit,
|
buildSystemdUnit,
|
||||||
systemdDisableCommand,
|
systemdDisableCommand,
|
||||||
systemdEnableCommand,
|
systemdEnableCommand,
|
||||||
systemdUnitPath,
|
systemdUnitPath,
|
||||||
|
type SystemdUnitOptions,
|
||||||
} from './systemd.js'
|
} from './systemd.js'
|
||||||
|
import { DEFAULT_ORIGIN_ZONE, mergeOrigins, subdomainOrigin } from './originConfig.js'
|
||||||
|
import { isLoopbackHostLiteral } from '../net/loopbackLiteral.js'
|
||||||
|
|
||||||
export type ServicePlatform = 'launchd' | 'systemd'
|
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 {
|
export class RootRefusedError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('refusing to install the agent service as root — run as the logged-in user (least privilege)')
|
super('refusing to install the agent service as root — run as the logged-in user (least privilege)')
|
||||||
@@ -42,37 +171,109 @@ export function detectPlatform(os: NodeJS.Platform): ServicePlatform | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Write + load the service unit for `platform`. Throws RootRefusedError if running as root. */
|
/**
|
||||||
export async function installService(
|
* Resolve the BASE-APP env injected into the base-app unit. Runs the S-GATE on `BIND_HOST` (throws
|
||||||
_cfg: AgentConfig,
|
* `BindHostError` on a non-loopback value, before any write) and, when a `domain` (+ `cfg.subdomain`)
|
||||||
platform: ServicePlatform,
|
* is supplied, merges the tunnel origin `https://<subdomain>.<zone>.<domain>` into ALLOWED_ORIGINS —
|
||||||
deps: InstallDeps,
|
* never weakening an origin the caller already provided. Immutable.
|
||||||
): Promise<void> {
|
*/
|
||||||
if (deps.getuid() === 0) throw new RootRefusedError()
|
function resolveBaseAppEnv(cfg: AgentConfig, options: InstallOptions): ServiceEnv {
|
||||||
const bin = deps.binPath()
|
const base = options.env ?? {}
|
||||||
if (platform === 'launchd') {
|
const bindHost = normalizeBindHost(base.BIND_HOST) // S-GATE — throws on non-loopback
|
||||||
const path = launchdPlistPath(deps.homedir())
|
const withBind: ServiceEnv = { ...base, BIND_HOST: bindHost }
|
||||||
deps.writeFile(path, buildLaunchdPlist(bin))
|
if (!options.domain || !cfg.subdomain) return withBind
|
||||||
const { cmd, args } = launchdLoadCommand(path)
|
const origin = subdomainOrigin(cfg.subdomain, options.domain, options.zone ?? DEFAULT_ORIGIN_ZONE)
|
||||||
await deps.runCommand(cmd, args)
|
return { ...withBind, ALLOWED_ORIGINS: mergeOrigins(withBind.ALLOWED_ORIGINS, origin) }
|
||||||
return
|
|
||||||
}
|
|
||||||
const path = systemdUnitPath(deps.homedir())
|
|
||||||
deps.writeFile(path, buildSystemdUnit(bin, deps.username()))
|
|
||||||
const { cmd, args } = systemdEnableCommand()
|
|
||||||
await deps.runCommand(cmd, args)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unload the service unit for `platform`. */
|
/**
|
||||||
|
* 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(
|
export async function uninstallService(
|
||||||
platform: ServicePlatform,
|
platform: ServicePlatform,
|
||||||
deps: Pick<InstallDeps, 'runCommand' | 'homedir'>,
|
deps: Pick<InstallDeps, 'runCommand' | 'homedir'>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (platform === 'launchd') {
|
if (platform === 'launchd') {
|
||||||
const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir()))
|
for (const label of [agentLabel(), baseAppLabel()]) {
|
||||||
await deps.runCommand(cmd, args)
|
const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir(), label))
|
||||||
|
await deps.runCommand(cmd, args)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const { cmd, args } = systemdDisableCommand()
|
for (const unit of [agentUnitName(), baseAppUnitName()]) {
|
||||||
await deps.runCommand(cmd, args)
|
const { cmd, args } = systemdDisableCommand(unit)
|
||||||
|
await deps.runCommand(cmd, args)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,108 @@
|
|||||||
/**
|
/**
|
||||||
* macOS launchd plist writer — PLAN_RELAY_AGENT T17. The service runs as the LOGGED-IN USER, not
|
* 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).
|
* 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`).
|
||||||
*/
|
*/
|
||||||
const LABEL = 'com.web-terminal.agent'
|
|
||||||
|
|
||||||
|
/** 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 {
|
export function launchdLabel(): string {
|
||||||
return LABEL
|
return AGENT_LABEL
|
||||||
}
|
}
|
||||||
|
|
||||||
export function launchdPlistPath(homedir: string): string {
|
export function launchdPlistPath(homedir: string, label: string = AGENT_LABEL): string {
|
||||||
return `${homedir}/Library/LaunchAgents/${LABEL}.plist`
|
return `${homedir}/Library/LaunchAgents/${label}.plist`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the plist. ExecStart = `<bin> run`; RunAtLoad + KeepAlive (restart on failure). */
|
/** Escape the five XML-significant characters so env keys/values/paths are plist-safe. */
|
||||||
export function buildLaunchdPlist(binPath: string): string {
|
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 [
|
return [
|
||||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
'<?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">',
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
||||||
'<plist version="1.0">',
|
'<plist version="1.0">',
|
||||||
'<dict>',
|
'<dict>',
|
||||||
' <key>Label</key>',
|
' <key>Label</key>',
|
||||||
` <string>${LABEL}</string>`,
|
` <string>${escapeXml(label)}</string>`,
|
||||||
' <key>ProgramArguments</key>',
|
...programArgumentsBlock(programArguments),
|
||||||
' <array>',
|
|
||||||
` <string>${binPath}</string>`,
|
|
||||||
' <string>run</string>',
|
|
||||||
' </array>',
|
|
||||||
' <key>RunAtLoad</key>',
|
' <key>RunAtLoad</key>',
|
||||||
' <true/>',
|
' <true/>',
|
||||||
' <key>KeepAlive</key>',
|
' <key>KeepAlive</key>',
|
||||||
' <true/>',
|
' <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>',
|
'</dict>',
|
||||||
'</plist>',
|
'</plist>',
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* The ONE base-app touch-point — PLAN_RELAY_AGENT T17 (INDEX §0, EXPLORE §3 "Zero code change").
|
* The ONE base-app touch-point — PLAN_RELAY_AGENT T17 (INDEX §0, EXPLORE §3 "Zero code change").
|
||||||
* APPENDS `https://<subdomain>.term.<domain>` to the base app's ALLOWED_ORIGINS env (idempotent),
|
* 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
|
* 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").
|
* 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'
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
|
||||||
@@ -18,23 +23,40 @@ const defaultFs: OriginFsDeps = {
|
|||||||
write: (p, c) => writeFileSync(p, c),
|
write: (p, c) => writeFileSync(p, c),
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compose the subdomain origin the base app must trust. */
|
/** Default DNS zone label (relay hosts). Native-tunnel hosts pass `terminal`. */
|
||||||
export function subdomainOrigin(subdomain: string, domain: string): string {
|
export const DEFAULT_ORIGIN_ZONE = 'term'
|
||||||
return `https://${subdomain}.term.${domain}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const KEY = 'ALLOWED_ORIGINS'
|
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 {
|
function upsertOriginLine(content: string, origin: string): string {
|
||||||
const lines = content.length === 0 ? [] : content.split('\n')
|
const lines = content.length === 0 ? [] : content.split('\n')
|
||||||
let found = false
|
let found = false
|
||||||
const next = lines.map((line) => {
|
const next = lines.map((line) => {
|
||||||
if (!line.startsWith(`${KEY}=`)) return line
|
if (!line.startsWith(`${KEY}=`)) return line
|
||||||
found = true
|
found = true
|
||||||
const current = line.slice(KEY.length + 1)
|
return `${KEY}=${mergeOrigins(line.slice(KEY.length + 1), origin)}`
|
||||||
const origins = current.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
|
|
||||||
if (origins.includes(origin)) return line // idempotent — already trusted
|
|
||||||
return `${KEY}=${[...origins, origin].join(',')}`
|
|
||||||
})
|
})
|
||||||
if (!found) next.push(`${KEY}=${origin}`)
|
if (!found) next.push(`${KEY}=${origin}`)
|
||||||
return next.join('\n')
|
return next.join('\n')
|
||||||
@@ -42,15 +64,17 @@ function upsertOriginLine(content: string, origin: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Idempotently append the subdomain origin to ALLOWED_ORIGINS in `baseAppEnvPath`. Never removes an
|
* Idempotently append the subdomain origin to ALLOWED_ORIGINS in `baseAppEnvPath`. Never removes an
|
||||||
* existing origin. Creates the file/line if absent.
|
* existing origin. Creates the file/line if absent. `zone` selects the DNS zone label (default
|
||||||
|
* preserves relay callers).
|
||||||
*/
|
*/
|
||||||
export function ensureAllowedOrigin(
|
export function ensureAllowedOrigin(
|
||||||
baseAppEnvPath: string,
|
baseAppEnvPath: string,
|
||||||
subdomain: string,
|
subdomain: string,
|
||||||
domain: string,
|
domain: string,
|
||||||
fs: OriginFsDeps = defaultFs,
|
fs: OriginFsDeps = defaultFs,
|
||||||
|
zone: string = DEFAULT_ORIGIN_ZONE,
|
||||||
): void {
|
): void {
|
||||||
const origin = subdomainOrigin(subdomain, domain)
|
const origin = subdomainOrigin(subdomain, domain, zone)
|
||||||
const existing = fs.exists(baseAppEnvPath) ? fs.read(baseAppEnvPath) : ''
|
const existing = fs.exists(baseAppEnvPath) ? fs.read(baseAppEnvPath) : ''
|
||||||
const updated = upsertOriginLine(existing, origin)
|
const updated = upsertOriginLine(existing, origin)
|
||||||
fs.write(baseAppEnvPath, updated.endsWith('\n') ? updated : `${updated}\n`)
|
fs.write(baseAppEnvPath, updated.endsWith('\n') ? updated : `${updated}\n`)
|
||||||
|
|||||||
@@ -1,30 +1,129 @@
|
|||||||
/**
|
/**
|
||||||
* Linux systemd unit writer — PLAN_RELAY_AGENT T17. Runs as the LOGGED-IN USER (never root,
|
* 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).
|
* 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`).
|
||||||
*/
|
*/
|
||||||
const UNIT_NAME = 'web-terminal-agent.service'
|
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 {
|
export function systemdUnitName(): string {
|
||||||
return UNIT_NAME
|
return AGENT_UNIT
|
||||||
}
|
}
|
||||||
|
|
||||||
export function systemdUnitPath(homedir: string): string {
|
export function systemdUnitPath(homedir: string, unitName: string = AGENT_UNIT): string {
|
||||||
return `${homedir}/.config/systemd/user/${UNIT_NAME}`
|
return `${homedir}/.config/systemd/user/${unitName}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the unit. ExecStart = `<bin> run`; Restart=on-failure; User=<user> (never root). */
|
/**
|
||||||
export function buildSystemdUnit(binPath: string, user: string): string {
|
* 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 [
|
return [
|
||||||
'[Unit]',
|
'[Unit]',
|
||||||
'Description=web-terminal host agent (rendezvous relay)',
|
`Description=${description}`,
|
||||||
'After=network-online.target',
|
'After=network-online.target',
|
||||||
'',
|
'',
|
||||||
'[Service]',
|
'[Service]',
|
||||||
'Type=simple',
|
'Type=simple',
|
||||||
`ExecStart=${binPath} run`,
|
`ExecStart=${execStart}`,
|
||||||
'Restart=on-failure',
|
'Restart=on-failure',
|
||||||
'RestartSec=1',
|
'RestartSec=1',
|
||||||
`User=${user}`,
|
`User=${user}`,
|
||||||
|
...environmentLines(options),
|
||||||
'',
|
'',
|
||||||
'[Install]',
|
'[Install]',
|
||||||
'WantedBy=default.target',
|
'WantedBy=default.target',
|
||||||
@@ -32,9 +131,13 @@ export function buildSystemdUnit(binPath: string, user: string): string {
|
|||||||
].join('\n')
|
].join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function systemdEnableCommand(): { cmd: string; args: readonly string[] } {
|
export function systemdEnableCommand(
|
||||||
return { cmd: 'systemctl', args: ['--user', 'enable', '--now', UNIT_NAME] }
|
unitName: string = AGENT_UNIT,
|
||||||
|
): { cmd: string; args: readonly string[] } {
|
||||||
|
return { cmd: 'systemctl', args: ['--user', 'enable', '--now', unitName] }
|
||||||
}
|
}
|
||||||
export function systemdDisableCommand(): { cmd: string; args: readonly string[] } {
|
export function systemdDisableCommand(
|
||||||
return { cmd: 'systemctl', args: ['--user', 'disable', '--now', UNIT_NAME] }
|
unitName: string = AGENT_UNIT,
|
||||||
|
): { cmd: string; args: readonly string[] } {
|
||||||
|
return { cmd: 'systemctl', args: ['--user', 'disable', '--now', unitName] }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,13 @@ export class CertExpiredError extends Error {
|
|||||||
export interface TlsClientOptions {
|
export interface TlsClientOptions {
|
||||||
readonly cert: string
|
readonly cert: string
|
||||||
readonly key: string
|
readonly key: string
|
||||||
readonly ca: 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
|
readonly rejectUnauthorized: true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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')
|
||||||
|
}
|
||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,10 +33,20 @@ describe('AgentConfig validation', () => {
|
|||||||
expect(isLoopbackWsUrl('ws://127.0.0.1:3000')).toBe(true)
|
expect(isLoopbackWsUrl('ws://127.0.0.1:3000')).toBe(true)
|
||||||
expect(isLoopbackWsUrl('ws://localhost:3000')).toBe(true)
|
expect(isLoopbackWsUrl('ws://localhost:3000')).toBe(true)
|
||||||
expect(isLoopbackWsUrl('ws://127.5.5.5: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('ws://10.0.0.5:3000')).toBe(false)
|
||||||
expect(isLoopbackWsUrl('wss://127.0.0.1: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', () => {
|
it('loadAgentConfig fails fast on a missing relayUrl', () => {
|
||||||
expect(() => loadAgentConfig({} as NodeJS.ProcessEnv, {})).toThrow()
|
expect(() => loadAgentConfig({} as NodeJS.ProcessEnv, {})).toThrow()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Keystore } from '../src/keys/keystore.js'
|
|||||||
import type { AgentIdentity } from '../src/keys/identity.js'
|
import type { AgentIdentity } from '../src/keys/identity.js'
|
||||||
import type { EnrollResult } from 'relay-contracts'
|
import type { EnrollResult } from 'relay-contracts'
|
||||||
import { CliUsageError, parseArgs, runCli, type CliDeps } from '../src/cli.js'
|
import { CliUsageError, parseArgs, runCli, type CliDeps } from '../src/cli.js'
|
||||||
|
import type { InstallOptions } from '../src/service/install.js'
|
||||||
|
|
||||||
const CFG: AgentConfig = {
|
const CFG: AgentConfig = {
|
||||||
relayUrl: 'wss://relay/agent',
|
relayUrl: 'wss://relay/agent',
|
||||||
@@ -14,8 +15,9 @@ const CFG: AgentConfig = {
|
|||||||
hostId: 'h-1',
|
hostId: 'h-1',
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeIdentity(): AgentIdentity {
|
function fakeIdentity(alg: AgentIdentity['alg'] = 'ed25519'): AgentIdentity {
|
||||||
return {
|
return {
|
||||||
|
alg,
|
||||||
publicKey: new Uint8Array(32),
|
publicKey: new Uint8Array(32),
|
||||||
enrollFpr: 'fpr',
|
enrollFpr: 'fpr',
|
||||||
sign: () => new Uint8Array(64),
|
sign: () => new Uint8Array(64),
|
||||||
@@ -24,10 +26,10 @@ function fakeIdentity(): AgentIdentity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeKeystore(enrolled: boolean): Keystore {
|
function fakeKeystore(enrolled: boolean, alg: AgentIdentity['alg'] = 'ed25519'): Keystore {
|
||||||
return {
|
return {
|
||||||
saveIdentity: vi.fn(),
|
saveIdentity: vi.fn(),
|
||||||
loadIdentity: () => (enrolled ? fakeIdentity() : null),
|
loadIdentity: () => (enrolled ? fakeIdentity(alg) : null),
|
||||||
saveCert: vi.fn(),
|
saveCert: vi.fn(),
|
||||||
loadCert: () => (enrolled ? { certPem: 'C', caChainPem: 'CA' } : null),
|
loadCert: () => (enrolled ? { certPem: 'C', caChainPem: 'CA' } : null),
|
||||||
saveContentSecret: vi.fn(),
|
saveContentSecret: vi.fn(),
|
||||||
@@ -35,12 +37,19 @@ function fakeKeystore(enrolled: boolean): Keystore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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[] } {
|
function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps; out: string[] } {
|
||||||
const out: string[] = []
|
const out: string[] = []
|
||||||
const d: CliDeps = {
|
const d: CliDeps = {
|
||||||
loadConfig: () => CFG,
|
loadConfig: () => CFG,
|
||||||
openKeystore: () => fakeKeystore(enrolled),
|
openKeystore: () => fakeKeystore(enrolled),
|
||||||
generateIdentity: fakeIdentity,
|
generateIdentity: () => fakeIdentity('ed25519'),
|
||||||
|
generateP256Identity: () => fakeIdentity('p256'),
|
||||||
redeem: async (): Promise<EnrollResult> => ({
|
redeem: async (): Promise<EnrollResult> => ({
|
||||||
hostId: 'h-1',
|
hostId: 'h-1',
|
||||||
subdomain: 'host-42',
|
subdomain: 'host-42',
|
||||||
@@ -48,7 +57,13 @@ function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps;
|
|||||||
caChain: 'CA',
|
caChain: 'CA',
|
||||||
hostContentSecret: new Uint8Array([1]),
|
hostContentSecret: new Uint8Array([1]),
|
||||||
}),
|
}),
|
||||||
|
enrollNative: async () => ({ hostId: 'h-1', subdomain: 'host-42' }),
|
||||||
|
provisionFrpc: async () => '/opt/frpc',
|
||||||
|
writeFrpcConfig: vi.fn(),
|
||||||
|
nativeConfigExists: () => false,
|
||||||
runTunnel: async () => 0,
|
runTunnel: async () => 0,
|
||||||
|
superviseFrpc: async () => 0,
|
||||||
|
resolveInstallOptions: () => NATIVE_OPTIONS,
|
||||||
installService: vi.fn(async () => {}),
|
installService: vi.fn(async () => {}),
|
||||||
uninstallService: vi.fn(async () => {}),
|
uninstallService: vi.fn(async () => {}),
|
||||||
print: (l) => out.push(l),
|
print: (l) => out.push(l),
|
||||||
@@ -79,7 +94,7 @@ describe('parseArgs (T5)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('runCli (T5)', () => {
|
describe('runCli — legacy relay pair (no --install)', () => {
|
||||||
it('pair happy path calls redeem and prints no secrets', async () => {
|
it('pair happy path calls redeem and prints no secrets', async () => {
|
||||||
const { d, out } = deps()
|
const { d, out } = deps()
|
||||||
const code = await runCli(parseArgs(['pair', 'ABCD']), d)
|
const code = await runCli(parseArgs(['pair', 'ABCD']), d)
|
||||||
@@ -87,12 +102,97 @@ describe('runCli (T5)', () => {
|
|||||||
expect(out.join('\n')).toContain('host-42')
|
expect(out.join('\n')).toContain('host-42')
|
||||||
expect(out.join('\n')).not.toContain('PEM')
|
expect(out.join('\n')).not.toContain('PEM')
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('pair --install installs the service', async () => {
|
describe('runCli — native pair --install onboard (B5)', () => {
|
||||||
const install = vi.fn(async () => {})
|
it('wires keygen(P-256)→enroll→provisionFrpc→write toml+env→install both→print URL, in order', async () => {
|
||||||
const { d } = deps({ installService: install })
|
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)
|
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
|
||||||
expect(install).toHaveBeenCalledOnce()
|
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 () => {
|
it('run before pairing fails fast', async () => {
|
||||||
@@ -100,6 +200,48 @@ describe('runCli (T5)', () => {
|
|||||||
await expect(runCli({ command: 'run', flags: {} }, d)).rejects.toBeInstanceOf(CliUsageError)
|
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 () => {
|
it('status prints no key/cert material (INV9)', async () => {
|
||||||
const { d, out } = deps({}, true)
|
const { d, out } = deps({}, true)
|
||||||
await runCli({ command: 'status', flags: {} }, d)
|
await runCli({ command: 'status', flags: {} }, d)
|
||||||
|
|||||||
@@ -1,8 +1,52 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { X509Certificate } from 'node:crypto'
|
import { X509Certificate, createPublicKey, verify } from 'node:crypto'
|
||||||
import { generateIdentity } from '../src/keys/identity.js'
|
import { generateIdentity, generateP256Identity } from '../src/keys/identity.js'
|
||||||
import { buildCsr } from '../src/enroll/csr.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)', () => {
|
describe('PKCS#10 CSR (T4)', () => {
|
||||||
it('emits a PEM CERTIFICATE REQUEST', () => {
|
it('emits a PEM CERTIFICATE REQUEST', () => {
|
||||||
const csr = buildCsr(generateIdentity(), 'host-42.term.example.com')
|
const csr = buildCsr(generateIdentity(), 'host-42.term.example.com')
|
||||||
@@ -30,3 +74,50 @@ describe('PKCS#10 CSR (T4)', () => {
|
|||||||
expect(typeof X509Certificate).toBe('function')
|
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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
|
import { createPublicKey, verify } from 'node:crypto'
|
||||||
import {
|
import {
|
||||||
computeEnrollFpr,
|
computeEnrollFpr,
|
||||||
generateIdentity,
|
generateIdentity,
|
||||||
|
generateP256Identity,
|
||||||
identityFromPrivatePem,
|
identityFromPrivatePem,
|
||||||
|
p256IdentityFromPrivatePem,
|
||||||
verifySignature,
|
verifySignature,
|
||||||
} from '../src/keys/identity.js'
|
} from '../src/keys/identity.js'
|
||||||
|
|
||||||
@@ -50,4 +53,53 @@ describe('AgentIdentity (INV4)', () => {
|
|||||||
// No function exports raw private key bytes onto the network surface.
|
// No function exports raw private key bytes onto the network surface.
|
||||||
expect(src).not.toMatch(/exportPrivateRaw|privateKeyBytes|toRawPrivate/)
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { AgentConfig } from '../src/config/agentConfig.js'
|
import type { AgentConfig } from '../src/config/agentConfig.js'
|
||||||
import {
|
import {
|
||||||
|
BindHostError,
|
||||||
RootRefusedError,
|
RootRefusedError,
|
||||||
|
assertNativeZone,
|
||||||
|
buildInstallOptions,
|
||||||
detectPlatform,
|
detectPlatform,
|
||||||
installService,
|
installService,
|
||||||
|
normalizeBindHost,
|
||||||
uninstallService,
|
uninstallService,
|
||||||
type InstallDeps,
|
type InstallDeps,
|
||||||
} from '../src/service/install.js'
|
} 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 = {
|
const CFG: AgentConfig = {
|
||||||
relayUrl: 'wss://relay/agent',
|
relayUrl: 'wss://relay/agent',
|
||||||
@@ -17,7 +23,12 @@ const CFG: AgentConfig = {
|
|||||||
hostId: 'h-1',
|
hostId: 'h-1',
|
||||||
}
|
}
|
||||||
|
|
||||||
function deps(uid = 501): InstallDeps & { writes: Array<[string, string]>; runs: Array<[string, readonly string[]]> } {
|
type FakeDeps = InstallDeps & {
|
||||||
|
writes: Array<[string, string]>
|
||||||
|
runs: Array<[string, readonly string[]]>
|
||||||
|
}
|
||||||
|
|
||||||
|
function deps(uid = 501): FakeDeps {
|
||||||
const writes: Array<[string, string]> = []
|
const writes: Array<[string, string]> = []
|
||||||
const runs: Array<[string, readonly string[]]> = []
|
const runs: Array<[string, readonly string[]]> = []
|
||||||
return {
|
return {
|
||||||
@@ -34,6 +45,13 @@ function deps(uid = 501): InstallDeps & { writes: Array<[string, string]>; runs:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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)', () => {
|
describe('detectPlatform (T17)', () => {
|
||||||
it('maps darwin→launchd, linux→systemd, else null', () => {
|
it('maps darwin→launchd, linux→systemd, else null', () => {
|
||||||
expect(detectPlatform('darwin')).toBe('launchd')
|
expect(detectPlatform('darwin')).toBe('launchd')
|
||||||
@@ -42,35 +60,363 @@ describe('detectPlatform (T17)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('installService (T17)', () => {
|
describe('installService — least privilege (T17)', () => {
|
||||||
it('refuses to install as root (negative, least privilege)', async () => {
|
it('refuses to install as root (negative, least privilege)', async () => {
|
||||||
await expect(installService(CFG, 'systemd', deps(0))).rejects.toBeInstanceOf(RootRefusedError)
|
await expect(installService(CFG, 'systemd', deps(0))).rejects.toBeInstanceOf(RootRefusedError)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('systemd: writes a run-as-user unit and enables it', async () => {
|
it('root refusal emits nothing', async () => {
|
||||||
const d = deps()
|
const d = deps(0)
|
||||||
await installService(CFG, 'systemd', d)
|
await expect(installService(CFG, 'systemd', d)).rejects.toBeInstanceOf(RootRefusedError)
|
||||||
const [, unit] = d.writes[0]!
|
expect(d.writes).toHaveLength(0)
|
||||||
expect(unit).toContain('ExecStart=/usr/local/bin/web-terminal-agent run')
|
expect(d.runs).toHaveLength(0)
|
||||||
expect(unit).toContain('User=alice')
|
})
|
||||||
expect(unit).not.toContain('User=root')
|
})
|
||||||
expect(unit).toContain('Restart=on-failure')
|
|
||||||
expect(d.runs[0]![0]).toBe('systemctl')
|
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()
|
||||||
it('launchd: writes a plist with ProgramArguments run + KeepAlive', async () => {
|
await installService(CFG, 'systemd', d)
|
||||||
const d = deps()
|
// exactly two units
|
||||||
await installService(CFG, 'launchd', d)
|
expect(d.writes).toHaveLength(2)
|
||||||
const [path, plist] = d.writes[0]!
|
const baseApp = unitWith(d, baseAppUnitName())
|
||||||
expect(path).toContain('LaunchAgents')
|
const agent = unitWith(d, agentUnitName())
|
||||||
expect(plist).toContain('<string>run</string>')
|
// agent unit supervises frpc via `<node> <bin> run` (absolute node so a minimal service PATH
|
||||||
expect(plist).toContain('<key>KeepAlive</key>')
|
// that lacks /usr/local/bin can't fail with EX_CONFIG); base-app runs the node server (loopback)
|
||||||
expect(d.runs[0]![0]).toBe('launchctl')
|
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=')
|
||||||
it('uninstall unloads cleanly', async () => {
|
expect(baseApp).toContain('server.js')
|
||||||
const d = deps()
|
expect(baseApp).not.toContain('web-terminal-agent run')
|
||||||
await uninstallService('launchd', d)
|
// both least-privilege + restart-on-failure
|
||||||
expect(d.runs[0]).toEqual(['launchctl', ['unload', '/home/alice/Library/LaunchAgents/com.web-terminal.agent.plist']])
|
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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { afterEach, describe, expect, it } from 'vitest'
|
|||||||
import { mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
import { mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { generateIdentity } from '../src/keys/identity.js'
|
import { createPublicKey, generateKeyPairSync, verify } from 'node:crypto'
|
||||||
|
import { generateIdentity, generateP256Identity } from '../src/keys/identity.js'
|
||||||
import { KeystoreError, openKeystore } from '../src/keys/keystore.js'
|
import { KeystoreError, openKeystore } from '../src/keys/keystore.js'
|
||||||
|
|
||||||
const dirs: string[] = []
|
const dirs: string[] = []
|
||||||
@@ -67,4 +68,50 @@ describe('Keystore (INV4/INV5)', () => {
|
|||||||
mkdirSync(dir, { mode: 0o755 })
|
mkdirSync(dir, { mode: 0o755 })
|
||||||
expect(() => openKeystore(dir).saveIdentity(generateIdentity())).toThrow(KeystoreError)
|
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/)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { ensureAllowedOrigin, subdomainOrigin, type OriginFsDeps } from '../src/service/originConfig.js'
|
import {
|
||||||
|
DEFAULT_ORIGIN_ZONE,
|
||||||
|
ensureAllowedOrigin,
|
||||||
|
mergeOrigins,
|
||||||
|
subdomainOrigin,
|
||||||
|
type OriginFsDeps,
|
||||||
|
} from '../src/service/originConfig.js'
|
||||||
|
|
||||||
function memFs(initial: string | null): { fs: OriginFsDeps; get(): string } {
|
function memFs(initial: string | null): { fs: OriginFsDeps; get(): string } {
|
||||||
const store = { content: initial }
|
const store = { content: initial }
|
||||||
@@ -41,3 +47,38 @@ describe('ensureAllowedOrigin (T17, EXPLORE §3)', () => {
|
|||||||
expect(get()).toContain('https://host-42.term.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',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,9 +8,13 @@ import { openKeystore } from '../src/keys/keystore.js'
|
|||||||
import {
|
import {
|
||||||
computeRenewDelayMs,
|
computeRenewDelayMs,
|
||||||
createCertRotator,
|
createCertRotator,
|
||||||
|
recoverCert,
|
||||||
|
recoveryUrlFor,
|
||||||
renewCert,
|
renewCert,
|
||||||
renewalUrlFor,
|
renewalUrlFor,
|
||||||
} from '../src/certs/rotation.js'
|
} 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'
|
import { FakeTimer } from './fixtures/fakes.js'
|
||||||
|
|
||||||
const CFG: AgentConfig = {
|
const CFG: AgentConfig = {
|
||||||
@@ -52,10 +56,10 @@ describe('renewCert (T13)', () => {
|
|||||||
it('installs a fresh cert atomically on success (same key)', async () => {
|
it('installs a fresh cert atomically on success (same key)', async () => {
|
||||||
const { dir, ks } = enrolledKs()
|
const { dir, ks } = enrolledKs()
|
||||||
const before = ks.loadIdentity()!.publicKey
|
const before = ks.loadIdentity()!.publicKey
|
||||||
const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' }))
|
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)
|
const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch)
|
||||||
expect(out).toBe('rotated')
|
expect(out).toBe('rotated')
|
||||||
expect(ks.loadCert()).toEqual({ certPem: 'NEWCERT', caChainPem: 'NEWCA' })
|
expect(ks.loadCert()!.certPem).toContain('NEWCERT'); expect(ks.loadCert()!.caChainPem).toContain('NEWCA')
|
||||||
// pubkey unchanged — only the cert rotated
|
// pubkey unchanged — only the cert rotated
|
||||||
expect(Buffer.from(ks.loadIdentity()!.publicKey).equals(Buffer.from(before))).toBe(true)
|
expect(Buffer.from(ks.loadIdentity()!.publicKey).equals(Buffer.from(before))).toBe(true)
|
||||||
rmSync(dir, { recursive: true, force: true })
|
rmSync(dir, { recursive: true, force: true })
|
||||||
@@ -101,7 +105,7 @@ describe('createCertRotator (T13)', () => {
|
|||||||
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
|
||||||
timer,
|
timer,
|
||||||
renewBeforeMs: 1000,
|
renewBeforeMs: 1000,
|
||||||
fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' })) as unknown as typeof fetch,
|
fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })) as unknown as typeof fetch,
|
||||||
now: () => new Date(0),
|
now: () => new Date(0),
|
||||||
parseCert: () => new Date(2000),
|
parseCert: () => new Date(2000),
|
||||||
})
|
})
|
||||||
@@ -113,8 +117,194 @@ describe('createCertRotator (T13)', () => {
|
|||||||
timer.advance(1000)
|
timer.advance(1000)
|
||||||
await flush()
|
await flush()
|
||||||
expect(rotated).toBe(1)
|
expect(rotated).toBe(1)
|
||||||
expect(ks.loadCert()!.certPem).toBe('NEWCERT')
|
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()
|
rotator.stop()
|
||||||
rmSync(dir, { recursive: true, force: true })
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
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())
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A client result union for the six guarded git-write ops (worktree create/remove/prune, git
|
||||||
|
* stage/commit/push). It carries the server's SAFE body only — never raw git stderr (the server
|
||||||
|
* classifies + sanitizes every failure, `src/http/git-ops.ts` / `worktrees.ts`, SEC-M10):
|
||||||
|
*
|
||||||
|
* - [Ok] — a 200 with the op's route-specific payload [T].
|
||||||
|
* - [Rejected] — a 4xx/5xx with the server's inert `error` string ([message]) to display verbatim.
|
||||||
|
* 403 is OVERLOADED (Origin-guard failure AND the disabled kill-switch both 403) so the client
|
||||||
|
* cannot tell them apart by status — it surfaces [message] inertly rather than inventing a typed
|
||||||
|
* variant (plan Edge cases / Security).
|
||||||
|
* - [RateLimited] — a 429 (stage/commit share one limiter, push a tighter one). Do NOT auto-retry.
|
||||||
|
*
|
||||||
|
* Nothing here throws on a bad body: a missing/garbled payload degrades to defaults (empty sha,
|
||||||
|
* empty pruned list) rather than crashing (tolerant-decode discipline, plan §8).
|
||||||
|
*/
|
||||||
|
public sealed interface GitWriteOutcome<out T> {
|
||||||
|
/** 200 — the op succeeded; [payload] is the route-specific success body. */
|
||||||
|
public data class Ok<out T>(val payload: T) : GitWriteOutcome<T>
|
||||||
|
|
||||||
|
/** A 4xx/5xx failure carrying the server's SAFE [message] (inert; may be null if unparseable). */
|
||||||
|
public data class Rejected(val status: Int, val message: String?) : GitWriteOutcome<Nothing>
|
||||||
|
|
||||||
|
/** 429 — the server rate-limited this write. */
|
||||||
|
public data object RateLimited : GitWriteOutcome<Nothing>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-op 200 payloads (all fields optional/defaulted → a garbled body degrades, never throws) ──
|
||||||
|
|
||||||
|
/** `POST /projects/git/stage` 200 → `{ ok, staged, count }`. */
|
||||||
|
@Serializable
|
||||||
|
public data class StageResult(val staged: Boolean = false, val count: Int = 0)
|
||||||
|
|
||||||
|
/** `POST /projects/git/commit` 200 → `{ ok, commit }` (short sha; may be `""` — empty is valid). */
|
||||||
|
@Serializable
|
||||||
|
public data class CommitResult(val commit: String = "")
|
||||||
|
|
||||||
|
/** `POST /projects/git/push` 200 → `{ ok, branch, remote }`. */
|
||||||
|
@Serializable
|
||||||
|
public data class PushResult(val branch: String? = null, val remote: String? = null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /projects/git/fetch` 200 payload (w6/G2).
|
||||||
|
*
|
||||||
|
* [lastFetchMs] is FETCH_HEAD's mtime AFTER the fetch, so the UI can stop saying "stale" without
|
||||||
|
* re-probing. On FAILURE the server deliberately leaves the old value alone, which is why this is
|
||||||
|
* nullable: a fetch that did not happen must keep reading as stale rather than pretending it refreshed.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
public data class FetchResult(val lastFetchMs: Long? = null)
|
||||||
|
|
||||||
|
/** `POST /projects/worktree` 200 → `{ ok, path, branch }`. */
|
||||||
|
@Serializable
|
||||||
|
public data class CreateWorktreeResult(val path: String? = null, val branch: String? = null)
|
||||||
|
|
||||||
|
/** `DELETE /projects/worktree` 200 → `{ ok, path }` (git's canonical removed path). */
|
||||||
|
@Serializable
|
||||||
|
public data class RemoveWorktreeResult(val path: String? = null)
|
||||||
|
|
||||||
|
/** `POST /projects/worktree/prune` 200 → `{ ok, pruned: [...] }` (empty = nothing to prune). */
|
||||||
|
@Serializable
|
||||||
|
public data class PruneWorktreesResult(val pruned: List<String> = emptyList())
|
||||||
|
|
||||||
|
/** Shape of a failure body — worktree routes emit `{ error }`, git-ops `{ ok:false, error }`; both
|
||||||
|
* carry `error` as a SAFE string. Decoded to surface [error] inertly. */
|
||||||
|
@Serializable
|
||||||
|
internal data class GitErrorBody(val ok: Boolean = false, val error: String? = null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a guarded 200 body into [T], degrading a missing/garbled body to the payload's defaults
|
||||||
|
* (never throws — the caller already knows the status is 200).
|
||||||
|
*/
|
||||||
|
internal fun <T> decodeGitPayload(bytes: ByteArray, deserializer: kotlinx.serialization.KSerializer<T>): T =
|
||||||
|
LossyDecode.objectOrNull(bytes, deserializer) ?: ModelJson.decodeFromString(deserializer, "{}")
|
||||||
|
|
||||||
|
/** Read the SAFE `error` string from a failure body; null when the body is empty/unparseable. */
|
||||||
|
internal fun decodeGitError(bytes: ByteArray): String? =
|
||||||
|
LossyDecode.objectOrNull(bytes, GitErrorBody.serializer())?.error
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two verdicts `POST /hook/decision` accepts — anything else is a 400 server-side. The [wire]
|
||||||
|
* value is what goes into the request body (`{ sessionId, decision, token }`).
|
||||||
|
*/
|
||||||
|
public enum class HookDecision(public val wire: String) {
|
||||||
|
ALLOW("allow"),
|
||||||
|
DENY("deny"),
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import wang.yaojia.webterm.wire.ClaudeStatus
|
||||||
|
import wang.yaojia.webterm.wire.StatusTelemetry
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One running (or just-exited) server session from `GET /live-sessions` (read-only discovery — NO
|
||||||
|
* Origin header). Mirrors `src/types.ts` `LiveSessionInfo` and iOS `APIClient.LiveSessionInfo`
|
||||||
|
* field-for-field.
|
||||||
|
*
|
||||||
|
* Tolerant decode at the untrusted boundary (plan §4):
|
||||||
|
* - identity/geometry ([id]/[createdAt]/[clientCount]/[exited]/[cols]/[rows]) are REQUIRED — an
|
||||||
|
* entry missing them is dropped by `LossyDecode.listOrNull`;
|
||||||
|
* - an unrecognized `status` string maps to [ClaudeStatus.UNKNOWN] (a future server status must
|
||||||
|
* not make a running session invisible);
|
||||||
|
* - absent `cwd`/`telemetry`/`lastOutputAt` degrade to `null`.
|
||||||
|
*
|
||||||
|
* NOTE: ms timestamps ([createdAt]/[lastOutputAt]) are `Long` — epoch-ms overflows a 32-bit `Int`
|
||||||
|
* (Swift's `Int` is 64-bit, so iOS used `Int`).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
public data class LiveSessionInfo(
|
||||||
|
@Serializable(with = UuidSerializer::class) val id: UUID,
|
||||||
|
/** Server `Date.now()` at spawn (ms since epoch). */
|
||||||
|
val createdAt: Long,
|
||||||
|
/** Devices currently attached (JOIN/mirror semantics). */
|
||||||
|
val clientCount: Int,
|
||||||
|
@Serializable(with = ClaudeStatusSerializer::class) val status: ClaudeStatus = ClaudeStatus.UNKNOWN,
|
||||||
|
val exited: Boolean,
|
||||||
|
val cwd: String? = null,
|
||||||
|
/** Tab title / derived label (e.g. 'claude', 'shell'), OSC-title-derived server-side (`src/types.ts`
|
||||||
|
* `title?`). HOST/ATTACKER-influenced — run through `TitleSanitizer` before any UI/list use (§8).
|
||||||
|
* Additive optional field; absent → `null`. */
|
||||||
|
val title: String? = null,
|
||||||
|
/** Current PTY size (latest-writer-wins on the server). */
|
||||||
|
val cols: Int,
|
||||||
|
val rows: Int,
|
||||||
|
/** Latest statusLine telemetry, if any (B2). */
|
||||||
|
val telemetry: StatusTelemetry? = null,
|
||||||
|
/** Server ms timestamp of the last PTY output (== createdAt until first output). Additive
|
||||||
|
* optional field — pre-P1 servers omit it; `null` means "no unread data source". */
|
||||||
|
val lastOutputAt: Long? = null,
|
||||||
|
/** W2 pending prompt-queue depth (`src/types.ts` `queueLength?`) — what the "N queued" badge
|
||||||
|
* renders. Additive OPTIONAL: `null` on a pre-W2 server (queue unsupported / unknown), `0` when
|
||||||
|
* the queue is empty; the badge shows only for a positive value. Decoded tolerantly
|
||||||
|
* ([LossyIntSerializer]) so a garbled value can never hide a running session. */
|
||||||
|
@Serializable(with = LossyIntSerializer::class) val queueLength: Int? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tolerant decode config for the UNTRUSTED server boundary (plan §4 / A8). The server is an
|
||||||
|
* untrusted input source here: unknown keys are ignored, malformed list elements are dropped one
|
||||||
|
* by one, and nothing crashes on bad input. The Android analogue of iOS's per-field `try?`
|
||||||
|
* tolerance in `APIClient/Models.swift`.
|
||||||
|
*
|
||||||
|
* ENCODE is deterministic (`ignoreUnknownKeys`/`isLenient` only affect parsing) so the same
|
||||||
|
* instance also encodes request bodies (`/hook/decision`, `/push/fcm-token`, `PUT /prefs`).
|
||||||
|
*/
|
||||||
|
internal val ModelJson: Json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-element / per-body tolerant decoders (mirror iOS `LiveSessionInfo.decodeList` /
|
||||||
|
* `LossyBox` / `LossyList`).
|
||||||
|
*
|
||||||
|
* - [listOrNull]: a non-array top level yields `null` (the caller raises
|
||||||
|
* `ApiClientError.InvalidResponseBody` — the pairing "port speaks HTTP but isn't web-terminal"
|
||||||
|
* signal); malformed elements are dropped, good ones kept.
|
||||||
|
* - [listOrEmpty]: a non-array top level yields `[]` (matches iOS `TimelineEvent.decodeList`, which
|
||||||
|
* the server itself returns when timeline capture is disabled).
|
||||||
|
* - [objectOrNull]: a single object that fails to decode yields `null` (caller raises
|
||||||
|
* `InvalidResponseBody`).
|
||||||
|
*/
|
||||||
|
internal object LossyDecode {
|
||||||
|
fun <T> listOrNull(bytes: ByteArray, element: KSerializer<T>): List<T>? {
|
||||||
|
val array = parseArray(bytes) ?: return null
|
||||||
|
return array.mapNotNull { el -> runCatching { ModelJson.decodeFromJsonElement(element, el) }.getOrNull() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> listOrEmpty(bytes: ByteArray, element: KSerializer<T>): List<T> =
|
||||||
|
listOrNull(bytes, element) ?: emptyList()
|
||||||
|
|
||||||
|
fun <T> objectOrNull(bytes: ByteArray, deserializer: KSerializer<T>): T? =
|
||||||
|
runCatching { ModelJson.decodeFromString(deserializer, bytes.decodeToString()) }.getOrNull()
|
||||||
|
|
||||||
|
private fun parseArray(bytes: ByteArray): JsonArray? =
|
||||||
|
runCatching { ModelJson.parseToJsonElement(bytes.decodeToString()) as? JsonArray }.getOrNull()
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a [PrStatus] has (or lacks) PR data (`src/types.ts` `PrAvailability`). Drives the detail
|
||||||
|
* chip's copy. Decoded via [PrAvailabilitySerializer]: an unknown/future value **degrades to
|
||||||
|
* [ERROR]** (never throws) — a new server availability must never make the chip crash.
|
||||||
|
*/
|
||||||
|
public enum class PrAvailability(public val wire: String) {
|
||||||
|
/** A PR exists for the current branch; the sibling fields are populated. */
|
||||||
|
OK("ok"),
|
||||||
|
|
||||||
|
/** gh works but the branch has no PR (or no remote/default repo). */
|
||||||
|
NO_PR("no-pr"),
|
||||||
|
|
||||||
|
/** `gh` binary not found on PATH (ENOENT). */
|
||||||
|
NOT_INSTALLED("not-installed"),
|
||||||
|
|
||||||
|
/** gh present but not logged in (needs `gh auth login`). */
|
||||||
|
UNAUTHENTICATED("unauthenticated"),
|
||||||
|
|
||||||
|
/** `GH_ENABLED=0` — feature off, never spawns gh. */
|
||||||
|
DISABLED("disabled"),
|
||||||
|
|
||||||
|
/** gh spawned but failed for another reason (timeout, etc.); also the unknown/missing fallback. */
|
||||||
|
ERROR("error"),
|
||||||
|
|
||||||
|
;
|
||||||
|
|
||||||
|
public companion object {
|
||||||
|
/** Map the wire string; unknown → [ERROR] (mirror of the FE never treating non-`ok` as fatal). */
|
||||||
|
public fun fromWire(wire: String): PrAvailability =
|
||||||
|
entries.firstOrNull { it.wire == wire } ?: ERROR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode [PrAvailability] by its `wire` value; an unknown/future value maps to [PrAvailability.ERROR]
|
||||||
|
* rather than throwing (mirror of [ClaudeStatusSerializer]). Serializes back the `wire` string.
|
||||||
|
*/
|
||||||
|
internal object PrAvailabilitySerializer : KSerializer<PrAvailability> {
|
||||||
|
override val descriptor: SerialDescriptor =
|
||||||
|
PrimitiveSerialDescriptor("PrAvailability", PrimitiveKind.STRING)
|
||||||
|
|
||||||
|
override fun deserialize(decoder: Decoder): PrAvailability =
|
||||||
|
PrAvailability.fromWire(decoder.decodeString())
|
||||||
|
|
||||||
|
override fun serialize(encoder: Encoder, value: PrAvailability) =
|
||||||
|
encoder.encodeString(value.wire)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rolled-up CI check counts from gh's statusCheckRollup (`src/types.ts` `PrCheckSummary`). */
|
||||||
|
@Serializable
|
||||||
|
public data class PrCheckSummary(
|
||||||
|
val total: Int = 0,
|
||||||
|
val passing: Int = 0,
|
||||||
|
val failing: Int = 0,
|
||||||
|
val pending: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /projects/pr` result (`src/types.ts` `PrStatus`). Every field except [availability] is
|
||||||
|
* optional (present only when `availability == ok`); [availability] itself defaults to
|
||||||
|
* [PrAvailability.ERROR] so a body missing the field still decodes (never throws). `state` /
|
||||||
|
* `mergeable` are lower-cased string unions on the wire — kept as raw INERT strings here (rendered
|
||||||
|
* as plain text; no enum needed for display).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
public data class PrStatus(
|
||||||
|
@Serializable(with = PrAvailabilitySerializer::class)
|
||||||
|
val availability: PrAvailability = PrAvailability.ERROR,
|
||||||
|
val number: Int? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val url: String? = null,
|
||||||
|
val state: String? = null,
|
||||||
|
val isDraft: Boolean? = null,
|
||||||
|
val mergeable: String? = null,
|
||||||
|
val headRefName: String? = null,
|
||||||
|
val baseRefName: String? = null,
|
||||||
|
val checks: PrCheckSummary? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import wang.yaojia.webterm.wire.ClaudeStatus
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One running session belonging to a project (`src/types.ts` `ProjectSessionRef`). The server
|
||||||
|
* mirrors [LiveSessionInfo] fields into this ref. Tolerant: unknown status → [ClaudeStatus.UNKNOWN],
|
||||||
|
* absent title → `null`; missing id/clientCount/createdAt/exited drops the ref (nested lossy list).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
public data class ProjectSessionRef(
|
||||||
|
@Serializable(with = UuidSerializer::class) val id: UUID,
|
||||||
|
val title: String? = null,
|
||||||
|
@Serializable(with = ClaudeStatusSerializer::class) val status: ClaudeStatus = ClaudeStatus.UNKNOWN,
|
||||||
|
val clientCount: Int,
|
||||||
|
val createdAt: Long,
|
||||||
|
val exited: Boolean,
|
||||||
|
/**
|
||||||
|
* w6/G7: the session's working directory, needed to attribute it to a worktree. Matching is
|
||||||
|
* DEEPEST-first, because `.claude/worktrees/<name>` lives INSIDE the main checkout and a prefix
|
||||||
|
* match would count every worktree session against the parent repo too.
|
||||||
|
*/
|
||||||
|
val cwd: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A discovered project (git repo or recently-used cwd) — `GET /projects` (`src/types.ts`
|
||||||
|
* `ProjectInfo`). There is NO `namespace` field on the wire; namespace grouping is a client concept
|
||||||
|
* surfaced only via `UiPrefs.collapsed` group-keys.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
public data class ProjectInfo(
|
||||||
|
val name: String,
|
||||||
|
val path: String,
|
||||||
|
val isGit: Boolean,
|
||||||
|
val branch: String? = null,
|
||||||
|
/** Uncommitted changes; only present when the server runs the dirty check. */
|
||||||
|
val dirty: Boolean? = null,
|
||||||
|
/** Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key. */
|
||||||
|
val lastActiveMs: Long? = null,
|
||||||
|
/** W3 sync chip — commits on HEAD not on `@{u}` (best-effort; absent when no upstream). */
|
||||||
|
val ahead: Int? = null,
|
||||||
|
/** W3 sync chip — commits on `@{u}` not on HEAD (best-effort; absent when no upstream). */
|
||||||
|
val behind: Int? = null,
|
||||||
|
/** HEAD commit time in ms (`git log -1 --format=%ct * 1000`); absent on a fresh/empty repo. */
|
||||||
|
val lastCommitMs: Long? = null,
|
||||||
|
/** w6/G1: porcelain line count, gated the same way as [dirty]. */
|
||||||
|
val dirtyCount: Int? = null,
|
||||||
|
@Serializable(with = ProjectSessionRefListSerializer::class)
|
||||||
|
val sessions: List<ProjectSessionRef> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One entry from `git worktree list --porcelain` (`src/types.ts` `WorktreeInfo`). */
|
||||||
|
@Serializable
|
||||||
|
public data class WorktreeInfo(
|
||||||
|
val path: String,
|
||||||
|
/** Branch name; `null` on detached HEAD. */
|
||||||
|
val branch: String? = null,
|
||||||
|
val head: String? = null,
|
||||||
|
val isMain: Boolean = false,
|
||||||
|
val isCurrent: Boolean = false,
|
||||||
|
val locked: Boolean? = null,
|
||||||
|
val prunable: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Detailed view of one project — `GET /projects/detail?path=` (`src/types.ts` `ProjectDetail`). */
|
||||||
|
@Serializable
|
||||||
|
public data class ProjectDetail(
|
||||||
|
val name: String,
|
||||||
|
val path: String,
|
||||||
|
val isGit: Boolean,
|
||||||
|
val branch: String? = null,
|
||||||
|
val dirty: Boolean? = null,
|
||||||
|
/** w6/G1: porcelain line count, gated the same way as [dirty]. */
|
||||||
|
val dirtyCount: Int? = null,
|
||||||
|
/** w6/G1: git repos only; null for a non-git dir. See [SyncState] for what may be trusted. */
|
||||||
|
val sync: SyncState? = null,
|
||||||
|
@Serializable(with = WorktreeInfoListSerializer::class)
|
||||||
|
val worktrees: List<WorktreeInfo> = emptyList(),
|
||||||
|
@Serializable(with = ProjectSessionRefListSerializer::class)
|
||||||
|
val sessions: List<ProjectSessionRef> = emptyList(),
|
||||||
|
val hasClaudeMd: Boolean = false,
|
||||||
|
/** CLAUDE.md content (server-truncated for display) when present. */
|
||||||
|
val claudeMd: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* W2 prompt queue — the server-side FIFO of follow-up prompts injected into a session's PTY the next
|
||||||
|
* time Claude goes idle (`src/server.ts` ~605/644/654, `SessionManager.enqueueFollowup`/`clearQueue`).
|
||||||
|
*
|
||||||
|
* A queued entry is **raw keyboard input for a shell** (invariant #9 / byte-shuttle): it is stored and
|
||||||
|
* later written to the PTY verbatim. Nothing on this path may trim, escape, re-encode or normalise it —
|
||||||
|
* the only transformation is the server appending `\r` when the caller passed `appendEnter = true`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /live-sessions/:id/queue` 200 → `{ length, items }` — the pending depth plus the queued texts
|
||||||
|
* (verbatim keystroke bytes). Both fields default, so a missing/garbled key degrades to an empty queue
|
||||||
|
* instead of throwing; a non-object body yields `null` from `LossyDecode` (→ `InvalidResponseBody`).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
public data class QueueSnapshot(
|
||||||
|
/** Pending entries on the server. Equals `items.size` for a healthy server; trust [items] for
|
||||||
|
* rendering and [length] only as the badge number the server itself broadcasts. */
|
||||||
|
val length: Int = 0,
|
||||||
|
/** The queued prompts in FIFO order, verbatim. */
|
||||||
|
val items: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of the two GUARDED queue writes — `POST` (enqueue) and `DELETE` (cancel-all). One union for
|
||||||
|
* both ops (the `GitWriteOutcome` precedent: one union, six routes); [Full], [TooLarge] and [Disabled]
|
||||||
|
* are simply unreachable for the DELETE, whose handler neither validates text nor consults the
|
||||||
|
* `QUEUE_ENABLED` kill-switch.
|
||||||
|
*
|
||||||
|
* Every variant maps to different UI behaviour, which is why they are typed rather than folded into a
|
||||||
|
* status code:
|
||||||
|
* - [Ok] — the server accepted it; [length] is the authoritative new depth for the "N queued" badge.
|
||||||
|
* - [Full] — 409, the bounded FIFO is at `QUEUE_MAX_ITEMS` (default 10). Cancel something first;
|
||||||
|
* the server never silently drops.
|
||||||
|
* - [TooLarge] — 413, over `QUEUE_ITEM_MAX_BYTES` (default 4 KiB, server-configurable — the client
|
||||||
|
* deliberately does NOT pre-validate size, which would invent a policy it cannot know).
|
||||||
|
* - [Disabled] — 503, `QUEUE_ENABLED=0` on this host: hide the affordance, do not retry.
|
||||||
|
* - [SessionGone] — 404, unknown or already-exited session.
|
||||||
|
* - [RateLimited] — 429 from the shared `QUEUE_RATE_MAX = 20`/minute/IP bucket. **Never auto-retry**;
|
||||||
|
* a retry loop just burns the same budget a real enqueue needs.
|
||||||
|
* - [Rejected] — any other 4xx/5xx, carrying the server's SAFE `error` string verbatim (`message` may
|
||||||
|
* be `null` when the body is empty, e.g. the Origin guard's bare 403).
|
||||||
|
*/
|
||||||
|
public sealed interface QueueWriteOutcome {
|
||||||
|
/** 200 — accepted; [length] is the new pending depth (0 for a cleared queue). */
|
||||||
|
public data class Ok(val length: Int) : QueueWriteOutcome
|
||||||
|
|
||||||
|
/** 409 — the bounded FIFO is full (`QUEUE_MAX_ITEMS`). */
|
||||||
|
public data object Full : QueueWriteOutcome
|
||||||
|
|
||||||
|
/** 413 — the prompt exceeds the server's per-item byte cap. */
|
||||||
|
public data object TooLarge : QueueWriteOutcome
|
||||||
|
|
||||||
|
/** 503 — the queue feature is switched off on this host. */
|
||||||
|
public data object Disabled : QueueWriteOutcome
|
||||||
|
|
||||||
|
/** 404 — the session is unknown or has already exited. */
|
||||||
|
public data object SessionGone : QueueWriteOutcome
|
||||||
|
|
||||||
|
/** 429 — shared per-IP queue bucket. Do NOT auto-retry. */
|
||||||
|
public data object RateLimited : QueueWriteOutcome
|
||||||
|
|
||||||
|
/** Any other failure, with the server's inert message (null when unparseable/empty). */
|
||||||
|
public data class Rejected(val status: Int, val message: String?) : QueueWriteOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the new depth out of a queue write's 200 body (`{ length }`). The status already says it
|
||||||
|
* succeeded, so a missing/garbled body degrades to `0` rather than throwing — the next `queue` read or
|
||||||
|
* `queue` WS frame re-establishes the true depth.
|
||||||
|
*/
|
||||||
|
internal fun decodeQueueDepth(bytes: ByteArray): Int =
|
||||||
|
LossyDecode.objectOrNull(bytes, QueueSnapshot.serializer())?.length ?: 0
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
|
import kotlinx.serialization.builtins.nullable
|
||||||
|
import kotlinx.serialization.builtins.serializer
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonDecoder
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
|
import kotlinx.serialization.json.intOrNull
|
||||||
|
import wang.yaojia.webterm.wire.ClaudeStatus
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a server session id as [UUID]. Server ids are lowercase `crypto.randomUUID()` strings;
|
||||||
|
* a non-UUID string throws, so `LossyDecode` drops that list element (matches iOS decoding
|
||||||
|
* `UUID.self` — a malformed id must not surface). Serializes back lowercase (`UUID.toString()`).
|
||||||
|
*/
|
||||||
|
internal object UuidSerializer : KSerializer<UUID> {
|
||||||
|
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
|
||||||
|
override fun deserialize(decoder: Decoder): UUID = UUID.fromString(decoder.decodeString())
|
||||||
|
override fun serialize(encoder: Encoder, value: UUID) = encoder.encodeString(value.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode [ClaudeStatus] by its `wire` value; an unknown/future value maps to
|
||||||
|
* [ClaudeStatus.UNKNOWN] rather than dropping the entry — a new server status must never make a
|
||||||
|
* running session invisible (iOS `rawStatus.flatMap(...) ?? .unknown`).
|
||||||
|
*/
|
||||||
|
internal object ClaudeStatusSerializer : KSerializer<ClaudeStatus> {
|
||||||
|
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ClaudeStatus", PrimitiveKind.STRING)
|
||||||
|
override fun deserialize(decoder: Decoder): ClaudeStatus =
|
||||||
|
ClaudeStatus.fromWire(decoder.decodeString()) ?: ClaudeStatus.UNKNOWN
|
||||||
|
override fun serialize(encoder: Encoder, value: ClaudeStatus) = encoder.encodeString(value.wire)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tolerant `Int?` field decoder: a wrong-typed/garbled value degrades to `null` instead of failing
|
||||||
|
* the whole enclosing object. Used for ADDITIVE OPTIONAL numeric fields (e.g.
|
||||||
|
* `LiveSessionInfo.queueLength`) where dropping the entry would hide a running session for the sake of
|
||||||
|
* a badge. Required numeric fields deliberately keep the strict built-in behaviour.
|
||||||
|
*/
|
||||||
|
internal object LossyIntSerializer : KSerializer<Int?> {
|
||||||
|
private val delegate: KSerializer<Int?> = Int.serializer().nullable
|
||||||
|
override val descriptor: SerialDescriptor = delegate.descriptor
|
||||||
|
override fun serialize(encoder: Encoder, value: Int?) = delegate.serialize(encoder, value)
|
||||||
|
override fun deserialize(decoder: Decoder): Int? {
|
||||||
|
val json = decoder as? JsonDecoder ?: return delegate.deserialize(decoder)
|
||||||
|
val primitive = json.decodeJsonElement() as? JsonPrimitive ?: return null
|
||||||
|
return if (primitive.isString) primitive.content.toIntOrNull() else primitive.intOrNull
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `List<T>` serializer that drops malformed elements and degrades a non-array to `[]` — the
|
||||||
|
* Android analogue of iOS's `LossyList.decode` for NESTED arrays (`ProjectInfo.sessions`,
|
||||||
|
* `ProjectDetail.worktrees`). A bad nested element must not fail the whole parent object.
|
||||||
|
*/
|
||||||
|
internal class LossyListSerializer<T>(private val element: KSerializer<T>) : KSerializer<List<T>> {
|
||||||
|
private val delegate = ListSerializer(element)
|
||||||
|
override val descriptor: SerialDescriptor = delegate.descriptor
|
||||||
|
override fun serialize(encoder: Encoder, value: List<T>) = delegate.serialize(encoder, value)
|
||||||
|
override fun deserialize(decoder: Decoder): List<T> {
|
||||||
|
val json = decoder as? JsonDecoder ?: return delegate.deserialize(decoder)
|
||||||
|
val array = json.decodeJsonElement() as? JsonArray ?: return emptyList()
|
||||||
|
return array.mapNotNull { runCatching { json.json.decodeFromJsonElement(element, it) }.getOrNull() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object ProjectSessionRefListSerializer :
|
||||||
|
KSerializer<List<ProjectSessionRef>> by LossyListSerializer(ProjectSessionRef.serializer())
|
||||||
|
|
||||||
|
internal object WorktreeInfoListSerializer :
|
||||||
|
KSerializer<List<WorktreeInfo>> by LossyListSerializer(WorktreeInfo.serializer())
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /live-sessions/:id/preview` response: the tail of the session's ring buffer for a
|
||||||
|
* read-only thumbnail (no attach, no client registered). [data] is opaque ANSI/UTF-8 — feed it to
|
||||||
|
* a terminal, never parse it. All fields required (a malformed body → `InvalidResponseBody`).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
public data class SessionPreview(
|
||||||
|
@Serializable(with = UuidSerializer::class) val id: UUID,
|
||||||
|
val cols: Int,
|
||||||
|
val rows: Int,
|
||||||
|
val data: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* w6/G1 — how far the checked-out branch has drifted from its upstream (`src/types.ts` `SyncState`).
|
||||||
|
*
|
||||||
|
* ## The one rule this whole feature turns on
|
||||||
|
* `ahead`/`behind` are computed against `@{u}`, a **locally cached** remote ref that only a `fetch`
|
||||||
|
* moves. That asymmetry decides what may be trusted:
|
||||||
|
*
|
||||||
|
* - [ahead] needs only local refs, so it is always trustworthy.
|
||||||
|
* - [behind] is only meaningful if [lastFetchMs] is recent. A stale FETCH_HEAD reports `behind = 0`
|
||||||
|
* for a branch that is in fact many commits behind — the server's own commit message records finding
|
||||||
|
* exactly that (`↓0` while FETCH_HEAD had not moved in 19 days).
|
||||||
|
* - **No upstream leaves [ahead] and [behind] undefined**, which is NOT the same as zero. A fresh
|
||||||
|
* worktree branch normally has no upstream, and rendering it as "in sync" is the single easiest bug
|
||||||
|
* to ship here. [isFullySynced] is the only sanctioned way to ask "may I render the all-clear?" and
|
||||||
|
* it answers false whenever anything is unknown. Do not re-derive that test at a call site.
|
||||||
|
*
|
||||||
|
* Every field is optional because every one of them degrades independently on the server (no upstream,
|
||||||
|
* detached HEAD, never fetched, not a git repo).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
public data class SyncState(
|
||||||
|
/** e.g. `origin/develop`; null ⇒ the branch tracks nothing. */
|
||||||
|
val upstream: String? = null,
|
||||||
|
/** Commits on HEAD not on `@{u}`. Null ⇒ unknown (no upstream), NOT zero. */
|
||||||
|
val ahead: Int? = null,
|
||||||
|
/** Commits on `@{u}` not on HEAD. Null ⇒ unknown. Trust only when [isFetchFresh]. */
|
||||||
|
val behind: Int? = null,
|
||||||
|
/** FETCH_HEAD mtime in ms; null ⇒ never fetched. */
|
||||||
|
val lastFetchMs: Long? = null,
|
||||||
|
/** HEAD is not on a branch ⇒ no branch, no ahead/behind, and fetch/push are not offerable. */
|
||||||
|
val detached: Boolean = false,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** True when there is an upstream to compare against at all. */
|
||||||
|
public val hasUpstream: Boolean get() = upstream != null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when [lastFetchMs] is recent enough for [behind] to mean anything.
|
||||||
|
*
|
||||||
|
* @param nowMs caller-supplied clock so this stays pure and testable.
|
||||||
|
*/
|
||||||
|
public fun isFetchFresh(nowMs: Long): Boolean {
|
||||||
|
val fetched = lastFetchMs ?: return false
|
||||||
|
val age = nowMs - fetched
|
||||||
|
// A clock skew that puts the fetch in the future is not evidence of freshness.
|
||||||
|
return age in 0..FETCH_FRESH_WINDOW_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when [behind] should be shown with a "stale — I have not checked" qualifier. */
|
||||||
|
public fun isBehindStale(nowMs: Long): Boolean = hasUpstream && !isFetchFresh(nowMs)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ONLY state that may render as the green all-clear: nothing to push, nothing to pull, and a
|
||||||
|
* fresh enough fetch to justify saying so.
|
||||||
|
*
|
||||||
|
* Green means "I checked, you can ignore this". Getting it wrong is lying to the user, so anything
|
||||||
|
* unknown — no upstream, detached, never fetched, stale fetch, null counts — is false.
|
||||||
|
*/
|
||||||
|
public fun isFullySynced(nowMs: Long): Boolean =
|
||||||
|
hasUpstream && !detached && ahead == 0 && behind == 0 && isFetchFresh(nowMs)
|
||||||
|
|
||||||
|
public companion object {
|
||||||
|
/**
|
||||||
|
* How long a fetch stays "fresh". Mirrors the server rule that `behind` is flagged stale once
|
||||||
|
* FETCH_HEAD is older than an hour (w6/G1).
|
||||||
|
*/
|
||||||
|
public const val FETCH_FRESH_WINDOW_MS: Long = 60L * 60L * 1000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* w6/G7 — the git state of ONE worktree, fetched lazily per row via `GET /projects/worktree/state`
|
||||||
|
* (`src/types.ts` `WorktreeState`).
|
||||||
|
*
|
||||||
|
* Deliberately narrower than `ProjectDetail`: N rows must not each pay for a worktree listing and a
|
||||||
|
* CLAUDE.md read that nothing renders.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
public data class WorktreeState(
|
||||||
|
val path: String,
|
||||||
|
val branch: String? = null,
|
||||||
|
val sync: SyncState? = null,
|
||||||
|
val dirtyCount: Int? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /config/ui` response (`{ allowAutoMode }`). Reserved for a future permission-mode switcher
|
||||||
|
* (filters the high-risk raw `auto` mode); the plan-gate three-way UI does not consume it.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
public data class UiConfig(
|
||||||
|
val allowAutoMode: Boolean,
|
||||||
|
)
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cross-device Projects-UI prefs blob (`GET /prefs` RO · `PUT /prefs` G) — an
|
||||||
|
* OPAQUE-BUT-VALIDATED JSON object round-trip. Known keys mirror the web client
|
||||||
|
* (`favourites: string[]`, `collapsed: { group-key: true }`); ALL OTHER top-level keys are
|
||||||
|
* preserved verbatim across decode → mutate → encode, so an Android `PUT` can never clobber prefs
|
||||||
|
* written by the web/iOS client or a future server (`PUT` replaces the WHOLE blob server-side, so
|
||||||
|
* key preservation is correctness, not politeness).
|
||||||
|
*
|
||||||
|
* Immutable snapshot: [withFavourites]/[withCollapsed] return NEW copies that replace exactly one
|
||||||
|
* known key and carry every other key through untouched. Numbers round-trip verbatim because a
|
||||||
|
* parsed [JsonPrimitive] keeps its source token (`42` never re-encodes as `42.0`).
|
||||||
|
*/
|
||||||
|
public class UiPrefs private constructor(private val storage: JsonObject) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Favourited project paths (★): non-empty JSON strings, de-duplicated, original order kept.
|
||||||
|
* Wrong-typed entries are dropped, not fatal (mirrors `public/prefs.ts sanitizePrefs`).
|
||||||
|
*/
|
||||||
|
public val favourites: List<String>
|
||||||
|
get() {
|
||||||
|
val array = storage[KEY_FAVOURITES] as? JsonArray ?: return emptyList()
|
||||||
|
val out = LinkedHashSet<String>()
|
||||||
|
for (element in array) {
|
||||||
|
val primitive = element as? JsonPrimitive ?: continue
|
||||||
|
if (!primitive.isString) continue
|
||||||
|
val path = primitive.content
|
||||||
|
if (path.isNotEmpty()) out.add(path)
|
||||||
|
}
|
||||||
|
return out.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Namespace group-key → collapsed. Only literal `true` values count (expanded is the default;
|
||||||
|
* both web and server sanitizers agree). A JSON string `"true"` does NOT count.
|
||||||
|
*/
|
||||||
|
public val collapsed: Map<String, Boolean>
|
||||||
|
get() {
|
||||||
|
val obj = storage[KEY_COLLAPSED] as? JsonObject ?: return emptyMap()
|
||||||
|
val out = LinkedHashMap<String, Boolean>()
|
||||||
|
for ((key, value) in obj) {
|
||||||
|
if (key.isEmpty()) continue
|
||||||
|
if (value is JsonPrimitive && !value.isString && value.booleanOrNull == true) {
|
||||||
|
out[key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** New snapshot with `favourites` replaced; every other key untouched (position preserved). */
|
||||||
|
public fun withFavourites(favourites: List<String>): UiPrefs {
|
||||||
|
val next = LinkedHashMap<String, JsonElement>(storage)
|
||||||
|
next[KEY_FAVOURITES] = JsonArray(favourites.map { JsonPrimitive(it) })
|
||||||
|
return UiPrefs(JsonObject(next))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** New snapshot with `collapsed` replaced; every other key untouched (position preserved). */
|
||||||
|
public fun withCollapsed(collapsed: Map<String, Boolean>): UiPrefs {
|
||||||
|
val next = LinkedHashMap<String, JsonElement>(storage)
|
||||||
|
next[KEY_COLLAPSED] = JsonObject(collapsed.mapValues { JsonPrimitive(it.value) })
|
||||||
|
return UiPrefs(JsonObject(next))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encode the FULL blob (known + unknown keys) for `PUT /prefs`. */
|
||||||
|
public fun encodeBody(): ByteArray =
|
||||||
|
ModelJson.encodeToString(JsonObject.serializer(), storage).encodeToByteArray()
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean = other is UiPrefs && other.storage == storage
|
||||||
|
override fun hashCode(): Int = storage.hashCode()
|
||||||
|
override fun toString(): String = "UiPrefs(storage=$storage)"
|
||||||
|
|
||||||
|
public companion object {
|
||||||
|
private const val KEY_FAVOURITES = "favourites"
|
||||||
|
private const val KEY_COLLAPSED = "collapsed"
|
||||||
|
|
||||||
|
/** Fresh prefs (e.g. first write from a device that never fetched). */
|
||||||
|
public fun create(
|
||||||
|
favourites: List<String> = emptyList(),
|
||||||
|
collapsed: Map<String, Boolean> = emptyMap(),
|
||||||
|
): UiPrefs = UiPrefs(
|
||||||
|
JsonObject(
|
||||||
|
mapOf(
|
||||||
|
KEY_FAVOURITES to JsonArray(favourites.map { JsonPrimitive(it) }),
|
||||||
|
KEY_COLLAPSED to JsonObject(collapsed.mapValues { JsonPrimitive(it.value) }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a `/prefs` body. `null` = top level is not a JSON object — callers surface
|
||||||
|
* `InvalidResponseBody` LOUDLY instead of degrading to empty prefs (an empty-based `PUT`
|
||||||
|
* would wipe the server blob).
|
||||||
|
*/
|
||||||
|
public fun decode(bytes: ByteArray): UiPrefs? {
|
||||||
|
val element = runCatching { ModelJson.parseToJsonElement(bytes.decodeToString()) }.getOrNull()
|
||||||
|
return (element as? JsonObject)?.let { UiPrefs(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package wang.yaojia.webterm.api.pairing
|
||||||
|
|
||||||
|
import wang.yaojia.webterm.api.routes.Endpoints
|
||||||
|
import wang.yaojia.webterm.wire.AccessTokenRule
|
||||||
|
import wang.yaojia.webterm.wire.AuthCookie
|
||||||
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
|
import wang.yaojia.webterm.wire.HttpResponse
|
||||||
|
import wang.yaojia.webterm.wire.HttpTransport
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The outcome of the pairing-time access-token validation probe (`POST /auth`). The first four cases
|
||||||
|
* are the FROZEN four the server distinguishes (ios-completion §1.1); [Malformed] is a client-side
|
||||||
|
* pre-check and [Unexpected] keeps an unknown status from being mistaken for success.
|
||||||
|
*/
|
||||||
|
public sealed interface AuthProbeResult {
|
||||||
|
/** **204 + `Set-Cookie: webterm_auth=…`** — the token is correct. Persist it for this host. */
|
||||||
|
public data object Accepted : AuthProbeResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **204 with NO `Set-Cookie`** — this host has `WEBTERM_TOKEN` unset, so there is nothing to
|
||||||
|
* authenticate. The token MUST NOT be persisted, and this MUST NOT be read as "authenticated"
|
||||||
|
* (frozen §1.1): the host is simply open, exactly as a zero-config LAN deploy.
|
||||||
|
*/
|
||||||
|
public data object AuthDisabled : AuthProbeResult
|
||||||
|
|
||||||
|
/** **401** — wrong token. UI: "访问令牌不正确". */
|
||||||
|
public data object InvalidToken : AuthProbeResult
|
||||||
|
|
||||||
|
/** **429** — the server's `/auth` limiter (10/min/IP) tripped. UI: "尝试过多,稍后再试". */
|
||||||
|
public data object RateLimited : AuthProbeResult
|
||||||
|
|
||||||
|
/** The token cannot be a valid server token ([AccessTokenRule]) — rejected before any network I/O. */
|
||||||
|
public data object Malformed : AuthProbeResult
|
||||||
|
|
||||||
|
/** Any other status (e.g. a captive portal's 302). Never treated as success. */
|
||||||
|
public data class Unexpected(val status: Int) : AuthProbeResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate [token] against [endpoint] via **`POST /auth`** — a one-shot pairing-time probe, NOT a
|
||||||
|
* session-establishing login: the native client keeps stamping its own `Cookie` header afterwards and
|
||||||
|
* ignores the `Set-Cookie` value entirely (frozen §1.1). The response's `Set-Cookie` is used only as a
|
||||||
|
* BOOLEAN signal (was the token accepted, or is auth disabled on this host?).
|
||||||
|
*
|
||||||
|
* Transport-level failures propagate unwrapped so the caller can classify them with
|
||||||
|
* [PairingError.classify] (same convention as [runPairingProbe]).
|
||||||
|
*
|
||||||
|
* Never logs the token; the token appears only in the JSON body, never in the URL.
|
||||||
|
*/
|
||||||
|
public suspend fun probeAccessToken(
|
||||||
|
endpoint: HostEndpoint,
|
||||||
|
http: HttpTransport,
|
||||||
|
token: String,
|
||||||
|
): AuthProbeResult {
|
||||||
|
// Boundary validation first: a token outside the server's charset/length can never be correct, and
|
||||||
|
// there is no point spending a rate-limit slot (or shipping it over the wire) to find that out.
|
||||||
|
val normalized = AccessTokenRule.normalize(token) ?: return AuthProbeResult.Malformed
|
||||||
|
val request = Endpoints.auth(normalized).toHttpRequest(endpoint)
|
||||||
|
?: return AuthProbeResult.Unexpected(UNBUILDABLE_REQUEST)
|
||||||
|
val response = http.send(request)
|
||||||
|
return classifyAuthResponse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun classifyAuthResponse(response: HttpResponse): AuthProbeResult = when (response.status) {
|
||||||
|
HTTP_NO_CONTENT ->
|
||||||
|
// The ONE discriminator between "token correct" and "this host has no auth at all".
|
||||||
|
if (response.carriesAuthCookie()) AuthProbeResult.Accepted else AuthProbeResult.AuthDisabled
|
||||||
|
HTTP_UNAUTHORIZED -> AuthProbeResult.InvalidToken
|
||||||
|
HTTP_TOO_MANY_REQUESTS -> AuthProbeResult.RateLimited
|
||||||
|
else -> AuthProbeResult.Unexpected(response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True iff the response set OUR auth cookie. Header names are case-insensitive on the wire, and the
|
||||||
|
* value must name [AuthCookie.NAME] — some other service's `Set-Cookie` (a proxy's session id) proves
|
||||||
|
* nothing about our token.
|
||||||
|
*/
|
||||||
|
private fun HttpResponse.carriesAuthCookie(): Boolean =
|
||||||
|
headers.entries.any { (name, value) ->
|
||||||
|
name.equals(SET_COOKIE_HEADER, ignoreCase = true) && value.startsWith("${AuthCookie.NAME}=")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sentinel status for "the request could not even be built" — unreachable for a validated
|
||||||
|
* [HostEndpoint], surfaced instead of crashing (never mistaken for a real HTTP status).
|
||||||
|
*/
|
||||||
|
private const val UNBUILDABLE_REQUEST = 0
|
||||||
|
|
||||||
|
private const val SET_COOKIE_HEADER = "Set-Cookie"
|
||||||
|
private const val HTTP_NO_CONTENT = 204
|
||||||
|
private const val HTTP_UNAUTHORIZED = 401
|
||||||
|
private const val HTTP_TOO_MANY_REQUESTS = 429
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package wang.yaojia.webterm.api.pairing
|
||||||
|
|
||||||
|
import wang.yaojia.webterm.wire.HostClassifier
|
||||||
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
|
import java.io.InterruptedIOException
|
||||||
|
import java.net.ConnectException
|
||||||
|
import java.net.NoRouteToHostException
|
||||||
|
import java.net.PortUnreachableException
|
||||||
|
import java.net.SocketException
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
import java.net.UnknownServiceException
|
||||||
|
import java.security.cert.CertPathBuilderException
|
||||||
|
import java.security.cert.CertPathValidatorException
|
||||||
|
import java.security.cert.CertificateException
|
||||||
|
import javax.net.ssl.SSLException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error taxonomy for the pairing probe (Android port of iOS `APIClient.PairingError`). Each case
|
||||||
|
* maps one probe failure mode to actionable UI copy (`PairingViewModel`, A19, renders these).
|
||||||
|
*
|
||||||
|
* DEVIATION from iOS (plan R12): the iOS `localNetworkDenied` case is DROPPED — Android has no
|
||||||
|
* iOS-style Local-Network permission prompt (a plain WS to a LAN IP needs no runtime permission),
|
||||||
|
* so the ENETDOWN→localNetworkDenied diagnosis is dead weight here. iOS `atsBlocked` is kept as
|
||||||
|
* [CleartextBlocked]: the genuine Android analogue is a `network_security_config` cleartext block
|
||||||
|
* surfaced as [java.net.UnknownServiceException] (plan §6.9/§8 cleartext posture).
|
||||||
|
*/
|
||||||
|
public sealed interface PairingError {
|
||||||
|
/** Nothing answered — connection refused / no route / DNS failure (`ConnectException`, `UnknownHostException`, …). */
|
||||||
|
public data class HostUnreachable(val underlying: String) : PairingError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The port speaks HTTP but `GET /live-sessions` did not return the web-terminal shape (a JSON
|
||||||
|
* array) — "端口对吗?". Also used when probe ② connects but the socket never speaks our protocol.
|
||||||
|
*/
|
||||||
|
public data object HttpOkButNotWebTerminal : PairingError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The WS upgrade (or the guarded kill round-trip) was rejected — the host's Origin whitelist
|
||||||
|
* does not contain our dialed origin. [hint] carries the exact `ALLOWED_ORIGINS=<origin>` line,
|
||||||
|
* always derived from [HostEndpoint.originHeader] (never hand-assembled; default ports omitted).
|
||||||
|
*/
|
||||||
|
public data class OriginRejected(val hint: String) : PairingError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleartext (`ws://`/`http://`) to a host outside the app's `network_security_config` allowlist
|
||||||
|
* was blocked by the platform ([java.net.UnknownServiceException]). Android analogue of iOS
|
||||||
|
* `atsBlocked`. [host] is the dialed host for the actionable copy.
|
||||||
|
*/
|
||||||
|
public data class CleartextBlocked(val host: String) : PairingError
|
||||||
|
|
||||||
|
/** TLS negotiation / certificate failure on an https/wss target (`SSLException`/`CertificateException`). */
|
||||||
|
public data object TlsFailure : PairingError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The host answered **401** to the probe's HTTP legs — it has `WEBTERM_TOKEN` set and we presented
|
||||||
|
* no cookie / a wrong one (ios-completion §1.1). Distinct from [OriginRejected]: this is fixable by
|
||||||
|
* entering the host's access token, not by editing `ALLOWED_ORIGINS`.
|
||||||
|
*
|
||||||
|
* NOTE the probe's ORDERING is what makes the two distinguishable: probe ① authenticates over HTTP
|
||||||
|
* first, so a 401 there is the token; once ① has passed with our cookie, a 401 on the later WS
|
||||||
|
* upgrade can only be the Origin allow-list (the server writes the same bare 401 for both).
|
||||||
|
*/
|
||||||
|
public data object AccessTokenRequired : PairingError
|
||||||
|
|
||||||
|
/** The probe deadline elapsed, or the transport timed out (`SocketTimeoutException`). */
|
||||||
|
public data object Timeout : PairingError
|
||||||
|
|
||||||
|
public companion object {
|
||||||
|
/**
|
||||||
|
* Actionable copy for [OriginRejected] — always derived from the SINGLE origin source
|
||||||
|
* ([HostEndpoint.originHeader]), never hand-assembled. Ported verbatim from iOS.
|
||||||
|
*/
|
||||||
|
public fun originRejectedHint(endpoint: HostEndpoint): String =
|
||||||
|
"服务器拒绝了这个来源。请在主机上设置 ALLOWED_ORIGINS=${endpoint.originHeader}" +
|
||||||
|
"(与 App 连接的 URL 完全一致)后重启 web-terminal,再重试配对。"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a transport-level [Throwable] thrown by [wang.yaojia.webterm.wire.HttpTransport]
|
||||||
|
* or [wang.yaojia.webterm.wire.TermTransport] into the probe taxonomy. Walks the bounded
|
||||||
|
* `cause` chain (OkHttp wraps causes; never trust an error graph not to cycle) so wrapped
|
||||||
|
* roots (e.g. an `IOException` wrapping a `ConnectException`) are seen.
|
||||||
|
*
|
||||||
|
* @param unrecognizedFallback used by probe step ② — after step ① proved the host reachable
|
||||||
|
* AND web-terminal-shaped, an upgrade failure with no recognizable network cause is, by
|
||||||
|
* elimination, the server's Origin 401 (its only upgrade-reject path).
|
||||||
|
*/
|
||||||
|
public fun classify(
|
||||||
|
error: Throwable,
|
||||||
|
endpoint: HostEndpoint,
|
||||||
|
unrecognizedFallback: PairingError? = null,
|
||||||
|
): PairingError {
|
||||||
|
val chain = causeChain(error)
|
||||||
|
if (chain.any { it is UnknownServiceException }) {
|
||||||
|
return CleartextBlocked(host = HostClassifier.hostOf(endpoint))
|
||||||
|
}
|
||||||
|
if (chain.any { isTimeout(it) }) return Timeout
|
||||||
|
if (chain.any { isTls(it) }) return TlsFailure
|
||||||
|
if (chain.any { isConnectivity(it) }) {
|
||||||
|
return HostUnreachable(underlying = describe(error))
|
||||||
|
}
|
||||||
|
return unrecognizedFallback ?: HostUnreachable(underlying = describe(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MAX_CAUSE_DEPTH = 8
|
||||||
|
|
||||||
|
/** Bounded walk of [Throwable.cause] with an identity cycle-guard (defensive). */
|
||||||
|
private fun causeChain(error: Throwable): List<Throwable> {
|
||||||
|
val chain = mutableListOf<Throwable>()
|
||||||
|
var current: Throwable? = error
|
||||||
|
while (current != null && chain.size < MAX_CAUSE_DEPTH) {
|
||||||
|
if (chain.any { it === current }) break
|
||||||
|
chain.add(current)
|
||||||
|
current = current.cause
|
||||||
|
}
|
||||||
|
return chain
|
||||||
|
}
|
||||||
|
|
||||||
|
// `SocketTimeoutException` extends `InterruptedIOException`; OkHttp's overall call-timeout
|
||||||
|
// also surfaces as a bare `InterruptedIOException` — both mean "timed out".
|
||||||
|
private fun isTimeout(e: Throwable): Boolean = e is InterruptedIOException
|
||||||
|
|
||||||
|
private fun isTls(e: Throwable): Boolean =
|
||||||
|
e is SSLException ||
|
||||||
|
e is CertificateException ||
|
||||||
|
e is CertPathValidatorException ||
|
||||||
|
e is CertPathBuilderException
|
||||||
|
|
||||||
|
// `ConnectException` / `NoRouteToHostException` / `PortUnreachableException` all extend
|
||||||
|
// `SocketException`; listed explicitly for readability. `UnknownHostException` is a DNS
|
||||||
|
// failure (extends `IOException`, not `SocketException`).
|
||||||
|
private fun isConnectivity(e: Throwable): Boolean =
|
||||||
|
e is ConnectException ||
|
||||||
|
e is NoRouteToHostException ||
|
||||||
|
e is PortUnreachableException ||
|
||||||
|
e is SocketException ||
|
||||||
|
e is UnknownHostException
|
||||||
|
|
||||||
|
private fun describe(error: Throwable): String =
|
||||||
|
error.message ?: error::class.simpleName ?: "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package wang.yaojia.webterm.api.pairing
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.flow.mapNotNull
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||||
|
import wang.yaojia.webterm.wire.AuthCookie
|
||||||
|
import wang.yaojia.webterm.wire.ClientMessage
|
||||||
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
|
import wang.yaojia.webterm.wire.HttpRequest
|
||||||
|
import wang.yaojia.webterm.wire.HttpTransport
|
||||||
|
import wang.yaojia.webterm.wire.MessageCodec
|
||||||
|
import wang.yaojia.webterm.wire.ServerMessage
|
||||||
|
import wang.yaojia.webterm.wire.TermTransport
|
||||||
|
import wang.yaojia.webterm.wire.TransportConnection
|
||||||
|
import wang.yaojia.webterm.wire.Tunables
|
||||||
|
import java.net.URI
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a pairing probe — the validated [HostEndpoint] on success, a [PairingError] on failure.
|
||||||
|
* Android analogue of iOS `Result<HostEndpoint, PairingError>`. The pairing UI (A19) constructs the
|
||||||
|
* persisted `Host{id,name}` from the returned endpoint (id/name are not the probe's to know).
|
||||||
|
*/
|
||||||
|
public sealed interface PairingProbeResult {
|
||||||
|
public data class Success(val endpoint: HostEndpoint) : PairingProbeResult
|
||||||
|
|
||||||
|
public data class Failure(val error: PairingError) : PairingProbeResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public probe entry (Android port of iOS `runPairingProbe`). Two-step probe:
|
||||||
|
* 1. `GET /live-sessions` (NO Origin) — reachability + web-terminal shape.
|
||||||
|
* 2. WS `attach(null)` → adopt the server-issued `attached` id → close → **immediately
|
||||||
|
* `DELETE /live-sessions/:id` WITH Origin** (verifies the Origin guard on both the upgrade and
|
||||||
|
* the guarded-HTTP side, and never leaks the probe's orphan session).
|
||||||
|
*
|
||||||
|
* **Confirm-before-network contract:** callers MUST run this only after the user confirmed a
|
||||||
|
* scanned/typed host (A19). Step ① already talks to the network and step ② spawns a PTY on the
|
||||||
|
* target machine — nothing here is speculative, so the UI gate is the caller's responsibility.
|
||||||
|
*
|
||||||
|
* The wall-clock deadline is [Tunables.PAIRING_PROBE_TIMEOUT]; tests drive [runPairingProbeCore]
|
||||||
|
* with an explicit (or `null`) timeout for deterministic virtual-time coverage.
|
||||||
|
*/
|
||||||
|
public suspend fun runPairingProbe(
|
||||||
|
endpoint: HostEndpoint,
|
||||||
|
http: HttpTransport,
|
||||||
|
ws: TermTransport,
|
||||||
|
tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||||
|
): PairingProbeResult =
|
||||||
|
runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT, tokens = tokens)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic probe core. [timeout] `null` = no app-level deadline (the transport's own timeouts
|
||||||
|
* still apply — fast, race-free tests); otherwise the whole probe is cancelled past the deadline and
|
||||||
|
* resolves [PairingError.Timeout]. Cancellation is the coroutine analogue of iOS's `group.cancelAll`.
|
||||||
|
*/
|
||||||
|
internal suspend fun runPairingProbeCore(
|
||||||
|
endpoint: HostEndpoint,
|
||||||
|
http: HttpTransport,
|
||||||
|
ws: TermTransport,
|
||||||
|
timeout: Duration?,
|
||||||
|
tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||||
|
): PairingProbeResult {
|
||||||
|
if (timeout == null) return performProbe(endpoint, http, ws, tokens)
|
||||||
|
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws, tokens) }
|
||||||
|
?: PairingProbeResult.Failure(PairingError.Timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Probe body ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private suspend fun performProbe(
|
||||||
|
endpoint: HostEndpoint,
|
||||||
|
http: HttpTransport,
|
||||||
|
ws: TermTransport,
|
||||||
|
tokens: AccessTokenSource,
|
||||||
|
): PairingProbeResult {
|
||||||
|
// The access token (if any) rides BOTH HTTP legs as the hand-written auth cookie. The WS leg gets
|
||||||
|
// it from the transport's own AccessTokenSource (the same store), so all three legs authenticate.
|
||||||
|
val cookieHeaders = authHeaders(tokens.tokenFor(endpoint))
|
||||||
|
|
||||||
|
// ① Reachability + shape. Any HTTP-level answer that isn't the /live-sessions array shape
|
||||||
|
// means "some other service" → httpOkButNotWebTerminal ("端口对吗?"); a 401 means this host
|
||||||
|
// wants an access token we do not have.
|
||||||
|
probeReachability(endpoint, http, cookieHeaders)?.let { return it }
|
||||||
|
|
||||||
|
// ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401, so after ① passed an
|
||||||
|
// unrecognizable connect failure is classified as originRejected.
|
||||||
|
val connection: TransportConnection = try {
|
||||||
|
ws.connect(endpoint)
|
||||||
|
} catch (cancel: CancellationException) {
|
||||||
|
throw cancel
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
return PairingProbeResult.Failure(
|
||||||
|
PairingError.classify(
|
||||||
|
error,
|
||||||
|
endpoint,
|
||||||
|
unrecognizedFallback = PairingError.OriginRejected(
|
||||||
|
PairingError.originRejectedHint(endpoint),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once connected, the connection MUST be closed on every exit path — including the timeout/cancel
|
||||||
|
// path, where withTimeoutOrNull cancels us mid-`firstOrNull`. Without this finally, a cancel skips
|
||||||
|
// close() and leaks the WS + its orphan PTY session on the host. NonCancellable so the close still
|
||||||
|
// runs while we are already being cancelled.
|
||||||
|
return try {
|
||||||
|
when (val adoption = adoptAttachedSession(connection)) {
|
||||||
|
is Adoption.Failure -> PairingProbeResult.Failure(adoption.error)
|
||||||
|
is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http, cookieHeaders)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
withContext(NonCancellable) { runCatching { connection.close() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe step ①. Returns a [PairingProbeResult.Failure] to short-circuit, or `null` to proceed.
|
||||||
|
* Reachable + web-terminal-shaped = HTTP 200 with a JSON-array body (an HTML admin page, a 404, or
|
||||||
|
* a non-array body are all "not web-terminal").
|
||||||
|
*/
|
||||||
|
private suspend fun probeReachability(
|
||||||
|
endpoint: HostEndpoint,
|
||||||
|
http: HttpTransport,
|
||||||
|
authHeaders: Map<String, String>,
|
||||||
|
): PairingProbeResult.Failure? {
|
||||||
|
val response = try {
|
||||||
|
http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint), headers = authHeaders))
|
||||||
|
} catch (cancel: CancellationException) {
|
||||||
|
throw cancel
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
|
||||||
|
}
|
||||||
|
// Checked BEFORE the shape check: the 401 body is the server's auth JSON, not a session array, and
|
||||||
|
// reporting "端口对吗?" for a token problem would send the user chasing the wrong thing.
|
||||||
|
if (response.status == HTTP_UNAUTHORIZED) {
|
||||||
|
return PairingProbeResult.Failure(PairingError.AccessTokenRequired)
|
||||||
|
}
|
||||||
|
if (response.status != HTTP_OK || !isJsonArray(response.body)) {
|
||||||
|
return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send `attach(null)` (explicit JSON `"sessionId":null` via [MessageCodec]) and wait for the
|
||||||
|
* server-issued `attached` id, skipping any other or undecodable frame (untrusted server; only
|
||||||
|
* `attached` matters here). A stream that ends or errors before speaking our protocol is NOT an
|
||||||
|
* Origin problem — the upgrade already succeeded — so it maps to [PairingError.HttpOkButNotWebTerminal].
|
||||||
|
*/
|
||||||
|
private suspend fun adoptAttachedSession(connection: TransportConnection): Adoption =
|
||||||
|
try {
|
||||||
|
connection.send(MessageCodec.encode(ClientMessage.Attach(sessionId = null)))
|
||||||
|
val sessionId = connection.frames
|
||||||
|
.mapNotNull { frame -> (MessageCodec.decodeServer(frame) as? ServerMessage.Attached)?.sessionId }
|
||||||
|
.firstOrNull()
|
||||||
|
if (sessionId != null) Adoption.Success(sessionId) else Adoption.Failure(PairingError.HttpOkButNotWebTerminal)
|
||||||
|
} catch (cancel: CancellationException) {
|
||||||
|
throw cancel
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
Adoption.Failure(PairingError.HttpOkButNotWebTerminal)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The guarded kill round-trip is part of pairing verification itself (`DELETE` exercises the
|
||||||
|
* HTTP-side Origin guard the later `hookDecision` will need) AND guarantees the probe leaves no
|
||||||
|
* orphan session. 204 = killed, 404 = already gone (both success), 403 = Origin guard rejected us.
|
||||||
|
*/
|
||||||
|
private suspend fun killProbeSession(
|
||||||
|
sessionId: String,
|
||||||
|
endpoint: HostEndpoint,
|
||||||
|
http: HttpTransport,
|
||||||
|
authHeaders: Map<String, String>,
|
||||||
|
): PairingProbeResult {
|
||||||
|
val response = try {
|
||||||
|
http.send(
|
||||||
|
HttpRequest(
|
||||||
|
method = HttpMethod.DELETE,
|
||||||
|
url = killUrl(endpoint, sessionId),
|
||||||
|
headers = authHeaders + (ORIGIN_HEADER to endpoint.originHeader),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch (cancel: CancellationException) {
|
||||||
|
throw cancel
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
|
||||||
|
}
|
||||||
|
return when (response.status) {
|
||||||
|
HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint)
|
||||||
|
HTTP_UNAUTHORIZED -> PairingProbeResult.Failure(PairingError.AccessTokenRequired)
|
||||||
|
HTTP_FORBIDDEN -> PairingProbeResult.Failure(
|
||||||
|
PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)),
|
||||||
|
)
|
||||||
|
else -> PairingProbeResult.Failure(
|
||||||
|
PairingError.HostUnreachable(underlying = "HTTP ${response.status}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed interface Adoption {
|
||||||
|
data class Success(val sessionId: String) : Adoption
|
||||||
|
|
||||||
|
data class Failure(val error: PairingError) : Adoption
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── URL derivation + shape check ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private val PROBE_JSON = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||||
|
|
||||||
|
/** Web-terminal shape = the body parses as a JSON array (the server replies `[]` when idle). */
|
||||||
|
private fun isJsonArray(body: ByteArray): Boolean =
|
||||||
|
runCatching { PROBE_JSON.parseToJsonElement(body.decodeToString()) is JsonArray }.getOrDefault(false)
|
||||||
|
|
||||||
|
private fun liveSessionsUrl(endpoint: HostEndpoint): String = httpBaseUrl(endpoint) + LIVE_SESSIONS_PATH
|
||||||
|
|
||||||
|
private fun killUrl(endpoint: HostEndpoint, sessionId: String): String =
|
||||||
|
httpBaseUrl(endpoint) + LIVE_SESSIONS_PATH + "/" + sessionId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `scheme://host[:port]` from the endpoint's dialed URL — path/query/fragment/credentials dropped
|
||||||
|
* (same derivation philosophy as [HostEndpoint.wsUrl]). The dialed port is kept verbatim; the host
|
||||||
|
* is left as [java.net.URI] returns it (IPv6 literals are already bracketed).
|
||||||
|
*/
|
||||||
|
private fun httpBaseUrl(endpoint: HostEndpoint): String {
|
||||||
|
val uri = URI(endpoint.baseUrl)
|
||||||
|
val scheme = (uri.scheme ?: "http").lowercase()
|
||||||
|
val host = uri.host ?: ""
|
||||||
|
val portPart = if (uri.port != -1) ":${uri.port}" else ""
|
||||||
|
return "$scheme://$host$portPart"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `Cookie: webterm_auth=<t>` for the probe's HTTP legs, or NO header when the host has no token —
|
||||||
|
* derived from the single point ([AuthCookie]), never hand-assembled.
|
||||||
|
*/
|
||||||
|
private fun authHeaders(token: String?): Map<String, String> =
|
||||||
|
if (token == null) emptyMap() else mapOf(AuthCookie.HEADER_NAME to AuthCookie.headerValue(token))
|
||||||
|
|
||||||
|
private const val LIVE_SESSIONS_PATH = "/live-sessions"
|
||||||
|
private const val ORIGIN_HEADER = "Origin"
|
||||||
|
private const val HTTP_OK = 200
|
||||||
|
private const val HTTP_UNAUTHORIZED = 401
|
||||||
|
private const val HTTP_NO_CONTENT = 204
|
||||||
|
private const val HTTP_FORBIDDEN = 403
|
||||||
|
private const val HTTP_NOT_FOUND = 404
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
package wang.yaojia.webterm.api.routes
|
||||||
|
|
||||||
|
import wang.yaojia.webterm.api.models.CommitResult
|
||||||
|
import wang.yaojia.webterm.api.models.CreateWorktreeResult
|
||||||
|
import wang.yaojia.webterm.api.models.GitLogResult
|
||||||
|
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||||
|
import wang.yaojia.webterm.api.models.HookDecision
|
||||||
|
import wang.yaojia.webterm.api.models.LiveSessionInfo
|
||||||
|
import wang.yaojia.webterm.api.models.LossyDecode
|
||||||
|
import wang.yaojia.webterm.api.models.PrStatus
|
||||||
|
import wang.yaojia.webterm.api.models.ProjectDetail
|
||||||
|
import wang.yaojia.webterm.api.models.ProjectInfo
|
||||||
|
import wang.yaojia.webterm.api.models.PruneWorktreesResult
|
||||||
|
import wang.yaojia.webterm.api.models.QueueSnapshot
|
||||||
|
import wang.yaojia.webterm.api.models.QueueWriteOutcome
|
||||||
|
import wang.yaojia.webterm.api.models.decodeQueueDepth
|
||||||
|
import wang.yaojia.webterm.api.models.FetchResult
|
||||||
|
import wang.yaojia.webterm.api.models.PushResult
|
||||||
|
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
|
||||||
|
import wang.yaojia.webterm.api.models.SessionPreview
|
||||||
|
import wang.yaojia.webterm.api.models.StageResult
|
||||||
|
import wang.yaojia.webterm.api.models.UiConfig
|
||||||
|
import wang.yaojia.webterm.api.models.UiPrefs
|
||||||
|
import wang.yaojia.webterm.api.models.WorktreeState
|
||||||
|
import wang.yaojia.webterm.api.models.decodeGitError
|
||||||
|
import wang.yaojia.webterm.api.models.decodeGitPayload
|
||||||
|
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||||
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
|
import wang.yaojia.webterm.wire.HttpResponse
|
||||||
|
import wang.yaojia.webterm.wire.HttpTransport
|
||||||
|
import wang.yaojia.webterm.wire.TimelineEvent
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed client for the server's HTTP surface (12 frozen routes). Pure — consumes [HttpTransport]
|
||||||
|
* by interface (no OkHttp/Android); `:transport-okhttp` (A7) provides the real impl, the
|
||||||
|
* `:test-support` fake queues canned responses.
|
||||||
|
*
|
||||||
|
* **Origin 铁律 (CSWSH, plan §4.3):** only the guarded (state-changing) routes stamp
|
||||||
|
* `Origin: endpoint.originHeader`; the read-only GETs never do. Stamping lives in ONE place
|
||||||
|
* ([ApiRoute.toHttpRequest]) and the value is single-point-derived by [HostEndpoint].
|
||||||
|
*
|
||||||
|
* The server is UNTRUSTED at this boundary: bodies decode tolerantly (malformed entries dropped),
|
||||||
|
* statuses map to explicit [ApiClientError]s, and nothing here crashes on bad input.
|
||||||
|
*
|
||||||
|
* **Access token (ios-completion §1.1):** [tokens] is consulted once per request and its token is
|
||||||
|
* stamped as `Cookie: webterm_auth=<t>` on EVERY route (RO included) inside the same single point that
|
||||||
|
* stamps `Origin` ([ApiRoute.toHttpRequest]). The default [AccessTokenSource.NONE] means "no token" —
|
||||||
|
* an unauthenticated LAN host behaves exactly as before. A **401** becomes the typed
|
||||||
|
* [ApiClientError.Unauthorized] so the UI can send the user to re-enter the token — except on the
|
||||||
|
* routes that define their own 401 ([UnauthorizedPolicy.ROUTE_DEFINED], the git-write family).
|
||||||
|
*/
|
||||||
|
public class ApiClient(
|
||||||
|
public val endpoint: HostEndpoint,
|
||||||
|
private val http: HttpTransport,
|
||||||
|
private val tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||||
|
) {
|
||||||
|
// ── RO (read-only — NO Origin header) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** `GET /live-sessions` — the discovery list every device polls. */
|
||||||
|
public suspend fun liveSessions(): List<LiveSessionInfo> {
|
||||||
|
val response = perform(Endpoints.liveSessions())
|
||||||
|
if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status)
|
||||||
|
return LossyDecode.listOrNull(response.body, LiveSessionInfo.serializer())
|
||||||
|
?: throw ApiClientError.InvalidResponseBody
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /live-sessions/:id/preview` — ring-buffer tail for a read-only thumbnail (no attach). */
|
||||||
|
public suspend fun preview(id: UUID): SessionPreview {
|
||||||
|
val response = perform(Endpoints.preview(id))
|
||||||
|
requireOk(response)
|
||||||
|
return LossyDecode.objectOrNull(response.body, SessionPreview.serializer())
|
||||||
|
?: throw ApiClientError.InvalidResponseBody
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /live-sessions/:id/events` — the activity timeline. A non-array body (timeline capture
|
||||||
|
* disabled) → `[]`; unknown-class entries survive shape-decode and are filtered downstream. */
|
||||||
|
public suspend fun events(id: UUID): List<TimelineEvent> {
|
||||||
|
val response = perform(Endpoints.events(id))
|
||||||
|
requireOk(response)
|
||||||
|
return LossyDecode.listOrEmpty(response.body, TimelineEvent.serializer())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /config/ui` — `{ allowAutoMode }`. */
|
||||||
|
public suspend fun uiConfig(): UiConfig {
|
||||||
|
val response = perform(Endpoints.uiConfig())
|
||||||
|
requireOk(response)
|
||||||
|
return LossyDecode.objectOrNull(response.body, UiConfig.serializer())
|
||||||
|
?: throw ApiClientError.InvalidResponseBody
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /projects` — discovered projects with their running sessions merged in. */
|
||||||
|
public suspend fun projects(): List<ProjectInfo> {
|
||||||
|
val response = perform(Endpoints.projects())
|
||||||
|
if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status)
|
||||||
|
return LossyDecode.listOrNull(response.body, ProjectInfo.serializer())
|
||||||
|
?: throw ApiClientError.InvalidResponseBody
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /projects/detail?path=` — branch/worktrees/CLAUDE.md for one project. An empty path is
|
||||||
|
* rejected client-side (mirror of the server's 400) before any network I/O. */
|
||||||
|
public suspend fun projectDetail(path: String): ProjectDetail {
|
||||||
|
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
|
||||||
|
val response = perform(Endpoints.projectDetail(path))
|
||||||
|
return when (response.status) {
|
||||||
|
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, ProjectDetail.serializer())
|
||||||
|
?: throw ApiClientError.InvalidResponseBody
|
||||||
|
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
|
||||||
|
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR -> throw ApiClientError.ProjectDetailUnavailable
|
||||||
|
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /projects/pr?path=` — PR + CI status for the project's current branch. The PR *degrade*
|
||||||
|
* (gh missing / unauth / no-PR / disabled) is `availability` inside a **200** body, NOT an HTTP
|
||||||
|
* status — so every valid git dir returns 200 and the chip renders from [PrStatus.availability].
|
||||||
|
* A garbled body degrades to `availability=ERROR` (tolerant decode). 400→path invalid; 404→not a
|
||||||
|
* repo. Empty path rejected client-side before any I/O.
|
||||||
|
*/
|
||||||
|
public suspend fun projectPr(path: String): PrStatus {
|
||||||
|
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
|
||||||
|
val response = perform(Endpoints.projectPr(path))
|
||||||
|
return when (response.status) {
|
||||||
|
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, PrStatus.serializer())
|
||||||
|
?: PrStatus() // availability defaults to ERROR — never throw on a bad PR body
|
||||||
|
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
|
||||||
|
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
|
||||||
|
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /projects/log?path=[&n=]` — recent commits (list-lossy: malformed commits dropped). 400→
|
||||||
|
* path invalid; 404→not a repo; 500→[ApiClientError.GitLogUnavailable]. Empty path rejected
|
||||||
|
* client-side before any I/O; `n` is clamped in the route builder.
|
||||||
|
*/
|
||||||
|
public suspend fun projectLog(path: String, n: Int? = null): GitLogResult {
|
||||||
|
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
|
||||||
|
val response = perform(Endpoints.projectLog(path, n))
|
||||||
|
return when (response.status) {
|
||||||
|
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, GitLogResult.serializer())
|
||||||
|
?: throw ApiClientError.InvalidResponseBody
|
||||||
|
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
|
||||||
|
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR -> throw ApiClientError.GitLogUnavailable
|
||||||
|
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /live-sessions/:id/queue` (W2) — the pending prompt queue: depth + the queued texts,
|
||||||
|
* verbatim. READ-ONLY, so no Origin (the server guards only the two writes). 404 → the session is
|
||||||
|
* gone; a non-object body → [ApiClientError.InvalidResponseBody] rather than a fake empty queue
|
||||||
|
* (claiming "nothing queued" when entries exist would mislead a cancel-all decision).
|
||||||
|
*/
|
||||||
|
public suspend fun queue(id: UUID): QueueSnapshot {
|
||||||
|
val response = perform(Endpoints.queue(id))
|
||||||
|
requireOk(response)
|
||||||
|
return LossyDecode.objectOrNull(response.body, QueueSnapshot.serializer())
|
||||||
|
?: throw ApiClientError.InvalidResponseBody
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /prefs` — the cross-device favourites/collapse blob. A non-object body throws
|
||||||
|
* `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */
|
||||||
|
public suspend fun prefs(): UiPrefs {
|
||||||
|
val response = perform(Endpoints.getPrefs())
|
||||||
|
if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status)
|
||||||
|
return UiPrefs.decode(response.body) ?: throw ApiClientError.InvalidResponseBody
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── G (state-changing — Origin required, byte-equal) ───────────────────────────────────
|
||||||
|
|
||||||
|
/** `DELETE /live-sessions/:id`. 204 = success; 404 = already gone (also success on the server,
|
||||||
|
* but iOS surfaces it as `SessionNotFound` — matched here). */
|
||||||
|
public suspend fun killSession(id: UUID) {
|
||||||
|
val response = perform(Endpoints.killSession(id))
|
||||||
|
when (response.status) {
|
||||||
|
HttpStatus.NO_CONTENT -> Unit
|
||||||
|
HttpStatus.NOT_FOUND -> throw ApiClientError.SessionNotFound
|
||||||
|
HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden
|
||||||
|
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /hook/decision` — resolve a held remote approval with a single-use `token` (push
|
||||||
|
* payload only; NEVER persist it). 403 → stale/mismatched token; 429 → rate-limited. */
|
||||||
|
public suspend fun hookDecision(sessionId: UUID, decision: HookDecision, token: String) {
|
||||||
|
val response = perform(Endpoints.hookDecision(sessionId, decision, token))
|
||||||
|
when (response.status) {
|
||||||
|
HttpStatus.NO_CONTENT -> Unit
|
||||||
|
HttpStatus.FORBIDDEN -> throw ApiClientError.DecisionRejected
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS -> throw ApiClientError.RateLimited
|
||||||
|
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `PUT /prefs` — replace the whole blob. Returns the server's sanitized echo — treat IT as the
|
||||||
|
* new source of truth, not the input. 403 = Origin guard. */
|
||||||
|
public suspend fun putPrefs(prefs: UiPrefs): UiPrefs {
|
||||||
|
val response = perform(Endpoints.putPrefs(prefs))
|
||||||
|
return when (response.status) {
|
||||||
|
HttpStatus.OK -> UiPrefs.decode(response.body) ?: throw ApiClientError.InvalidResponseBody
|
||||||
|
HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden
|
||||||
|
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── G: W2 prompt queue (enqueue / cancel-all) → QueueWriteOutcome ──────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /live-sessions/:id/queue` (W2) — queue a follow-up prompt the server injects into the PTY
|
||||||
|
* the next time Claude goes idle. Works with zero tabs attached, which is the whole point.
|
||||||
|
*
|
||||||
|
* [text] travels VERBATIM (invariant #9 — raw keyboard bytes for a shell); [appendEnter] is a flag
|
||||||
|
* the SERVER turns into a trailing `\r`, so callers must not append one. An empty prompt is
|
||||||
|
* rejected as [ApiClientError.QueueTextEmpty] before any network I/O; size is NOT pre-checked
|
||||||
|
* client-side (the byte cap is server config) — an over-cap prompt comes back as
|
||||||
|
* [QueueWriteOutcome.TooLarge].
|
||||||
|
*/
|
||||||
|
public suspend fun enqueueFollowup(id: UUID, text: String, appendEnter: Boolean = true): QueueWriteOutcome {
|
||||||
|
if (text.isEmpty()) throw ApiClientError.QueueTextEmpty
|
||||||
|
return queueWrite(Endpoints.enqueueFollowup(id, text, appendEnter))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `DELETE /live-sessions/:id/queue` (W2) — cancel every pending entry (escape hatch). 200 →
|
||||||
|
* [QueueWriteOutcome.Ok] with depth 0.
|
||||||
|
*/
|
||||||
|
public suspend fun clearQueue(id: UUID): QueueWriteOutcome = queueWrite(Endpoints.clearQueue(id))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared status mapping for the two guarded queue writes. Distinct typed outcomes because each one
|
||||||
|
* demands different UI behaviour (see [QueueWriteOutcome]); in particular a 429 from the shared
|
||||||
|
* `QUEUE_RATE_MAX = 20`/min/IP bucket is surfaced, NEVER auto-retried. Only 409/413/503 are
|
||||||
|
* POST-specific — the DELETE handler cannot produce them.
|
||||||
|
*/
|
||||||
|
private suspend fun queueWrite(route: ApiRoute): QueueWriteOutcome {
|
||||||
|
val response = perform(route)
|
||||||
|
return when (response.status) {
|
||||||
|
HttpStatus.OK -> QueueWriteOutcome.Ok(decodeQueueDepth(response.body))
|
||||||
|
HttpStatus.CONFLICT -> QueueWriteOutcome.Full
|
||||||
|
HttpStatus.PAYLOAD_TOO_LARGE -> QueueWriteOutcome.TooLarge
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE -> QueueWriteOutcome.Disabled
|
||||||
|
HttpStatus.NOT_FOUND -> QueueWriteOutcome.SessionGone
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS -> QueueWriteOutcome.RateLimited
|
||||||
|
// `decodeGitError` reads the generic `{ error }` failure shape the queue routes share with
|
||||||
|
// the git ones (400/403 here) — reused rather than duplicated.
|
||||||
|
in HttpStatus.CLIENT_ERROR_MIN..HttpStatus.SERVER_ERROR_MAX ->
|
||||||
|
QueueWriteOutcome.Rejected(response.status, decodeGitError(response.body))
|
||||||
|
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── G: git-write ops (worktree + git stage/commit/push) → GitWriteOutcome ──────────────
|
||||||
|
|
||||||
|
/** `POST /projects/worktree` — create a worktree for `branch` (off optional `base`). */
|
||||||
|
public suspend fun createWorktree(path: String, branch: String, base: String? = null): GitWriteOutcome<CreateWorktreeResult> =
|
||||||
|
gitWrite(Endpoints.createWorktree(path, branch, base), CreateWorktreeResult.serializer())
|
||||||
|
|
||||||
|
/** `DELETE /projects/worktree` — remove a worktree (409 "uncommitted" unless `force`). */
|
||||||
|
public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean = false): GitWriteOutcome<RemoveWorktreeResult> =
|
||||||
|
gitWrite(Endpoints.removeWorktree(path, worktreePath, force), RemoveWorktreeResult.serializer())
|
||||||
|
|
||||||
|
/** `POST /projects/worktree/prune` — reclaim stale worktrees (idempotent). */
|
||||||
|
public suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> =
|
||||||
|
gitWrite(Endpoints.pruneWorktrees(path), PruneWorktreesResult.serializer())
|
||||||
|
|
||||||
|
/** `POST /projects/git/stage` — stage (`stage=true`) or unstage the given files. */
|
||||||
|
public suspend fun gitStage(path: String, files: List<String>, stage: Boolean = true): GitWriteOutcome<StageResult> =
|
||||||
|
gitWrite(Endpoints.gitStage(path, files, stage), StageResult.serializer())
|
||||||
|
|
||||||
|
/** `POST /projects/git/commit` — commit the staged changes (empty sha possible). */
|
||||||
|
public suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> =
|
||||||
|
gitWrite(Endpoints.gitCommit(path, message), CommitResult.serializer())
|
||||||
|
|
||||||
|
/** `POST /projects/git/push` — push the current branch to its upstream (tighter rate limit). */
|
||||||
|
public suspend fun gitPush(path: String): GitWriteOutcome<PushResult> =
|
||||||
|
gitWrite(Endpoints.gitPush(path), PushResult.serializer())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /projects/git/fetch` (w6/G2) — refresh `refs/remotes` so `behind` becomes trustworthy.
|
||||||
|
*
|
||||||
|
* Shares the guarded-write dispatch, so a disabled kill-switch or an Origin failure both surface as
|
||||||
|
* [GitWriteOutcome.Rejected] with the server's safe message. Fetch has its OWN rate-limit bucket on
|
||||||
|
* the server precisely so refreshes cannot eat the budget a real push needs — surface a 429 rather
|
||||||
|
* than retrying.
|
||||||
|
*/
|
||||||
|
public suspend fun gitFetch(path: String): GitWriteOutcome<FetchResult> =
|
||||||
|
gitWrite(Endpoints.gitFetch(path), FetchResult.serializer())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /projects/worktree/state?path=` (w6/G7) — the per-row git state of ONE worktree.
|
||||||
|
*
|
||||||
|
* Mapped like the other project reads. A malformed body throws rather than degrading to an empty
|
||||||
|
* state: a row that silently claims "clean, in sync" would be a lie, and the caller is expected to
|
||||||
|
* isolate this failure to its own row (the panel must still render).
|
||||||
|
*/
|
||||||
|
public suspend fun worktreeState(path: String): WorktreeState {
|
||||||
|
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
|
||||||
|
val response = perform(Endpoints.worktreeState(path))
|
||||||
|
return when (response.status) {
|
||||||
|
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, WorktreeState.serializer())
|
||||||
|
?: throw ApiClientError.InvalidResponseBody
|
||||||
|
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
|
||||||
|
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
|
||||||
|
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared guarded-write dispatch + status mapping (plan §4.3): 200→[GitWriteOutcome.Ok] with the
|
||||||
|
* decoded payload; 429→[GitWriteOutcome.RateLimited]; any other 4xx/5xx→[GitWriteOutcome.Rejected]
|
||||||
|
* carrying the server's SAFE `error` string (403 is overloaded — Origin-guard AND disabled
|
||||||
|
* kill-switch both 403 — so the message, not a typed variant, is surfaced). A non-HTTP status
|
||||||
|
* (e.g. an odd 2xx/3xx) is [ApiClientError.UnexpectedStatus].
|
||||||
|
*/
|
||||||
|
private suspend fun <T> gitWrite(route: ApiRoute, serializer: kotlinx.serialization.KSerializer<T>): GitWriteOutcome<T> {
|
||||||
|
val response = perform(route)
|
||||||
|
return when (response.status) {
|
||||||
|
HttpStatus.OK -> GitWriteOutcome.Ok(decodeGitPayload(response.body, serializer))
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS -> GitWriteOutcome.RateLimited
|
||||||
|
in HttpStatus.CLIENT_ERROR_MIN..HttpStatus.SERVER_ERROR_MAX ->
|
||||||
|
GitWriteOutcome.Rejected(response.status, decodeGitError(response.body))
|
||||||
|
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /push/fcm-token` — register this device's FCM token (idempotent upsert → 204). Invalid
|
||||||
|
* tokens are rejected client-side (`InvalidFcmToken`) before any network I/O. */
|
||||||
|
public suspend fun registerFcmToken(token: String) {
|
||||||
|
sendFcmToken(token, Endpoints::registerFcmToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `DELETE /push/fcm-token` — unregister (idempotent → 204 even for an unknown token). */
|
||||||
|
public suspend fun unregisterFcmToken(token: String) {
|
||||||
|
sendFcmToken(token, Endpoints::unregisterFcmToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendFcmToken(token: String, build: (String) -> ApiRoute) {
|
||||||
|
val normalized = FcmTokenRule.normalize(token) ?: throw ApiClientError.InvalidFcmToken
|
||||||
|
val response = perform(build(normalized))
|
||||||
|
when (response.status) {
|
||||||
|
HttpStatus.NO_CONTENT -> Unit
|
||||||
|
HttpStatus.BAD_REQUEST -> throw ApiClientError.InvalidFcmToken
|
||||||
|
HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS -> throw ApiClientError.RateLimited
|
||||||
|
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internals ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ONE dispatch point: stamp Origin (iff guarded) + the auth cookie (iff a token is stored),
|
||||||
|
* send, and translate a **401** into the typed [ApiClientError.Unauthorized] BEFORE any per-route
|
||||||
|
* status mapping — otherwise a 401 on a read route would degrade into a generic status error and
|
||||||
|
* the UI would never learn to ask for the token (ios-completion §1.1).
|
||||||
|
*
|
||||||
|
* The one exception is declared AT the route ([UnauthorizedPolicy.ROUTE_DEFINED], the git-write
|
||||||
|
* family): there a 401 is the server classifying a HOST-side git credential failure
|
||||||
|
* (`src/http/git-ops.ts:108`), so it must flow on to [gitWrite]'s mapping and reach the user as the
|
||||||
|
* server's own message. Swallowing it would tell the user to rotate an access token that is fine.
|
||||||
|
*/
|
||||||
|
private suspend fun perform(route: ApiRoute): HttpResponse {
|
||||||
|
val request = route.toHttpRequest(endpoint, tokens.tokenFor(endpoint))
|
||||||
|
?: throw ApiClientError.InvalidRequest
|
||||||
|
val response = http.send(request)
|
||||||
|
if (response.status == HttpStatus.UNAUTHORIZED &&
|
||||||
|
route.unauthorizedPolicy == UnauthorizedPolicy.ACCESS_TOKEN_GATE
|
||||||
|
) {
|
||||||
|
throw ApiClientError.Unauthorized
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */
|
||||||
|
private fun requireOk(response: HttpResponse) {
|
||||||
|
when (response.status) {
|
||||||
|
HttpStatus.OK -> Unit
|
||||||
|
HttpStatus.NOT_FOUND -> throw ApiClientError.SessionNotFound
|
||||||
|
else -> throw ApiClientError.UnexpectedStatus(response.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package wang.yaojia.webterm.api.routes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed failures for [ApiClient] calls (explicit error handling, plan §4). Transport-level errors
|
||||||
|
* thrown by `HttpTransport.send` propagate UNWRAPPED (the pairing classifier reads their shape).
|
||||||
|
*
|
||||||
|
* [userMessage] is UI-ready copy (matches the iOS `APIClientError.message` 话术). The no-arg cases
|
||||||
|
* are singletons (`object`) so tests can assert by identity/equality; [UnexpectedStatus] carries the
|
||||||
|
* offending code.
|
||||||
|
*/
|
||||||
|
public sealed class ApiClientError(public val userMessage: String) : Exception(userMessage) {
|
||||||
|
/** The request could not be built from the endpoint (malformed base URL — should not happen for
|
||||||
|
* a validated `HostEndpoint`; surfaced instead of crashing). */
|
||||||
|
public data object InvalidRequest : ApiClientError("无法构造请求(主机地址异常)。")
|
||||||
|
|
||||||
|
/** A 2xx arrived but the body is not the endpoint's shape. For `/live-sessions` this is the
|
||||||
|
* pairing probe's "the port speaks HTTP but is not web-terminal" signal. */
|
||||||
|
public data object InvalidResponseBody : ApiClientError("服务器响应不是预期格式——端口对吗?")
|
||||||
|
|
||||||
|
/** 404 on a `/live-sessions/:id/…` sub-route — the session is gone (exited / reaped / killed). */
|
||||||
|
public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **401** from a route whose 401 can only be the access-token gate (RO or guarded): this host has
|
||||||
|
* `WEBTERM_TOKEN` set and our request carried no cookie / a wrong one (ios-completion §1.1).
|
||||||
|
* Distinct from [Forbidden] (that is the Origin guard) — the UI must send the user to enter or fix
|
||||||
|
* the host's access token. The git-write family is excluded (it defines its own 401 — see
|
||||||
|
* [UnauthorizedPolicy]), so this copy can never be shown for a host-side git credential failure.
|
||||||
|
*/
|
||||||
|
public data object Unauthorized : ApiClientError("此主机需要访问令牌,或令牌已失效。请重新输入访问令牌。")
|
||||||
|
|
||||||
|
/** 403 from a G route's Origin guard (CSWSH defence). */
|
||||||
|
public data object Forbidden : ApiClientError("服务器拒绝了此来源(Origin 校验未通过)。")
|
||||||
|
|
||||||
|
/** 403 from `POST /hook/decision`: the capability token is missing / mismatched / STALE —
|
||||||
|
* tokens are single-use and expiring by design. */
|
||||||
|
public data object DecisionRejected : ApiClientError("审批令牌已过期或已被处理——请回到终端里直接批准/拒绝。")
|
||||||
|
|
||||||
|
/** 429: the endpoint is rate-limited per IP by fixed server policy. */
|
||||||
|
public data object RateLimited : ApiClientError("操作过于频繁,服务器已限流,请稍后再试。")
|
||||||
|
|
||||||
|
/** The FCM registration token failed the client-side charset/length check, or the server echoed
|
||||||
|
* a 400. */
|
||||||
|
public data object InvalidFcmToken : ApiClientError("推送注册令牌格式异常,请重启 App 重新注册推送。")
|
||||||
|
|
||||||
|
/** 400 from `GET /projects/detail` — the `path` query parameter is missing/empty. Also raised
|
||||||
|
* client-side for an empty path, before any network I/O. */
|
||||||
|
public data object ProjectPathInvalid : ApiClientError("项目路径为空或不合法。")
|
||||||
|
|
||||||
|
/** 404 from `GET /projects/detail` — no project at that path (moved/deleted/not a directory). */
|
||||||
|
public data object ProjectNotFound : ApiClientError("项目不存在(路径可能已移动或删除)。")
|
||||||
|
|
||||||
|
/** 500 from `GET /projects/detail` — the server failed reading the repo. */
|
||||||
|
public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。")
|
||||||
|
|
||||||
|
/** An empty follow-up prompt was passed to `POST /live-sessions/:id/queue` (which the server 400s
|
||||||
|
* as `text must be a non-empty string`). Raised client-side, before any network I/O — mirrors the
|
||||||
|
* [ProjectPathInvalid] precedent. */
|
||||||
|
public data object QueueTextEmpty : ApiClientError("要排队的内容为空——请先输入要发送的提示词。")
|
||||||
|
|
||||||
|
/** 500 from `GET /projects/log` — the server failed reading the git log. */
|
||||||
|
public data object GitLogUnavailable : ApiClientError("读取提交记录失败,请稍后再试。")
|
||||||
|
|
||||||
|
/** Any other non-success status code. */
|
||||||
|
public data class UnexpectedStatus(val status: Int) : ApiClientError("服务器返回了意外状态码 $status。")
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package wang.yaojia.webterm.api.routes
|
||||||
|
|
||||||
|
import wang.yaojia.webterm.wire.AuthCookie
|
||||||
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
|
import wang.yaojia.webterm.wire.HttpRequest
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
|
/** Named HTTP status codes used by the client (no magic numbers, plan §4). */
|
||||||
|
internal object HttpStatus {
|
||||||
|
const val OK = 200
|
||||||
|
const val NO_CONTENT = 204
|
||||||
|
const val BAD_REQUEST = 400
|
||||||
|
const val UNAUTHORIZED = 401
|
||||||
|
const val FORBIDDEN = 403
|
||||||
|
const val NOT_FOUND = 404
|
||||||
|
const val CONFLICT = 409
|
||||||
|
const val PAYLOAD_TOO_LARGE = 413
|
||||||
|
const val TOO_MANY_REQUESTS = 429
|
||||||
|
const val INTERNAL_SERVER_ERROR = 500
|
||||||
|
const val SERVICE_UNAVAILABLE = 503
|
||||||
|
|
||||||
|
/** Inclusive bounds of the 4xx/5xx band a guarded-write maps to a `Rejected` outcome. */
|
||||||
|
const val CLIENT_ERROR_MIN = 400
|
||||||
|
const val SERVER_ERROR_MAX = 599
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Header / content-type names (no magic strings inline). */
|
||||||
|
internal object HeaderName {
|
||||||
|
const val ORIGIN = "Origin"
|
||||||
|
const val CONTENT_TYPE = "Content-Type"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit on the `/auth` probe: the server treats an `Accept` containing `text/html` as a browser
|
||||||
|
* form submit and answers **302** instead of 204/401 (`src/server.ts` `acceptsHtml`).
|
||||||
|
*/
|
||||||
|
const val ACCEPT = "Accept"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object ContentType {
|
||||||
|
const val JSON = "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a route mutates server state — THE security split (plan §4.3): `Origin` is stamped
|
||||||
|
* **iff** [GUARDED]. If the server ever reclassifies a RO route as guarded, tests go red instead of
|
||||||
|
* passing by coincidence.
|
||||||
|
*/
|
||||||
|
internal enum class OriginPolicy {
|
||||||
|
/** Read-only GET — MUST NOT carry Origin. */
|
||||||
|
READ_ONLY,
|
||||||
|
|
||||||
|
/** State-changing — MUST carry `Origin: endpoint.originHeader`, byte-equal; server 403s a
|
||||||
|
* missing/foreign Origin (CSWSH defence). */
|
||||||
|
GUARDED,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a **401** on this route must be READ (ios-completion §1.1) — declared at the route so the
|
||||||
|
* exception is visible instead of hidden in a call site. The Android analogue of iOS
|
||||||
|
* `UnauthorizedPolicy` (APIClient/Endpoints.swift).
|
||||||
|
*/
|
||||||
|
internal enum class UnauthorizedPolicy {
|
||||||
|
/** Default — on this route a 401 can only be the access-token gate (`src/server.ts:465`). */
|
||||||
|
ACCESS_TOKEN_GATE,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The route owns its 401 and it must NOT be read as "your access token is wrong": the git-write
|
||||||
|
* family, where the server classifies a HOST-side git credential failure as
|
||||||
|
* `{status:401, error:"Push authentication required on the host."}` (`src/http/git-ops.ts:108`).
|
||||||
|
*/
|
||||||
|
ROUTE_DEFINED,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One buildable API route — an immutable snapshot; building never mutates. The Android analogue of
|
||||||
|
* iOS `APIRoute`. [percentEncodedQuery] is pre-encoded ONCE by the route builder (never at call
|
||||||
|
* sites). Origin stamping happens HERE and only here (single point).
|
||||||
|
*/
|
||||||
|
internal class ApiRoute(
|
||||||
|
val method: HttpMethod,
|
||||||
|
val path: String,
|
||||||
|
val originPolicy: OriginPolicy,
|
||||||
|
val body: ByteArray? = null,
|
||||||
|
val percentEncodedQuery: String? = null,
|
||||||
|
/** Explicit `Accept` when the server's behaviour depends on it (the `/auth` probe). */
|
||||||
|
val accept: String? = null,
|
||||||
|
/** See [UnauthorizedPolicy]. Defaults to the gate reading. */
|
||||||
|
val unauthorizedPolicy: UnauthorizedPolicy = UnauthorizedPolicy.ACCESS_TOKEN_GATE,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Build the [HttpRequest] against [endpoint]'s scheme/host/port: the path is REPLACED, the
|
||||||
|
* query is REPLACED by [percentEncodedQuery] (dropped when null), fragment/credentials are
|
||||||
|
* dropped — the same derivation philosophy as `HostEndpoint.wsUrl`. Returns `null` if the base
|
||||||
|
* URL cannot be parsed (surfaced by the client as `InvalidRequest`).
|
||||||
|
*
|
||||||
|
* Two headers are stamped HERE and only here, and they are ORTHOGONAL (ios-completion §1.1):
|
||||||
|
* - `Origin` **iff** the route is [OriginPolicy.GUARDED] (the CSWSH 铁律, unchanged);
|
||||||
|
* - `Cookie: webterm_auth=<t>` whenever [accessToken] is non-null — on EVERY route, read-only
|
||||||
|
* ones included, because the server's access-token gate sits in front of the whole app. A null
|
||||||
|
* token adds no header at all, keeping an unauthenticated host byte-identical to before.
|
||||||
|
* The token is secret material: it is only ever placed in this header — never in [path], never in
|
||||||
|
* [percentEncodedQuery], never logged.
|
||||||
|
*/
|
||||||
|
fun toHttpRequest(endpoint: HostEndpoint, accessToken: String? = null): HttpRequest? {
|
||||||
|
val url = buildUrl(endpoint.baseUrl, path, percentEncodedQuery) ?: return null
|
||||||
|
val headers = LinkedHashMap<String, String>()
|
||||||
|
if (originPolicy == OriginPolicy.GUARDED) {
|
||||||
|
headers[HeaderName.ORIGIN] = endpoint.originHeader
|
||||||
|
}
|
||||||
|
if (accessToken != null) {
|
||||||
|
headers[AuthCookie.HEADER_NAME] = AuthCookie.headerValue(accessToken)
|
||||||
|
}
|
||||||
|
if (accept != null) {
|
||||||
|
headers[HeaderName.ACCEPT] = accept
|
||||||
|
}
|
||||||
|
if (body != null) {
|
||||||
|
headers[HeaderName.CONTENT_TYPE] = ContentType.JSON
|
||||||
|
}
|
||||||
|
return HttpRequest(method = method, url = url, headers = headers, body = body)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/**
|
||||||
|
* Rebuild `<scheme>://<host>[:<port>]<path>[?<query>]` from the dialed base URL, keeping the
|
||||||
|
* dialed port verbatim (like `wsUrl`, unlike the default-port-dropping Origin). The path and
|
||||||
|
* pre-encoded query are appended verbatim; any path/query/fragment/credentials the base URL
|
||||||
|
* carried are dropped.
|
||||||
|
*/
|
||||||
|
fun buildUrl(baseUrl: String, path: String, query: String?): String? {
|
||||||
|
val uri = runCatching { URI(baseUrl.trim()) }.getOrNull() ?: return null
|
||||||
|
val scheme = uri.scheme?.lowercase() ?: return null
|
||||||
|
val host = uri.host ?: return null
|
||||||
|
if (host.isEmpty()) return null
|
||||||
|
val serializedHost = if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
|
||||||
|
val portPart = if (uri.port != -1) ":${uri.port}" else ""
|
||||||
|
val queryPart = if (query != null) "?$query" else ""
|
||||||
|
return "$scheme://$serializedHost$portPart$path$queryPart"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
package wang.yaojia.webterm.api.routes
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import wang.yaojia.webterm.api.models.HookDecision
|
||||||
|
import wang.yaojia.webterm.api.models.ModelJson
|
||||||
|
import wang.yaojia.webterm.api.models.UiPrefs
|
||||||
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builders for the frozen endpoints (12 routes, verified against `src/http/…` + iOS `Endpoints` /
|
||||||
|
* `Prefs` / `Projects` / `ApnsToken`). The apns-token pair is ported as the Android **fcm-token**
|
||||||
|
* pair (this client uses FCM, not APNs/VAPID).
|
||||||
|
*
|
||||||
|
* RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `.../:id/queue`
|
||||||
|
* · `GET /config/ui` · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs`
|
||||||
|
* G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST|DELETE /live-sessions/:id/queue`
|
||||||
|
* · `POST /hook/decision` · `PUT /prefs` · `POST|DELETE /push/fcm-token`
|
||||||
|
*
|
||||||
|
* KNOWN WIRE-PARITY GAP (intentional, not drift): the `POST|DELETE /push/fcm-token` pair is AHEAD
|
||||||
|
* of the server. Its server route is delivered by plan task **A33** (`src/push/fcm.ts` +
|
||||||
|
* `/push/fcm-token`), which is still PENDING — so FCM push is non-functional against the current
|
||||||
|
* server until A33 lands. The client builders exist now so the token lifecycle is ready the moment
|
||||||
|
* the server route ships; do not "fix" this as a mismatch.
|
||||||
|
*/
|
||||||
|
internal object Endpoints {
|
||||||
|
// ── RO ───────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun liveSessions(): ApiRoute =
|
||||||
|
ApiRoute(HttpMethod.GET, "/live-sessions", OriginPolicy.READ_ONLY)
|
||||||
|
|
||||||
|
fun preview(id: UUID): ApiRoute =
|
||||||
|
ApiRoute(HttpMethod.GET, "/live-sessions/${pathId(id)}/preview", OriginPolicy.READ_ONLY)
|
||||||
|
|
||||||
|
fun events(id: UUID): ApiRoute =
|
||||||
|
ApiRoute(HttpMethod.GET, "/live-sessions/${pathId(id)}/events", OriginPolicy.READ_ONLY)
|
||||||
|
|
||||||
|
fun uiConfig(): ApiRoute =
|
||||||
|
ApiRoute(HttpMethod.GET, "/config/ui", OriginPolicy.READ_ONLY)
|
||||||
|
|
||||||
|
fun projects(): ApiRoute =
|
||||||
|
ApiRoute(HttpMethod.GET, "/projects", OriginPolicy.READ_ONLY)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /projects/detail?path=` — RO. The ONE place `path` gets percent-encoded, with a strict
|
||||||
|
* RFC 3986 unreserved-only set (deliberately stricter than URL-query-allowed: a bare `+` decodes
|
||||||
|
* to a SPACE in Express's qs parser, and `&`/`=` would split the parameter).
|
||||||
|
*/
|
||||||
|
fun projectDetail(path: String): ApiRoute =
|
||||||
|
ApiRoute(
|
||||||
|
HttpMethod.GET,
|
||||||
|
"/projects/detail",
|
||||||
|
OriginPolicy.READ_ONLY,
|
||||||
|
percentEncodedQuery = "path=${percentEncode(path)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /projects/worktree/state?path=` — RO (w6/G7). Deliberately NARROWER than
|
||||||
|
* `/projects/detail`: it is fetched once per worktree row, so N rows must not each pay for a
|
||||||
|
* worktree listing and a CLAUDE.md read that nothing renders.
|
||||||
|
*/
|
||||||
|
fun worktreeState(path: String): ApiRoute =
|
||||||
|
ApiRoute(
|
||||||
|
HttpMethod.GET,
|
||||||
|
"/projects/worktree/state",
|
||||||
|
OriginPolicy.READ_ONLY,
|
||||||
|
percentEncodedQuery = "path=${percentEncode(path)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
fun getPrefs(): ApiRoute =
|
||||||
|
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /live-sessions/:id/queue` — RO (W2). Same threat model as `GET /live-sessions`, so the
|
||||||
|
* server stamps no Origin guard on it and neither may we.
|
||||||
|
*/
|
||||||
|
fun queue(id: UUID): ApiRoute =
|
||||||
|
ApiRoute(HttpMethod.GET, queuePath(id), OriginPolicy.READ_ONLY)
|
||||||
|
|
||||||
|
/** `GET /projects/pr?path=` — RO PR + CI status. `path` strict-percent-encoded (as detail). */
|
||||||
|
fun projectPr(path: String): ApiRoute =
|
||||||
|
ApiRoute(
|
||||||
|
HttpMethod.GET,
|
||||||
|
"/projects/pr",
|
||||||
|
OriginPolicy.READ_ONLY,
|
||||||
|
percentEncodedQuery = "path=${percentEncode(path)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /projects/log?path=[&n=<int>]` — RO recent-commit log. `n` is clamped client-side to
|
||||||
|
* `1..GIT_LOG_MAX` (the server re-clamps regardless); a null/out-of-range `n` omits the param.
|
||||||
|
*/
|
||||||
|
fun projectLog(path: String, n: Int?): ApiRoute {
|
||||||
|
val query = StringBuilder("path=").append(percentEncode(path))
|
||||||
|
if (n != null) {
|
||||||
|
val clamped = n.coerceIn(1, GIT_LOG_MAX)
|
||||||
|
query.append("&n=").append(clamped)
|
||||||
|
}
|
||||||
|
return ApiRoute(HttpMethod.GET, "/projects/log", OriginPolicy.READ_ONLY, percentEncodedQuery = query.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── G ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun killSession(id: UUID): ApiRoute =
|
||||||
|
ApiRoute(HttpMethod.DELETE, "/live-sessions/${pathId(id)}", OriginPolicy.GUARDED)
|
||||||
|
|
||||||
|
/** Body is exactly `{ sessionId, decision, token }`. `token` is single-use (push payload only) —
|
||||||
|
* callers must never persist/log it. */
|
||||||
|
fun hookDecision(sessionId: UUID, decision: HookDecision, token: String): ApiRoute {
|
||||||
|
val body = ModelJson.encodeToString(
|
||||||
|
HookDecisionBody.serializer(),
|
||||||
|
HookDecisionBody(pathId(sessionId), decision.wire, token),
|
||||||
|
).encodeToByteArray()
|
||||||
|
return ApiRoute(HttpMethod.POST, "/hook/decision", OriginPolicy.GUARDED, body = body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `PUT /prefs` — G. Replaces the whole blob (server echoes a sanitized 200). */
|
||||||
|
fun putPrefs(prefs: UiPrefs): ApiRoute =
|
||||||
|
ApiRoute(HttpMethod.PUT, "/prefs", OriginPolicy.GUARDED, body = prefs.encodeBody())
|
||||||
|
|
||||||
|
fun registerFcmToken(normalized: String): ApiRoute =
|
||||||
|
fcmTokenRoute(HttpMethod.POST, normalized)
|
||||||
|
|
||||||
|
fun unregisterFcmToken(normalized: String): ApiRoute =
|
||||||
|
fcmTokenRoute(HttpMethod.DELETE, normalized)
|
||||||
|
|
||||||
|
private fun fcmTokenRoute(method: HttpMethod, normalized: String): ApiRoute {
|
||||||
|
val body = ModelJson.encodeToString(
|
||||||
|
FcmTokenBody.serializer(),
|
||||||
|
FcmTokenBody(normalized),
|
||||||
|
).encodeToByteArray()
|
||||||
|
return ApiRoute(method, FCM_TOKEN_PATH, OriginPolicy.GUARDED, body = body)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val FCM_TOKEN_PATH = "/push/fcm-token"
|
||||||
|
|
||||||
|
// ── G: access-token validation probe (`POST /auth`, ios-completion §1.1) ───────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /auth` — the pairing-time token-validation probe. Body `{"token":"<t>"}`.
|
||||||
|
*
|
||||||
|
* Two non-obvious requirements, both server-driven (`src/server.ts`):
|
||||||
|
* - **`Accept` must not contain `text/html`.** The route is dual-mode: an HTML-accepting request
|
||||||
|
* is treated as a browser form and answered with a **302** redirect instead of 204/401.
|
||||||
|
* - the route sits BEFORE the auth gate, so it is the one request that legitimately carries no
|
||||||
|
* auth cookie (the token is in the body — this IS the login).
|
||||||
|
*
|
||||||
|
* Guarded (a state-changing POST) so it stamps `Origin` like every other write; the server does not
|
||||||
|
* Origin-check `/auth`, but keeping the Origin-iff-guarded rule uniform avoids a special case.
|
||||||
|
*/
|
||||||
|
fun auth(token: String): ApiRoute {
|
||||||
|
val body = ModelJson.encodeToString(AuthBody.serializer(), AuthBody(token)).encodeToByteArray()
|
||||||
|
return ApiRoute(
|
||||||
|
HttpMethod.POST,
|
||||||
|
AUTH_PATH,
|
||||||
|
OriginPolicy.GUARDED,
|
||||||
|
body = body,
|
||||||
|
accept = ContentType.JSON,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val AUTH_PATH = "/auth"
|
||||||
|
|
||||||
|
// ── G: worktree write (create / remove / prune) ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Guarded writes, but NOT [UnauthorizedPolicy.ROUTE_DEFINED] (F5): these land in
|
||||||
|
// `src/http/worktrees.ts`, which emits no 401 at all (403 kill-switch / 400 / 404 / 500), and
|
||||||
|
// `src/server.ts:1182-1260` adds none. The only 401 they can ever see is the access-token gate, so
|
||||||
|
// they take the DEFAULT gate reading — pinning them route-defined made a gated host's 401 surface
|
||||||
|
// as a worktree failure instead of the token flow.
|
||||||
|
|
||||||
|
/** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */
|
||||||
|
fun createWorktree(path: String, branch: String, base: String?): ApiRoute =
|
||||||
|
jsonBodyRoute(
|
||||||
|
HttpMethod.POST,
|
||||||
|
"/projects/worktree",
|
||||||
|
CreateWorktreeBody.serializer(),
|
||||||
|
CreateWorktreeBody(path, branch, base),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `DELETE /projects/worktree` — `{ path, worktreePath, force }` (DELETE **with** a JSON body). */
|
||||||
|
fun removeWorktree(path: String, worktreePath: String, force: Boolean): ApiRoute =
|
||||||
|
jsonBodyRoute(
|
||||||
|
HttpMethod.DELETE,
|
||||||
|
"/projects/worktree",
|
||||||
|
RemoveWorktreeBody.serializer(),
|
||||||
|
RemoveWorktreeBody(path, worktreePath, force),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `POST /projects/worktree/prune` — `{ path }`. */
|
||||||
|
fun pruneWorktrees(path: String): ApiRoute =
|
||||||
|
jsonBodyRoute(HttpMethod.POST, "/projects/worktree/prune", PruneBody.serializer(), PruneBody(path))
|
||||||
|
|
||||||
|
// ── G: git write (stage / commit / push) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** `POST /projects/git/stage` — `{ path, files, stage }`. */
|
||||||
|
fun gitStage(path: String, files: List<String>, stage: Boolean): ApiRoute =
|
||||||
|
gitWriteRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage))
|
||||||
|
|
||||||
|
/** `POST /projects/git/commit` — `{ path, message }`. */
|
||||||
|
fun gitCommit(path: String, message: String): ApiRoute =
|
||||||
|
gitWriteRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /projects/git/fetch` — `{ path }` (w6/G2). GUARDED even though it only updates
|
||||||
|
* `refs/remotes`: it is state-changing and spawns a network git process, so it goes through the
|
||||||
|
* single Origin-stamping point like every other write. The remote is derived SERVER-side and no
|
||||||
|
* remote or refspec is ever read from the body, so a client cannot aim it at an arbitrary URL.
|
||||||
|
* It is NOT a pull: no working tree, no index, no merge.
|
||||||
|
*/
|
||||||
|
fun gitFetch(path: String): ApiRoute =
|
||||||
|
gitWriteRoute(HttpMethod.POST, "/projects/git/fetch", FetchBody.serializer(), FetchBody(path))
|
||||||
|
|
||||||
|
/** `POST /projects/git/push` — `{ path }`. */
|
||||||
|
fun gitPush(path: String): ApiRoute =
|
||||||
|
gitWriteRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
|
||||||
|
|
||||||
|
// ── G: W2 prompt queue (enqueue / cancel-all) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /live-sessions/:id/queue` — G. Body is exactly `{ text, appendEnter }` (express.json,
|
||||||
|
* 16 kb limit).
|
||||||
|
*
|
||||||
|
* [text] is RAW KEYBOARD INPUT for a shell and is carried VERBATIM (invariant #9): JSON string
|
||||||
|
* encoding is the only transformation, and it round-trips byte-exactly through `express.json`.
|
||||||
|
* Never trim/escape/normalise it here. [appendEnter] is a FLAG — the server materialises the
|
||||||
|
* trailing `\r` so the stored entry is byte-identical to a keystroke; the client must not append
|
||||||
|
* one itself.
|
||||||
|
*/
|
||||||
|
fun enqueueFollowup(id: UUID, text: String, appendEnter: Boolean): ApiRoute =
|
||||||
|
jsonBodyRoute(
|
||||||
|
HttpMethod.POST,
|
||||||
|
queuePath(id),
|
||||||
|
EnqueueBody.serializer(),
|
||||||
|
EnqueueBody(text, appendEnter),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `DELETE /live-sessions/:id/queue` — G, cancel-all escape hatch. Carries **no** body: unlike
|
||||||
|
* `DELETE /projects/worktree` (the body-carrying DELETE precedent), this handler reads only `:id`
|
||||||
|
* and clears the whole FIFO, so a body would be dead weight the server never parses.
|
||||||
|
*/
|
||||||
|
fun clearQueue(id: UUID): ApiRoute =
|
||||||
|
ApiRoute(HttpMethod.DELETE, queuePath(id), OriginPolicy.GUARDED)
|
||||||
|
|
||||||
|
private fun queuePath(id: UUID): String = "/live-sessions/${pathId(id)}/queue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]).
|
||||||
|
*
|
||||||
|
* The 401 reading defaults to [UnauthorizedPolicy.ACCESS_TOKEN_GATE] — correct for every route
|
||||||
|
* whose handler never writes a 401 of its own (the W2 queue: 400/404/429/503 only; the worktree
|
||||||
|
* trio: 403/400/404/500 only). Only the git-ops writes opt out, via [gitWriteRoute].
|
||||||
|
*/
|
||||||
|
private fun <T> jsonBodyRoute(
|
||||||
|
method: HttpMethod,
|
||||||
|
path: String,
|
||||||
|
serializer: kotlinx.serialization.KSerializer<T>,
|
||||||
|
value: T,
|
||||||
|
unauthorizedPolicy: UnauthorizedPolicy = UnauthorizedPolicy.ACCESS_TOKEN_GATE,
|
||||||
|
): ApiRoute {
|
||||||
|
val body = ModelJson.encodeToString(serializer, value).encodeToByteArray()
|
||||||
|
return ApiRoute(
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
OriginPolicy.GUARDED,
|
||||||
|
body = body,
|
||||||
|
unauthorizedPolicy = unauthorizedPolicy,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a GUARDED **git-ops** route with a `ModelJson`-encoded JSON body (Origin stamped in
|
||||||
|
* [ApiRoute]).
|
||||||
|
*
|
||||||
|
* Every route built here is [UnauthorizedPolicy.ROUTE_DEFINED]: the server classifies a HOST-side
|
||||||
|
* git credential failure as **401** with its own message (`src/http/git-ops.ts:108`), which the UI
|
||||||
|
* must display verbatim instead of the access-token copy (mirrors iOS `gitOpsRoute`).
|
||||||
|
*
|
||||||
|
* Scope is exactly the four routes that reach that classifier — `stage`, `commit`, `push`, `fetch`.
|
||||||
|
* The worktree trio must NOT be built here (F5): its handler has no 401 of its own.
|
||||||
|
*/
|
||||||
|
private fun <T> gitWriteRoute(
|
||||||
|
method: HttpMethod,
|
||||||
|
path: String,
|
||||||
|
serializer: kotlinx.serialization.KSerializer<T>,
|
||||||
|
value: T,
|
||||||
|
): ApiRoute = jsonBodyRoute(method, path, serializer, value, UnauthorizedPolicy.ROUTE_DEFINED)
|
||||||
|
|
||||||
|
/** Mirror of `src/http/git-log.ts` `GIT_LOG_MAX` — the server-side `?n=` clamp ceiling. */
|
||||||
|
private const val GIT_LOG_MAX = 50
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server session ids are lowercase `crypto.randomUUID()` strings and `:id` route params are
|
||||||
|
* matched as EXACT strings — always serialize lowercase. `UUID.toString()` is already lowercase
|
||||||
|
* on the JVM (unlike Swift's uppercase `uuidString`).
|
||||||
|
*/
|
||||||
|
private fun pathId(id: UUID): String = id.toString()
|
||||||
|
|
||||||
|
/** Strict RFC 3986 unreserved set — everything else is percent-encoded over UTF-8 bytes. */
|
||||||
|
private val UNRESERVED: Set<Char> =
|
||||||
|
("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~").toSet()
|
||||||
|
|
||||||
|
private fun percentEncode(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 HEX = "0123456789ABCDEF".toCharArray()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class HookDecisionBody(val sessionId: String, val decision: String, val token: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class FcmTokenBody(val token: String)
|
||||||
|
|
||||||
|
/** `POST /auth` body. Holds secret material — never log a request built from it. */
|
||||||
|
@Serializable
|
||||||
|
private data class AuthBody(val token: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class CreateWorktreeBody(val path: String, val branch: String, val base: String? = null)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class RemoveWorktreeBody(val path: String, val worktreePath: String, val force: Boolean)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class PruneBody(val path: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class StageBody(val path: String, val files: List<String>, val stage: Boolean)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class CommitBody(val path: String, val message: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class PushBody(val path: String)
|
||||||
|
|
||||||
|
@kotlinx.serialization.Serializable
|
||||||
|
private data class FetchBody(val path: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class EnqueueBody(val text: String, val appendEnter: Boolean)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package wang.yaojia.webterm.api.routes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side FCM registration-token validator (validate at the boundary, plan §4). Deliberately
|
||||||
|
* LOOSE, per plan §4.5: non-empty, bounded length, `base64url` charset plus `:` — the FCM token
|
||||||
|
* format/length is undocumented and changes, so a strict length regex risks rejecting valid tokens.
|
||||||
|
*
|
||||||
|
* Unlike iOS's APNs hex rule, FCM tokens are case-sensitive → NOT lowercased. [normalize] returns
|
||||||
|
* the token unchanged when valid, or `null` (→ `ApiClientError.InvalidFcmToken` before any I/O).
|
||||||
|
*/
|
||||||
|
internal object FcmTokenRule {
|
||||||
|
/** Generous headroom bound; real tokens are ~150–250 chars but the ceiling is undocumented. */
|
||||||
|
private const val MAX_LENGTH = 4096
|
||||||
|
|
||||||
|
/** base64url alphabet (`A–Z a–z 0–9 - _`) plus the `:` that appears in FCM tokens. */
|
||||||
|
private val ALLOWED: Set<Char> =
|
||||||
|
(('A'..'Z') + ('a'..'z') + ('0'..'9') + listOf('-', '_', ':')).toSet()
|
||||||
|
|
||||||
|
fun normalize(raw: String): String? {
|
||||||
|
if (raw.isEmpty() || raw.length > MAX_LENGTH) return null
|
||||||
|
if (!raw.all { it in ALLOWED }) return null
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package wang.yaojia.webterm.api.enroll
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertArrayEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.security.KeyPairGenerator
|
||||||
|
import java.security.Signature
|
||||||
|
import java.security.interfaces.ECPublicKey
|
||||||
|
import java.security.spec.ECGenParameterSpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B4 · Proves the manual PKCS#10 encoder produces a well-formed, self-signed P-256 CSR that the
|
||||||
|
* control-plane `verifyCsrPoPEc` (id-ecPublicKey + prime256v1 SPKI, ecdsa-with-SHA256
|
||||||
|
* self-signature) accepts. Runs headless with a SOFTWARE P-256 key via the SAME
|
||||||
|
* `Signature("SHA256withECDSA")` path the on-device AndroidKeyStore key uses — so the signing path
|
||||||
|
* is byte-identical. Real StrongBox keygen is device-only (`:client-tls-android`).
|
||||||
|
*/
|
||||||
|
class CertificateSigningRequestTest {
|
||||||
|
/** Software P-256 signer via the SAME JCA `SHA256withECDSA` path used on-device (no StrongBox). */
|
||||||
|
private class SoftwareEcSigner : CsrSigner {
|
||||||
|
val keyPair = KeyPairGenerator.getInstance("EC").apply {
|
||||||
|
initialize(ECGenParameterSpec("secp256r1"))
|
||||||
|
}.generateKeyPair()
|
||||||
|
|
||||||
|
override fun publicKeyX963(): ByteArray = EcPointEncoding.x963(keyPair.public as ECPublicKey)
|
||||||
|
|
||||||
|
override fun sign(message: ByteArray): ByteArray =
|
||||||
|
Signature.getInstance("SHA256withECDSA").apply {
|
||||||
|
initSign(keyPair.private)
|
||||||
|
update(message)
|
||||||
|
}.sign()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun csrIsCanonicalPkcs10SequenceOfExactlyThreeElements() {
|
||||||
|
val signer = SoftwareEcSigner()
|
||||||
|
|
||||||
|
val der = CertificateSigningRequest.der("web-terminal-device", signer)
|
||||||
|
|
||||||
|
val outer = TestDer.read(der, 0)!!
|
||||||
|
assertEquals(0x30, outer.tag, "outer CertificationRequest is a SEQUENCE")
|
||||||
|
assertEquals(der.size, outer.end, "no trailing garbage after the CSR")
|
||||||
|
val parts = TestDer.children(der, outer)
|
||||||
|
assertEquals(3, parts.size)
|
||||||
|
assertEquals(0x30, parts[0].tag) // certificationRequestInfo
|
||||||
|
assertEquals(0x30, parts[1].tag) // signatureAlgorithm
|
||||||
|
assertEquals(0x03, parts[2].tag) // signature BIT STRING
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun csrSelfSignatureVerifiesAgainstTheEmbeddedP256Key() {
|
||||||
|
val signer = SoftwareEcSigner()
|
||||||
|
|
||||||
|
val der = CertificateSigningRequest.der("web-terminal-device", signer)
|
||||||
|
|
||||||
|
// Extract the exact CertificationRequestInfo bytes that were signed and the ECDSA signature
|
||||||
|
// (the same crypto check verifyCsrPoPEc's req.verify() runs).
|
||||||
|
val outer = TestDer.read(der, 0)!!
|
||||||
|
val parts = TestDer.children(der, outer)
|
||||||
|
val infoBytes = der.copyOfRange(parts[0].start, parts[0].end)
|
||||||
|
val bitString = parts[2] // BIT STRING: first content byte is unused-bits (0x00)
|
||||||
|
val signature = der.copyOfRange(bitString.valueStart + 1, bitString.valueEnd)
|
||||||
|
|
||||||
|
val ok = Signature.getInstance("SHA256withECDSA").apply {
|
||||||
|
initVerify(signer.keyPair.public)
|
||||||
|
update(infoBytes)
|
||||||
|
}.verify(signature)
|
||||||
|
assertTrue(ok, "the CSR self-signature must verify against its own SubjectPublicKeyInfo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun csrEmbedsAP256SubjectPublicKeyInfoTheServerVerifierAccepts() {
|
||||||
|
val signer = SoftwareEcSigner()
|
||||||
|
val point = signer.publicKeyX963()
|
||||||
|
|
||||||
|
val der = CertificateSigningRequest.der("web-terminal-device", signer)
|
||||||
|
|
||||||
|
val outer = TestDer.read(der, 0)!!
|
||||||
|
val info = TestDer.children(der, outer)[0]
|
||||||
|
val infoChildren = TestDer.children(der, info)
|
||||||
|
assertEquals(4, infoChildren.size)
|
||||||
|
// version v1(0)
|
||||||
|
assertArrayEquals(byteArrayOf(0x02, 0x01, 0x00), der.copyOfRange(infoChildren[0].start, infoChildren[0].end))
|
||||||
|
assertEquals(0xA0, infoChildren[3].tag) // [0] IMPLICIT attributes
|
||||||
|
assertEquals(0, infoChildren[3].valueEnd - infoChildren[3].valueStart) // empty SET
|
||||||
|
|
||||||
|
// subjectPublicKeyInfo ::= SEQUENCE { AlgorithmIdentifier, BIT STRING point }
|
||||||
|
val spki = infoChildren[2]
|
||||||
|
val spkiChildren = TestDer.children(der, spki)
|
||||||
|
assertEquals(2, spkiChildren.size)
|
||||||
|
val algIdChildren = TestDer.children(der, spkiChildren[0])
|
||||||
|
// AlgorithmIdentifier { id-ecPublicKey, prime256v1 } — the exact OIDs verifyCsrPoPEc pins.
|
||||||
|
assertArrayEquals(
|
||||||
|
byteArrayOf(0x06, 0x07, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x02, 0x01),
|
||||||
|
der.copyOfRange(algIdChildren[0].start, algIdChildren[0].end),
|
||||||
|
)
|
||||||
|
assertArrayEquals(
|
||||||
|
byteArrayOf(0x06, 0x08, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x03, 0x01, 0x07),
|
||||||
|
der.copyOfRange(algIdChildren[1].start, algIdChildren[1].end),
|
||||||
|
)
|
||||||
|
// BIT STRING content = 0x00 unused-bits + the exact 65-byte point.
|
||||||
|
val bitString = spkiChildren[1]
|
||||||
|
assertEquals(0x03, bitString.tag)
|
||||||
|
assertEquals(0x00, der[bitString.valueStart].toInt() and 0xFF)
|
||||||
|
assertArrayEquals(point, der.copyOfRange(bitString.valueStart + 1, bitString.valueEnd))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun signatureAlgorithmIsEcdsaWithSha256() {
|
||||||
|
val signer = SoftwareEcSigner()
|
||||||
|
val der = CertificateSigningRequest.der("web-terminal-device", signer)
|
||||||
|
val outer = TestDer.read(der, 0)!!
|
||||||
|
val algId = TestDer.children(der, outer)[1]
|
||||||
|
val oid = TestDer.children(der, algId)[0]
|
||||||
|
assertArrayEquals(
|
||||||
|
byteArrayOf(0x06, 0x08, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x04, 0x03, 0x02),
|
||||||
|
der.copyOfRange(oid.start, oid.end),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun subjectCommonNameIsEncodedAsUtf8String() {
|
||||||
|
val signer = SoftwareEcSigner()
|
||||||
|
val der = CertificateSigningRequest.der("my-pixel", signer)
|
||||||
|
val outer = TestDer.read(der, 0)!!
|
||||||
|
val info = TestDer.children(der, outer)[0]
|
||||||
|
val name = TestDer.children(der, info)[1] // subject Name
|
||||||
|
val rdn = TestDer.children(der, name)[0] // SET
|
||||||
|
val attr = TestDer.children(der, rdn)[0] // SEQUENCE { OID, value }
|
||||||
|
val attrChildren = TestDer.children(der, attr)
|
||||||
|
// OID 2.5.4.3 (commonName), then a UTF8String (tag 0x0C) carrying the CN bytes.
|
||||||
|
assertArrayEquals(byteArrayOf(0x06, 0x03, 0x55, 0x04, 0x03), der.copyOfRange(attrChildren[0].start, attrChildren[0].end))
|
||||||
|
assertEquals(0x0C, attrChildren[1].tag)
|
||||||
|
assertArrayEquals("my-pixel".toByteArray(), der.copyOfRange(attrChildren[1].valueStart, attrChildren[1].valueEnd))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptySubjectCommonNameIsRejected() {
|
||||||
|
val signer = SoftwareEcSigner()
|
||||||
|
assertThrows(CsrException.InvalidSubject::class.java) {
|
||||||
|
CertificateSigningRequest.der("", signer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aNon65BytePublicKeyIsRejected() {
|
||||||
|
val badSigner = object : CsrSigner {
|
||||||
|
override fun publicKeyX963(): ByteArray = ByteArray(64) { 0x04 } // wrong length
|
||||||
|
override fun sign(message: ByteArray): ByteArray = ByteArray(0)
|
||||||
|
}
|
||||||
|
assertThrows(CsrException.InvalidPublicKey::class.java) {
|
||||||
|
CertificateSigningRequest.der("d", badSigner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aPublicKeyWithoutTheUncompressedPrefixIsRejected() {
|
||||||
|
val badSigner = object : CsrSigner {
|
||||||
|
override fun publicKeyX963(): ByteArray = ByteArray(65) { 0x02 } // right length, wrong prefix
|
||||||
|
override fun sign(message: ByteArray): ByteArray = ByteArray(0)
|
||||||
|
}
|
||||||
|
assertThrows(CsrException.InvalidPublicKey::class.java) {
|
||||||
|
CertificateSigningRequest.der("d", badSigner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A throwaway canonical-DER reader for structural assertions (the enroll path itself does no DER
|
||||||
|
* parsing — the server verifies; this mirrors the iOS `TestDER` test helper).
|
||||||
|
*/
|
||||||
|
internal object TestDer {
|
||||||
|
data class Element(val tag: Int, val start: Int, val valueStart: Int, val valueEnd: Int) {
|
||||||
|
val end: Int get() = valueEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
fun read(bytes: ByteArray, start: Int): Element? {
|
||||||
|
if (start < 0 || start + 1 >= bytes.size) return null
|
||||||
|
val tag = bytes[start].toInt() and 0xFF
|
||||||
|
var index = start + 1
|
||||||
|
val first = bytes[index].toInt() and 0xFF
|
||||||
|
index += 1
|
||||||
|
var length = 0
|
||||||
|
if (first and 0x80 == 0) {
|
||||||
|
length = first
|
||||||
|
} else {
|
||||||
|
val count = first and 0x7F
|
||||||
|
if (count == 0 || count > 4 || index + count > bytes.size) return null
|
||||||
|
repeat(count) {
|
||||||
|
length = (length shl 8) or (bytes[index].toInt() and 0xFF)
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val valueEnd = index + length
|
||||||
|
if (valueEnd > bytes.size) return null
|
||||||
|
return Element(tag = tag, start = start, valueStart = index, valueEnd = valueEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun children(bytes: ByteArray, parent: Element): List<Element> {
|
||||||
|
val elements = mutableListOf<Element>()
|
||||||
|
var index = parent.valueStart
|
||||||
|
while (index < parent.valueEnd) {
|
||||||
|
val element = read(bytes, index) ?: break
|
||||||
|
elements.add(element)
|
||||||
|
index = element.valueEnd
|
||||||
|
}
|
||||||
|
return elements
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
package wang.yaojia.webterm.api.enroll
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||||
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
|
import wang.yaojia.webterm.wire.HttpRequest
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.Base64
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B4 · DeviceEnrollmentClient request-building + response-mapping against the pinned login/enroll
|
||||||
|
* contract, driven by the shared `FakeHttpTransport` (no network). Mirrors the iOS
|
||||||
|
* `DeviceEnrollmentClientTests`, extended with the login step.
|
||||||
|
*/
|
||||||
|
class DeviceEnrollmentClientTest {
|
||||||
|
private companion object {
|
||||||
|
const val BASE = "https://cp.terminal.yaojia.wang"
|
||||||
|
const val BEARER = "device-enroll-token-abc"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val transport = FakeHttpTransport()
|
||||||
|
private val client = DeviceEnrollmentClient(BASE, transport)
|
||||||
|
|
||||||
|
private fun bodyObject(request: HttpRequest): JsonObject =
|
||||||
|
Json.parseToJsonElement(request.body!!.decodeToString()) as JsonObject
|
||||||
|
|
||||||
|
private fun enrollResponse(
|
||||||
|
deviceId: String = "dev-1",
|
||||||
|
cert: ByteArray = byteArrayOf(0x30, 0x01, 0x02),
|
||||||
|
caChain: List<ByteArray> = listOf(byteArrayOf(0x30, 0xAA.toByte())),
|
||||||
|
notBefore: String = "2026-07-08T00:00:00.000Z",
|
||||||
|
notAfter: String = "2026-10-06T00:00:00.000Z",
|
||||||
|
renewAfter: String = "2026-09-05T00:00:00.000Z",
|
||||||
|
): ByteArray {
|
||||||
|
val b64 = Base64.getEncoder()
|
||||||
|
val chainJson = caChain.joinToString(",") { "\"${b64.encodeToString(it)}\"" }
|
||||||
|
return """
|
||||||
|
{"deviceId":"$deviceId","cert":"${b64.encodeToString(cert)}","caChain":[$chainJson],
|
||||||
|
"notBefore":"$notBefore","notAfter":"$notAfter","renewAfter":"$renewAfter"}
|
||||||
|
""".trimIndent().toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── login ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loginPostsPasswordAndMapsThe201Bearer() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST,
|
||||||
|
url = "$BASE/auth/login",
|
||||||
|
status = 201,
|
||||||
|
body = """{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = client.login("hunter2")
|
||||||
|
|
||||||
|
val request = transport.recordedRequests.single()
|
||||||
|
assertEquals(HttpMethod.POST, request.method)
|
||||||
|
assertEquals("$BASE/auth/login", request.url)
|
||||||
|
assertEquals("application/json", request.headers["Content-Type"])
|
||||||
|
assertNull(request.headers["Authorization"], "login carries no bearer")
|
||||||
|
assertEquals("hunter2", bodyObject(request)["password"]!!.jsonPrimitive.content)
|
||||||
|
|
||||||
|
assertEquals("tok-xyz", result.enrollToken)
|
||||||
|
assertEquals("acct-1", result.accountId)
|
||||||
|
assertEquals(600L, result.expiresInSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loginRejectsAnEmptyPasswordBeforeAnyNetworkIo() = runTest {
|
||||||
|
val error = runCatching { client.login("") }.exceptionOrNull()
|
||||||
|
assertEquals(DeviceEnrollmentError.InvalidRequest, error)
|
||||||
|
assertTrue(transport.recordedRequests.isEmpty(), "must not hit the network for an empty password")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loginSurfacesA401AsHttpWithTheServerCode() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST,
|
||||||
|
url = "$BASE/auth/login",
|
||||||
|
status = 401,
|
||||||
|
body = """{"error":"rejected"}""".toByteArray(),
|
||||||
|
)
|
||||||
|
val error = runCatching { client.login("wrong") }.exceptionOrNull()
|
||||||
|
assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── enroll ───────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollBuildsABearerAuthenticatedPostWithTheContractBody() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse(),
|
||||||
|
)
|
||||||
|
val csr = byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte())
|
||||||
|
|
||||||
|
client.enroll(BEARER, csr, subdomain = "alice", deviceName = "Alice Pixel")
|
||||||
|
|
||||||
|
val request = transport.recordedRequests.single()
|
||||||
|
assertEquals(HttpMethod.POST, request.method)
|
||||||
|
assertEquals("$BASE/device/enroll", request.url)
|
||||||
|
assertEquals("Bearer $BEARER", request.headers["Authorization"])
|
||||||
|
assertEquals("application/json", request.headers["Content-Type"])
|
||||||
|
|
||||||
|
val obj = bodyObject(request)
|
||||||
|
assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content)
|
||||||
|
assertEquals("ec-p256", obj["keyAlg"]!!.jsonPrimitive.content)
|
||||||
|
assertEquals("alice", obj["subdomain"]!!.jsonPrimitive.content)
|
||||||
|
assertEquals("Alice Pixel", obj["deviceName"]!!.jsonPrimitive.content)
|
||||||
|
assertFalse(obj.containsKey("attestation"), "attestation is omitted when not provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollForwardsAttestationWhenProvided() = runTest {
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse())
|
||||||
|
client.enroll(BEARER, byteArrayOf(0x01), "a", "d", attestation = "attest-blob")
|
||||||
|
assertEquals("attest-blob", bodyObject(transport.recordedRequests.single())["attestation"]!!.jsonPrimitive.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollMapsA201IntoATypedResult() = runTest {
|
||||||
|
val cert = byteArrayOf(0x30, 0x82.toByte(), 0x01, 0x23)
|
||||||
|
val ca = byteArrayOf(0x30, 0x82.toByte(), 0x02, 0x00)
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201,
|
||||||
|
body = enrollResponse(deviceId = "dev-xyz", cert = cert, caChain = listOf(ca)),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = client.enroll(BEARER, byteArrayOf(0x01), "alice", "Pixel")
|
||||||
|
|
||||||
|
assertEquals("dev-xyz", result.deviceId)
|
||||||
|
assertArrayEquals(cert, result.certificate)
|
||||||
|
assertEquals(1, result.caChain.size)
|
||||||
|
assertArrayEquals(ca, result.caChain.single())
|
||||||
|
assertTrue(result.renewAfter!!.isBefore(result.notAfter))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollRejectsEmptyRequiredFieldsBeforeAnyNetworkIo() = runTest {
|
||||||
|
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll("", byteArrayOf(1), "a", "d") }.exceptionOrNull())
|
||||||
|
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, ByteArray(0), "a", "d") }.exceptionOrNull())
|
||||||
|
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, byteArrayOf(1), "", "d") }.exceptionOrNull())
|
||||||
|
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "") }.exceptionOrNull())
|
||||||
|
assertTrue(transport.recordedRequests.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollSurfacesA403SubdomainNotOwnedWithTheServerCode() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 403,
|
||||||
|
body = """{"error":"rejected"}""".toByteArray(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DeviceEnrollmentError.Http(403, "rejected"),
|
||||||
|
runCatching { client.enroll(BEARER, byteArrayOf(1), "bob", "d") }.exceptionOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollSurfacesA429RateLimited() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 429,
|
||||||
|
body = """{"error":"rate_limited"}""".toByteArray(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DeviceEnrollmentError.Http(429, "rate_limited"),
|
||||||
|
runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollThrowsMalformedResponseOnANonJson201Body() = runTest {
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = "not json".toByteArray())
|
||||||
|
assertEquals(
|
||||||
|
DeviceEnrollmentError.MalformedResponse,
|
||||||
|
runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollThrowsMalformedResponseWhenTheCertIsNotValidBase64() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201,
|
||||||
|
body = """{"deviceId":"d","cert":"@@not-base64@@","caChain":[]}""".toByteArray(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DeviceEnrollmentError.MalformedResponse,
|
||||||
|
runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollDegradesAbsentDatesToNull() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201,
|
||||||
|
body = """{"deviceId":"d","cert":"MAEC","caChain":[]}""".toByteArray(),
|
||||||
|
)
|
||||||
|
val result = client.enroll(BEARER, byteArrayOf(1), "a", "d")
|
||||||
|
assertNull(result.notAfter)
|
||||||
|
assertNull(result.renewAfter)
|
||||||
|
assertFalse(result.isRenewalDue(Instant.parse("2030-01-01T00:00:00Z")), "absent renewAfter never triggers")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── renew (silent rotation seam — mTLS-only, NO bearer) ─────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun renewTargetsDeviceIdRenewWithTheMinimalBodyAndNoAuthorizationHeader() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201, body = enrollResponse(deviceId = "dev-9"),
|
||||||
|
)
|
||||||
|
val csr = byteArrayOf(0x02)
|
||||||
|
|
||||||
|
// Production renew passes NO bearer — the endpoint authenticates by the current device cert (mTLS).
|
||||||
|
val result = client.renew("dev-9", csr)
|
||||||
|
|
||||||
|
val request = transport.recordedRequests.single()
|
||||||
|
assertEquals("$BASE/device/dev-9/renew", request.url)
|
||||||
|
assertNull(request.headers["Authorization"], "renew authenticates by mTLS — it must send NO Authorization header")
|
||||||
|
val obj = bodyObject(request)
|
||||||
|
assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content)
|
||||||
|
// The server's /device/:id/renew authenticates by the presented mTLS device cert and its body
|
||||||
|
// schema is `{ csr }` ONLY (.strict()) — any enroll-only extra (keyAlg/subdomain/deviceName)
|
||||||
|
// is rejected. The renew wire body must therefore carry the single `csr` key and nothing else.
|
||||||
|
assertEquals(setOf("csr"), obj.keys, "renew body is {csr}-only — no keyAlg/subdomain/deviceName")
|
||||||
|
assertFalse(obj.containsKey("keyAlg"), "renew must not send the enroll-only keyAlg field")
|
||||||
|
assertFalse(obj.containsKey("subdomain"), "renew body carries no subdomain/deviceName")
|
||||||
|
assertEquals("dev-9", result.deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun renewForwardsAnExplicitBearerWhenTheOptionalSeamIsUsed() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201, body = enrollResponse(deviceId = "dev-9"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// The bearer is optional/absent by default; when a caller DOES pass one it rides as a header.
|
||||||
|
client.renew("dev-9", byteArrayOf(0x02), bearerToken = BEARER)
|
||||||
|
|
||||||
|
assertEquals("Bearer $BEARER", transport.recordedRequests.single().headers["Authorization"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun renewRejectsEmptyDeviceIdOrCsrBeforeAnyNetworkIo() = runTest {
|
||||||
|
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew("", byteArrayOf(1)) }.exceptionOrNull())
|
||||||
|
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew("d", ByteArray(0)) }.exceptionOrNull())
|
||||||
|
assertTrue(transport.recordedRequests.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun renewDecodesTheRealServerResponseWhichCarriesNoDeviceId() = runTest {
|
||||||
|
// THE REAL WIRE SHAPE: `/device/:id/renew` answers `{cert, caChain, notAfter}` — it does NOT
|
||||||
|
// echo deviceId/notBefore/renewAfter (only `/device/enroll` does; see
|
||||||
|
// control-plane/src/api/renew.ts). Decoding it against the enroll DTO (deviceId required)
|
||||||
|
// made every silent renewal fail as MalformedResponse. The device id comes from the path we
|
||||||
|
// called, which is authoritative anyway.
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201,
|
||||||
|
body = """{"cert":"MAEC","caChain":["MAED"],"notAfter":"2026-12-06T00:00:00.000Z"}""".toByteArray(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = client.renew("dev-9", byteArrayOf(0x02))
|
||||||
|
|
||||||
|
assertEquals("dev-9", result.deviceId, "the device id comes from the path segment we called")
|
||||||
|
assertEquals(Instant.parse("2026-12-06T00:00:00Z"), result.notAfter)
|
||||||
|
assertNull(result.notBefore, "the renew 201 carries no notBefore")
|
||||||
|
assertNull(result.renewAfter, "the renew 201 carries no renewAfter — the client derives it")
|
||||||
|
assertEquals(1, result.caChain.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── recover (EXPIRED-leaf escape hatch — plain HTTPS, cert in the BODY, never mTLS) ──────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun recoverPostsTheExpiredLeafAndAFreshCsrWithNoAuthorizationHeader() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 201,
|
||||||
|
body = """{"cert":"MAEC","caChain":[],"notAfter":"2026-12-06T00:00:00.000Z"}""".toByteArray(),
|
||||||
|
)
|
||||||
|
val expiredLeaf = byteArrayOf(0x30, 0x11)
|
||||||
|
val csr = byteArrayOf(0x30, 0x22)
|
||||||
|
|
||||||
|
val result = client.recover("dev-9", expiredLeaf, csr)
|
||||||
|
|
||||||
|
val request = transport.recordedRequests.single()
|
||||||
|
assertEquals(HttpMethod.POST, request.method)
|
||||||
|
assertEquals("$BASE/device/dev-9/recover", request.url)
|
||||||
|
// Recovery is NOT mTLS-authenticated and carries no bearer: an expired client certificate
|
||||||
|
// cannot authenticate its own re-issuance (no TLS terminator will even forward one), so the
|
||||||
|
// lapsed cert travels in the BODY and proof-of-possession comes from the CSR self-signature.
|
||||||
|
assertNull(request.headers["Authorization"], "recovery carries no bearer")
|
||||||
|
val obj = bodyObject(request)
|
||||||
|
assertEquals(setOf("cert", "csr"), obj.keys, "the /recover schema is .strict() on {cert, csr}")
|
||||||
|
assertEquals(Base64.getEncoder().encodeToString(expiredLeaf), obj["cert"]!!.jsonPrimitive.content)
|
||||||
|
assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content)
|
||||||
|
assertEquals("dev-9", result.deviceId)
|
||||||
|
assertEquals(Instant.parse("2026-12-06T00:00:00Z"), result.notAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun recoverPercentEncodesTheDeviceIdPathSegment() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST, url = "$BASE/device/a%2Fb/recover", status = 201,
|
||||||
|
body = """{"cert":"MAEC","caChain":[]}""".toByteArray(),
|
||||||
|
)
|
||||||
|
client.recover("a/b", byteArrayOf(1), byteArrayOf(2))
|
||||||
|
assertEquals("$BASE/device/a%2Fb/recover", transport.recordedRequests.single().url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun recoverRejectsEmptyArgumentsBeforeAnyNetworkIo() = runTest {
|
||||||
|
assertEquals(
|
||||||
|
DeviceEnrollmentError.InvalidRequest,
|
||||||
|
runCatching { client.recover("", byteArrayOf(1), byteArrayOf(1)) }.exceptionOrNull(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DeviceEnrollmentError.InvalidRequest,
|
||||||
|
runCatching { client.recover("d", ByteArray(0), byteArrayOf(1)) }.exceptionOrNull(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DeviceEnrollmentError.InvalidRequest,
|
||||||
|
runCatching { client.recover("d", byteArrayOf(1), ByteArray(0)) }.exceptionOrNull(),
|
||||||
|
)
|
||||||
|
assertTrue(transport.recordedRequests.isEmpty(), "no I/O for a request that cannot succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun recoverSurfacesA401BeyondTheGraceWindowWithoutLeakingTheCert() = runTest {
|
||||||
|
// Past `DEFAULT_EXPIRED_RENEW_GRACE_MS` the control plane answers a uniform 401. The raised
|
||||||
|
// error must carry the STATUS + the server's `error` code only — never the submitted cert or
|
||||||
|
// CSR bytes, which would put credential material into a crash log.
|
||||||
|
val expiredLeaf = byteArrayOf(0x30, 0x11, 0x22, 0x33)
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 401,
|
||||||
|
body = """{"error":"rejected"}""".toByteArray(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val error = runCatching { client.recover("dev-9", expiredLeaf, byteArrayOf(0x30, 0x44)) }.exceptionOrNull()
|
||||||
|
|
||||||
|
assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error)
|
||||||
|
val rendered = "${error!!.message} $error"
|
||||||
|
assertFalse(
|
||||||
|
rendered.contains(Base64.getEncoder().encodeToString(expiredLeaf)),
|
||||||
|
"the raised error must not carry the certificate bytes",
|
||||||
|
)
|
||||||
|
assertFalse(rendered.contains("MDE"), "no base64 credential material in the error rendering")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun recoverSurfacesA403ForARevokedDevice() = runTest {
|
||||||
|
// Recovery NEVER bypasses revocation (renew.test.ts CP6e) — surface the 403 verbatim.
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 403,
|
||||||
|
body = """{"error":"rejected"}""".toByteArray(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DeviceEnrollmentError.Http(403, "rejected"),
|
||||||
|
runCatching { client.recover("dev-9", byteArrayOf(1), byteArrayOf(2)) }.exceptionOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun recoverThrowsMalformedResponseOnAnUndecodable201() = runTest {
|
||||||
|
transport.queueSuccess(
|
||||||
|
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 201,
|
||||||
|
body = """{"cert":"@@not-base64@@"}""".toByteArray(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DeviceEnrollmentError.MalformedResponse,
|
||||||
|
runCatching { client.recover("dev-9", byteArrayOf(1), byteArrayOf(2)) }.exceptionOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theBaseUrlIsReadableSoTheRotationTargetCanBePersisted() = runTest {
|
||||||
|
// The rotation driver must renew against the SAME control plane the device enrolled with; the
|
||||||
|
// enroller persists this alongside the enrollment record.
|
||||||
|
assertEquals(BASE, client.baseUrl)
|
||||||
|
assertEquals(BASE, DeviceEnrollmentClient("$BASE/", transport).baseUrl, "a trailing slash is trimmed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── isRenewalDue seam ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isRenewalDueFlipsAtRenewAfter() = runTest {
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse())
|
||||||
|
val result = client.enroll(BEARER, byteArrayOf(1), "a", "d")
|
||||||
|
assertFalse(result.isRenewalDue(Instant.parse("2026-09-04T00:00:00Z")))
|
||||||
|
assertTrue(result.isRenewalDue(Instant.parse("2026-09-06T00:00:00Z")))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertArrayEquals(expected: ByteArray, actual: ByteArray) =
|
||||||
|
org.junit.jupiter.api.Assertions.assertArrayEquals(expected, actual)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package wang.yaojia.webterm.api.enroll
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.math.BigInteger
|
||||||
|
import java.security.KeyPairGenerator
|
||||||
|
import java.security.interfaces.ECPublicKey
|
||||||
|
import java.security.spec.ECGenParameterSpec
|
||||||
|
|
||||||
|
/** B4 · The X9.63 uncompressed-point encoder — the security-load-bearing SubjectPublicKeyInfo bytes. */
|
||||||
|
class EcPointEncodingTest {
|
||||||
|
@Test
|
||||||
|
fun encodesAGeneratedP256KeyAs65UncompressedBytesRoundTrippingToTheCoordinates() {
|
||||||
|
val kp = KeyPairGenerator.getInstance("EC").apply {
|
||||||
|
initialize(ECGenParameterSpec("secp256r1"))
|
||||||
|
}.generateKeyPair()
|
||||||
|
val pub = kp.public as ECPublicKey
|
||||||
|
|
||||||
|
val encoded = EcPointEncoding.x963(pub)
|
||||||
|
|
||||||
|
assertEquals(65, encoded.size, "0x04 || X(32) || Y(32)")
|
||||||
|
assertEquals(0x04, encoded[0].toInt() and 0xFF, "uncompressed-point prefix")
|
||||||
|
// The 32-byte big-endian halves must be exactly the affine coordinates.
|
||||||
|
val x = BigInteger(1, encoded.copyOfRange(1, 33))
|
||||||
|
val y = BigInteger(1, encoded.copyOfRange(33, 65))
|
||||||
|
assertEquals(pub.w.affineX, x)
|
||||||
|
assertEquals(pub.w.affineY, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun leftPadsAShortCoordinateToTheFixedWidth() {
|
||||||
|
// A small value must be left-padded with leading zeros to exactly 32 bytes.
|
||||||
|
val padded = EcPointEncoding.toFixedLengthUnsigned(BigInteger.valueOf(1), 32)
|
||||||
|
assertEquals(32, padded.size)
|
||||||
|
assertEquals(1, padded[31].toInt())
|
||||||
|
assertEquals(0, padded[0].toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dropsTheBigIntegerSignByteWhenPresent() {
|
||||||
|
// A value whose top bit is set carries a leading 0x00 sign byte in BigInteger.toByteArray();
|
||||||
|
// it must be dropped, not counted toward the width.
|
||||||
|
val highBit = BigInteger(1, ByteArray(32) { 0xFF.toByte() })
|
||||||
|
val encoded = EcPointEncoding.toFixedLengthUnsigned(highBit, 32)
|
||||||
|
assertEquals(32, encoded.size)
|
||||||
|
assertEquals(0xFF, encoded[0].toInt() and 0xFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsACoordinateThatDoesNotFit() {
|
||||||
|
val tooBig = BigInteger.ONE.shiftLeft(256) // needs 33 bytes
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
EcPointEncoding.toFixedLengthUnsigned(tooBig, 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package wang.yaojia.webterm.api.enroll
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The PURE rotation decision table (repo precedent: `ReconnectMachine`, `LayoutPolicy`,
|
||||||
|
* `TerminalGridMath` — a policy is a function, so it is exhaustively testable with no clock, no
|
||||||
|
* keystore and no network). Ports the iOS `CertificateRotationSchedulerTests` timing cases and adds
|
||||||
|
* the two the phone track needs and iOS lacks: the EXPIRED-leaf `/device/:id/recover` window and the
|
||||||
|
* terminal beyond-grace state.
|
||||||
|
*/
|
||||||
|
class RotationPolicyTest {
|
||||||
|
private companion object {
|
||||||
|
val NOT_BEFORE: Instant = Instant.parse("2026-08-06T00:00:00Z")
|
||||||
|
val NOT_AFTER: Instant = Instant.parse("2026-10-06T00:00:00Z")
|
||||||
|
// notBefore + 2/3 · 61d lifetime — the value the control plane itself computes.
|
||||||
|
val RENEW_AFTER: Instant = Instant.parse("2026-09-15T16:00:00Z")
|
||||||
|
|
||||||
|
fun state(
|
||||||
|
notAfter: Instant? = NOT_AFTER,
|
||||||
|
renewAfter: Instant? = RENEW_AFTER,
|
||||||
|
): DeviceRenewalState = DeviceRenewalState(deviceId = "dev-1", notAfter = notAfter, renewAfter = renewAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── renewAfter derivation (the client MUST derive it: renew/recover 201s carry no renewAfter) ──
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun renewAfterIsTwoThirdsThroughTheLeafLifetime() {
|
||||||
|
// The control plane computes `notBefore + floor(2/3 · lifetime)` (device-enroll.ts
|
||||||
|
// DEFAULT_RENEW_FRACTION); the client must land on the same instant from the leaf alone,
|
||||||
|
// because `/device/:id/renew` and `/device/:id/recover` return `{cert, caChain, notAfter}`
|
||||||
|
// ONLY — no renewAfter. Without this derivation rotation would fire exactly once, ever.
|
||||||
|
assertEquals(RENEW_AFTER, RotationPolicy.renewAfterFor(NOT_BEFORE, NOT_AFTER))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun renewAfterIsNullWhenEitherBoundIsUnknown() {
|
||||||
|
assertNull(RotationPolicy.renewAfterFor(null, NOT_AFTER))
|
||||||
|
assertNull(RotationPolicy.renewAfterFor(NOT_BEFORE, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun renewAfterIsNullForANonsensicalWindow() {
|
||||||
|
// notAfter before notBefore is not a lifetime — degrade to "unknown" (fail-safe: the TLS
|
||||||
|
// stack is the real gate) instead of inventing a past instant that would renew-storm.
|
||||||
|
assertNull(RotationPolicy.renewAfterFor(NOT_AFTER, NOT_BEFORE))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the wait branch ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aLeafInsideItsRenewWindowIsNotDue() {
|
||||||
|
assertEquals(
|
||||||
|
RotationDecision.NOT_DUE,
|
||||||
|
RotationPolicy.decide(state(), now = RENEW_AFTER.minusSeconds(1)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aMissingRenewAfterNeverTriggers() {
|
||||||
|
// iOS fail-safe, ported verbatim: absent timing NEVER renews — the TLS stack is the real gate
|
||||||
|
// and the scheduler only pre-empts expiry.
|
||||||
|
assertEquals(
|
||||||
|
RotationDecision.NOT_DUE,
|
||||||
|
RotationPolicy.decide(state(renewAfter = null), now = NOT_AFTER.minusSeconds(1)),
|
||||||
|
)
|
||||||
|
assertFalse(state(renewAfter = null).isRenewalDue(NOT_AFTER.minusSeconds(1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anUnknownNotAfterFallsBackToTheRenewAfterBranch() {
|
||||||
|
// A cert whose expiry could not be read must not be treated as expired (that would push a
|
||||||
|
// working device into recovery); it is judged solely on the advisory renew timing.
|
||||||
|
assertEquals(
|
||||||
|
RotationDecision.NOT_DUE,
|
||||||
|
RotationPolicy.decide(state(notAfter = null), now = RENEW_AFTER.minusSeconds(1)),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
RotationDecision.RENEW,
|
||||||
|
RotationPolicy.decide(state(notAfter = null), now = RENEW_AFTER),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the renew branch ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun renewFiresAtTheRenewAfterBoundary() {
|
||||||
|
assertEquals(RotationDecision.RENEW, RotationPolicy.decide(state(), now = RENEW_AFTER))
|
||||||
|
assertTrue(state().isRenewalDue(RENEW_AFTER), "the >= boundary renews (iOS parity)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun renewStillFiresAtTheLastInstantBeforeExpiry() {
|
||||||
|
assertEquals(RotationDecision.RENEW, RotationPolicy.decide(state(), now = NOT_AFTER))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the recover branch (expired leaf — the phone-track deadlock escape hatch) ───────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anExpiredLeafInsideTheGraceWindowRecovers() {
|
||||||
|
// mTLS `/renew` cannot authenticate an expired leaf (nginx refuses to forward one at all), so
|
||||||
|
// the only route left is the plain-HTTPS `/device/:id/recover`.
|
||||||
|
assertEquals(
|
||||||
|
RotationDecision.RECOVER,
|
||||||
|
RotationPolicy.decide(state(), now = NOT_AFTER.plus(Duration.ofDays(8))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun recoveryIsStillAvailableAtTheExactGraceBoundary() {
|
||||||
|
val grace = Duration.ofDays(30)
|
||||||
|
assertEquals(
|
||||||
|
RotationDecision.RECOVER,
|
||||||
|
RotationPolicy.decide(state(), now = NOT_AFTER.plus(grace), expiredRecoveryGrace = grace),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aLeafExpiredBeyondTheGraceWindowRequiresAFreshEnroll() {
|
||||||
|
assertEquals(
|
||||||
|
RotationDecision.RE_ENROLL_REQUIRED,
|
||||||
|
RotationPolicy.decide(state(), now = NOT_AFTER.plus(Duration.ofDays(31))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aZeroGraceDisablesRecoveryEntirely() {
|
||||||
|
// Mirrors the control plane's `expiredRenewGraceMs = 0` posture (renew.test.ts CP6e).
|
||||||
|
assertEquals(
|
||||||
|
RotationDecision.RE_ENROLL_REQUIRED,
|
||||||
|
RotationPolicy.decide(
|
||||||
|
state(),
|
||||||
|
now = NOT_AFTER.plusMillis(1),
|
||||||
|
expiredRecoveryGrace = Duration.ZERO,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theTerminalStateWinsOverTheFailureBackoff() {
|
||||||
|
// Beyond grace nothing can ever succeed, so the answer must be the terminal one even while a
|
||||||
|
// backoff is armed — otherwise the user is told "retrying" forever (the exact failure mode
|
||||||
|
// the agent's 6380 identical warnings came from).
|
||||||
|
assertEquals(
|
||||||
|
RotationDecision.RE_ENROLL_REQUIRED,
|
||||||
|
RotationPolicy.decide(
|
||||||
|
state(),
|
||||||
|
now = NOT_AFTER.plus(Duration.ofDays(31)),
|
||||||
|
lastFailureAt = NOT_AFTER.plus(Duration.ofDays(31)).minusSeconds(1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the backoff branch (a failed attempt must not hammer the control plane) ─────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRecentFailureBacksOffInsteadOfRetryingImmediately() {
|
||||||
|
// Every foreground fires a pass; the control plane rate-limits renewals to 30/hour/identity,
|
||||||
|
// so an un-throttled retry turns one failure into a 429 storm.
|
||||||
|
val now = RENEW_AFTER.plus(Duration.ofMinutes(1))
|
||||||
|
assertEquals(
|
||||||
|
RotationDecision.BACKING_OFF,
|
||||||
|
RotationPolicy.decide(state(), now = now, lastFailureAt = now.minus(Duration.ofMinutes(1))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theBackoffExpiresAndTheRenewIsRetried() {
|
||||||
|
val now = RENEW_AFTER.plus(Duration.ofHours(1))
|
||||||
|
assertEquals(
|
||||||
|
RotationDecision.RENEW,
|
||||||
|
RotationPolicy.decide(
|
||||||
|
state(),
|
||||||
|
now = now,
|
||||||
|
lastFailureAt = now.minus(RotationPolicy.DEFAULT_FAILURE_BACKOFF),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRecentFailureAlsoThrottlesTheRecoveryPath() {
|
||||||
|
val now = NOT_AFTER.plus(Duration.ofDays(8))
|
||||||
|
assertEquals(
|
||||||
|
RotationDecision.BACKING_OFF,
|
||||||
|
RotationPolicy.decide(state(), now = now, lastFailureAt = now.minusSeconds(30)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aFailureNeverTurnsAnIdleLeafIntoAnAttempt() {
|
||||||
|
// NOT_DUE has no attempt to throttle — reporting BACKING_OFF there would be a lie.
|
||||||
|
assertEquals(
|
||||||
|
RotationDecision.NOT_DUE,
|
||||||
|
RotationPolicy.decide(
|
||||||
|
state(),
|
||||||
|
now = RENEW_AFTER.minusSeconds(1),
|
||||||
|
lastFailureAt = RENEW_AFTER.minusSeconds(2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── boundary validation ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun negativeDurationsAreRejectedAtTheBoundary() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
RotationPolicy.decide(state(), now = RENEW_AFTER, expiredRecoveryGrace = Duration.ofDays(-1))
|
||||||
|
}
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
RotationPolicy.decide(state(), now = RENEW_AFTER, failureBackoff = Duration.ofMinutes(-1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theStateCarriesNoCredentialInItsToString() {
|
||||||
|
// The rotation state is logged/surfaced; it must never be able to carry key or cert bytes.
|
||||||
|
val rendered = state().toString()
|
||||||
|
assertTrue(rendered.contains("dev-1"), "the non-secret device id is the only identifier here")
|
||||||
|
assertFalse(rendered.contains("MII"), "no DER/PEM material may appear in a loggable rendering")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitLogResult list-lossy decode (plan Phase A.2): a well-formed `{commits,truncated}` decodes; a
|
||||||
|
* commit missing `hash`/`at` is dropped while its siblings survive; `truncated` passes through; a
|
||||||
|
* subject-less commit still decodes (subject defaults to empty).
|
||||||
|
*/
|
||||||
|
class GitLogTest {
|
||||||
|
|
||||||
|
private fun decode(json: String): GitLogResult? =
|
||||||
|
LossyDecode.objectOrNull(json.toByteArray(), GitLogResult.serializer())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decodes commits and truncated`() {
|
||||||
|
val json = """
|
||||||
|
{ "truncated": true, "commits": [
|
||||||
|
{ "hash":"abc123", "at": 1710000000000, "subject":"first" },
|
||||||
|
{ "hash":"def456", "at": 1710000005000, "subject":"second" }
|
||||||
|
] }
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val result = decode(json)!!
|
||||||
|
assertTrue(result.truncated)
|
||||||
|
assertEquals(2, result.commits.size)
|
||||||
|
assertEquals("abc123", result.commits[0].hash)
|
||||||
|
assertEquals(1710000000000L, result.commits[0].at)
|
||||||
|
assertEquals("first", result.commits[0].subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `drops a commit missing hash or at, keeping the rest`() {
|
||||||
|
val json = """
|
||||||
|
{ "truncated": false, "commits": [
|
||||||
|
{ "at": 1, "subject":"no hash" },
|
||||||
|
{ "hash":"keep", "at": 2, "subject":"kept" },
|
||||||
|
{ "hash":"noAt", "subject":"no at" }
|
||||||
|
] }
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val result = decode(json)!!
|
||||||
|
assertFalse(result.truncated)
|
||||||
|
assertEquals(1, result.commits.size)
|
||||||
|
assertEquals("keep", result.commits.single().hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a subject-less commit still decodes with an empty subject`() {
|
||||||
|
val result = decode("""{ "commits":[ { "hash":"h", "at": 5 } ] }""")!!
|
||||||
|
assertEquals(1, result.commits.size)
|
||||||
|
assertEquals("", result.commits.single().subject)
|
||||||
|
assertFalse(result.truncated) // default
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a non-object body degrades to null`() {
|
||||||
|
org.junit.jupiter.api.Assertions.assertNull(decode("[]"))
|
||||||
|
org.junit.jupiter.api.Assertions.assertNull(decode("garbage"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitWrite payload + error decode (plan Phase A.3): each 200 payload decodes; a failure body
|
||||||
|
* `{ok:false,error:"…"}` (git-ops) and `{error:"…"}` (worktrees) both yield the SAFE `error` string;
|
||||||
|
* a garbled 200 body degrades to the payload defaults (never throws). The empty-sha commit case is
|
||||||
|
* exercised (server can return `{ok:true, commit:""}`).
|
||||||
|
*/
|
||||||
|
class GitWriteTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stage payload decodes staged and count`() {
|
||||||
|
val r = decodeGitPayload("""{"ok":true,"staged":true,"count":3}""".toByteArray(), StageResult.serializer())
|
||||||
|
assertEquals(StageResult(staged = true, count = 3), r)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `commit payload decodes the sha and tolerates an empty sha`() {
|
||||||
|
assertEquals("a1b2c3", decodeGitPayload("""{"ok":true,"commit":"a1b2c3"}""".toByteArray(), CommitResult.serializer()).commit)
|
||||||
|
assertEquals("", decodeGitPayload("""{"ok":true,"commit":""}""".toByteArray(), CommitResult.serializer()).commit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `push payload decodes branch and remote`() {
|
||||||
|
val r = decodeGitPayload("""{"ok":true,"branch":"main","remote":"origin"}""".toByteArray(), PushResult.serializer())
|
||||||
|
assertEquals("main", r.branch)
|
||||||
|
assertEquals("origin", r.remote)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `worktree create and remove and prune payloads decode`() {
|
||||||
|
val create = decodeGitPayload("""{"ok":true,"path":"/wt/x","branch":"feat"}""".toByteArray(), CreateWorktreeResult.serializer())
|
||||||
|
assertEquals("/wt/x", create.path)
|
||||||
|
assertEquals("feat", create.branch)
|
||||||
|
|
||||||
|
val remove = decodeGitPayload("""{"ok":true,"path":"/wt/x"}""".toByteArray(), RemoveWorktreeResult.serializer())
|
||||||
|
assertEquals("/wt/x", remove.path)
|
||||||
|
|
||||||
|
val prune = decodeGitPayload("""{"ok":true,"pruned":["a","b"]}""".toByteArray(), PruneWorktreesResult.serializer())
|
||||||
|
assertEquals(listOf("a", "b"), prune.pruned)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a garbled 200 body degrades to payload defaults, never throwing`() {
|
||||||
|
assertEquals(StageResult(), decodeGitPayload("not json".toByteArray(), StageResult.serializer()))
|
||||||
|
assertEquals(CommitResult(), decodeGitPayload("[]".toByteArray(), CommitResult.serializer()))
|
||||||
|
assertTrue(decodeGitPayload("{}".toByteArray(), PruneWorktreesResult.serializer()).pruned.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a git-ops failure body yields the safe error string`() {
|
||||||
|
assertEquals("Nothing to commit.", decodeGitError("""{"ok":false,"error":"Nothing to commit."}""".toByteArray()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a worktree failure body (no ok field) still yields the error string`() {
|
||||||
|
assertEquals("Worktree creation is disabled.", decodeGitError("""{"error":"Worktree creation is disabled."}""".toByteArray()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty or errorless failure body yields null`() {
|
||||||
|
assertNull(decodeGitError(ByteArray(0)))
|
||||||
|
assertNull(decodeGitError("""{"ok":false}""".toByteArray()))
|
||||||
|
assertNull(decodeGitError("not json".toByteArray()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PrStatus tolerant decode (plan Phase A.1): a full `availability:"ok"` body decodes every field; an
|
||||||
|
* unknown/missing `availability` degrades to [PrAvailability.ERROR] (never throws); `PrCheckSummary`
|
||||||
|
* counts round-trip; a non-object body degrades rather than crashing. Mirrors the FE never treating a
|
||||||
|
* non-`ok` availability as an HTTP error.
|
||||||
|
*/
|
||||||
|
class PrStatusTest {
|
||||||
|
|
||||||
|
private fun decode(json: String): PrStatus? =
|
||||||
|
LossyDecode.objectOrNull(json.toByteArray(), PrStatus.serializer())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decodes a full ok body with all fields and check counts`() {
|
||||||
|
val json = """
|
||||||
|
{ "availability":"ok", "number":42, "title":"Add worktrees", "url":"https://x/pull/42",
|
||||||
|
"state":"open", "isDraft":false, "mergeable":"mergeable",
|
||||||
|
"headRefName":"feat/wt", "baseRefName":"main",
|
||||||
|
"checks": { "total":5, "passing":3, "failing":1, "pending":1 } }
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val pr = decode(json)!!
|
||||||
|
assertEquals(PrAvailability.OK, pr.availability)
|
||||||
|
assertEquals(42, pr.number)
|
||||||
|
assertEquals("Add worktrees", pr.title)
|
||||||
|
assertEquals("https://x/pull/42", pr.url)
|
||||||
|
assertEquals("open", pr.state)
|
||||||
|
assertEquals(false, pr.isDraft)
|
||||||
|
assertEquals("mergeable", pr.mergeable)
|
||||||
|
assertEquals("feat/wt", pr.headRefName)
|
||||||
|
assertEquals("main", pr.baseRefName)
|
||||||
|
assertEquals(PrCheckSummary(total = 5, passing = 3, failing = 1, pending = 1), pr.checks)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unknown availability degrades to ERROR, never throwing`() {
|
||||||
|
val pr = decode("""{ "availability":"quantum-flux" }""")!!
|
||||||
|
assertEquals(PrAvailability.ERROR, pr.availability)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a body missing availability defaults to ERROR and leaves optional fields null`() {
|
||||||
|
val pr = decode("""{ "number":7 }""")!!
|
||||||
|
assertEquals(PrAvailability.ERROR, pr.availability)
|
||||||
|
assertEquals(7, pr.number)
|
||||||
|
assertNull(pr.title)
|
||||||
|
assertNull(pr.checks)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `each known availability maps from its wire value`() {
|
||||||
|
assertEquals(PrAvailability.NO_PR, PrAvailability.fromWire("no-pr"))
|
||||||
|
assertEquals(PrAvailability.NOT_INSTALLED, PrAvailability.fromWire("not-installed"))
|
||||||
|
assertEquals(PrAvailability.UNAUTHENTICATED, PrAvailability.fromWire("unauthenticated"))
|
||||||
|
assertEquals(PrAvailability.DISABLED, PrAvailability.fromWire("disabled"))
|
||||||
|
assertEquals(PrAvailability.ERROR, PrAvailability.fromWire("error"))
|
||||||
|
assertEquals(PrAvailability.ERROR, PrAvailability.fromWire("")) // empty → ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a non-object body degrades to null rather than throwing`() {
|
||||||
|
assertNull(decode("[]"))
|
||||||
|
assertNull(decode("not json"))
|
||||||
|
assertNull(LossyDecode.objectOrNull(ByteArray(0), PrStatus.serializer()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown top-level keys are ignored`() {
|
||||||
|
val pr = decode("""{ "availability":"ok", "futureField":123, "nested":{"a":1} }""")!!
|
||||||
|
assertEquals(PrAvailability.OK, pr.availability)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tolerant decode for the W2 queue read (`GET /live-sessions/:id/queue` → `{length, items}`) and for
|
||||||
|
* the additive `LiveSessionInfo.queueLength` the badge reads. The server is UNTRUSTED here: unknown
|
||||||
|
* keys, missing keys and wrong-typed items must degrade, never crash — and the queued strings, which
|
||||||
|
* are raw keyboard bytes, must come back verbatim.
|
||||||
|
*/
|
||||||
|
@DisplayName("W2 queue models — tolerant decode + verbatim items")
|
||||||
|
class PromptQueueTest {
|
||||||
|
private fun snapshot(json: String): QueueSnapshot? =
|
||||||
|
LossyDecode.objectOrNull(json.toByteArray(), QueueSnapshot.serializer())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decodes length and items`() {
|
||||||
|
val s = snapshot("""{"length":2,"items":["first","second"]}""")!!
|
||||||
|
assertEquals(2, s.length)
|
||||||
|
assertEquals(listOf("first", "second"), s.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `queued items keep control characters and CJK verbatim`() {
|
||||||
|
// [A + TAB + CR + CJK, exactly as the server stored the keystroke bytes.
|
||||||
|
val s = snapshot("""{"length":1,"items":["[A\t你好\r"]}""")!!
|
||||||
|
assertEquals("[A\t你好\r", s.items.single())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty queue and unknown keys both decode`() {
|
||||||
|
val s = snapshot("""{"length":0,"items":[],"futureField":true}""")!!
|
||||||
|
assertEquals(0, s.length)
|
||||||
|
assertTrue(s.items.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing fields fall back to an empty queue and a non-object body is null`() {
|
||||||
|
val s = snapshot("{}")!!
|
||||||
|
assertEquals(0, s.length)
|
||||||
|
assertTrue(s.items.isEmpty())
|
||||||
|
|
||||||
|
assertNull(snapshot("[]"))
|
||||||
|
assertNull(snapshot("garbage"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LiveSessionInfo.queueLength (additive optional, src/types.ts:318) ─────────────────────
|
||||||
|
|
||||||
|
private val base =
|
||||||
|
"""{"id":"11111111-2222-3333-4444-555555555555","createdAt":1,"clientCount":0,"exited":false,"cols":80,"rows":24"""
|
||||||
|
|
||||||
|
private fun info(extra: String): LiveSessionInfo? =
|
||||||
|
LossyDecode.objectOrNull("$base$extra}".toByteArray(), LiveSessionInfo.serializer())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `queueLength decodes when present`() {
|
||||||
|
assertEquals(4, info(""","queueLength":4""")!!.queueLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `queueLength is null on a pre-W2 server that omits it`() {
|
||||||
|
assertNull(info("")!!.queueLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a wrong-typed queueLength does not drop the whole session`() {
|
||||||
|
// A garbled value must not make a running session invisible — the field degrades to null.
|
||||||
|
assertNull(info(",\"queueLength\":\"lots\"")!!.queueLength)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* w6/G1 sync state.
|
||||||
|
*
|
||||||
|
* The load-bearing rule, quoted from the server commit that introduced the feature: *"Exactly ONE state
|
||||||
|
* may render green: ↑0 ↓0 AND a fresh fetch. Green means 'I checked, ignore this'; getting it wrong is
|
||||||
|
* lying to the user."* Most of this file exists to pin the ways that could go wrong — above all the
|
||||||
|
* no-upstream fall-through, which the server's own commit message calls "the easiest bug to ship here".
|
||||||
|
*/
|
||||||
|
@DisplayName("SyncState — when the all-clear may be rendered")
|
||||||
|
class SyncStateTest {
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
private val now = 1_700_000_000_000L
|
||||||
|
private fun fresh() = now - 60_000L // a minute ago
|
||||||
|
private fun stale() = now - (SyncState.FETCH_FRESH_WINDOW_MS + 60_000L)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("decodes the full server shape")
|
||||||
|
fun decodesFull() {
|
||||||
|
val s = json.decodeFromString<SyncState>(
|
||||||
|
"""{"upstream":"origin/develop","ahead":9,"behind":0,"lastFetchMs":$now,"detached":false}""",
|
||||||
|
)
|
||||||
|
assertEquals("origin/develop", s.upstream)
|
||||||
|
assertEquals(9, s.ahead)
|
||||||
|
assertEquals(0, s.behind)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("every field is optional — each degrades independently on the server")
|
||||||
|
fun decodesEmpty() {
|
||||||
|
val s = json.decodeFromString<SyncState>("{}")
|
||||||
|
assertNull(s.upstream)
|
||||||
|
assertNull(s.ahead)
|
||||||
|
assertNull(s.behind)
|
||||||
|
assertNull(s.lastFetchMs)
|
||||||
|
assertFalse(s.detached)
|
||||||
|
assertFalse(s.hasUpstream)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the green state ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("↑0 ↓0 with a fresh fetch is the ONLY green state")
|
||||||
|
fun theOneGreenState() {
|
||||||
|
val s = SyncState(upstream = "origin/main", ahead = 0, behind = 0, lastFetchMs = fresh())
|
||||||
|
assertTrue(s.isFullySynced(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("NO UPSTREAM is never green — the fall-through the server calls the easiest bug here")
|
||||||
|
fun noUpstreamIsNeverGreen() {
|
||||||
|
// The normal state of a fresh worktree branch. ahead/behind are UNDEFINED, not zero.
|
||||||
|
val s = SyncState(upstream = null, ahead = null, behind = null, lastFetchMs = fresh())
|
||||||
|
assertFalse(s.isFullySynced(now), "a branch tracking nothing has not been verified as in sync")
|
||||||
|
assertFalse(s.hasUpstream)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("undefined counts are not zero, even with an upstream and a fresh fetch")
|
||||||
|
fun undefinedCountsAreNotZero() {
|
||||||
|
assertFalse(SyncState("origin/main", ahead = null, behind = 0, lastFetchMs = fresh()).isFullySynced(now))
|
||||||
|
assertFalse(SyncState("origin/main", ahead = 0, behind = null, lastFetchMs = fresh()).isFullySynced(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("a stale fetch is not green — behind=0 from a stale FETCH_HEAD proves nothing")
|
||||||
|
fun staleFetchIsNotGreen() {
|
||||||
|
val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = stale())
|
||||||
|
assertFalse(s.isFullySynced(now))
|
||||||
|
assertTrue(s.isBehindStale(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("never fetched is not green")
|
||||||
|
fun neverFetchedIsNotGreen() {
|
||||||
|
val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = null)
|
||||||
|
assertFalse(s.isFullySynced(now))
|
||||||
|
assertFalse(s.isFetchFresh(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("a detached HEAD is not green")
|
||||||
|
fun detachedIsNotGreen() {
|
||||||
|
val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = fresh(), detached = true)
|
||||||
|
assertFalse(s.isFullySynced(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("anything unpushed or unpulled is not green")
|
||||||
|
fun pendingWorkIsNotGreen() {
|
||||||
|
assertFalse(SyncState("origin/main", ahead = 1, behind = 0, lastFetchMs = fresh()).isFullySynced(now))
|
||||||
|
assertFalse(SyncState("origin/main", ahead = 0, behind = 1, lastFetchMs = fresh()).isFullySynced(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── freshness edges ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("a fetch timestamp in the future is not treated as fresh")
|
||||||
|
fun futureFetchIsNotFresh() {
|
||||||
|
val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = now + 60_000L)
|
||||||
|
assertFalse(s.isFetchFresh(now), "clock skew is not evidence that we checked")
|
||||||
|
assertFalse(s.isFullySynced(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("the freshness boundary is inclusive at exactly the window")
|
||||||
|
fun boundaryInclusive() {
|
||||||
|
val atEdge = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = now - SyncState.FETCH_FRESH_WINDOW_MS)
|
||||||
|
assertTrue(atEdge.isFetchFresh(now))
|
||||||
|
val pastEdge = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = now - SyncState.FETCH_FRESH_WINDOW_MS - 1)
|
||||||
|
assertFalse(pastEdge.isFetchFresh(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("staleness is only claimed when there is an upstream to be stale about")
|
||||||
|
fun noUpstreamIsNotStale() {
|
||||||
|
assertFalse(SyncState(upstream = null, lastFetchMs = null).isBehindStale(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("WorktreeState decodes the narrow per-row shape")
|
||||||
|
fun worktreeStateDecodes() {
|
||||||
|
val w = json.decodeFromString<WorktreeState>(
|
||||||
|
"""{"path":"/repo/.claude/worktrees/x","branch":"wt-x","dirtyCount":3,
|
||||||
|
"sync":{"upstream":"origin/wt-x","ahead":2}}""",
|
||||||
|
)
|
||||||
|
assertEquals("wt-x", w.branch)
|
||||||
|
assertEquals(3, w.dirtyCount)
|
||||||
|
assertEquals(2, w.sync?.ahead)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package wang.yaojia.webterm.api.models
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prefs round-trip correctness trap: `PUT /prefs` replaces the WHOLE blob, so decode → mutate
|
||||||
|
* one known key → encode MUST carry every unknown top-level key through verbatim (including exact
|
||||||
|
* integer formatting), or an Android write clobbers web/iOS/future-server prefs.
|
||||||
|
*/
|
||||||
|
class UiPrefsTest {
|
||||||
|
@Test
|
||||||
|
fun sanitizesFavouritesAndCollapsedLikeTheWebClient() {
|
||||||
|
val prefs = UiPrefs.decode(
|
||||||
|
"""
|
||||||
|
{"favourites":["/a","/b","/a","",123],"collapsed":{"g1":true,"g2":false,"g3":"true","":true}}
|
||||||
|
""".trimIndent().toByteArray(),
|
||||||
|
)!!
|
||||||
|
|
||||||
|
// favourites: non-empty strings only, de-duplicated, order preserved; the number 123 dropped.
|
||||||
|
assertEquals(listOf("/a", "/b"), prefs.favourites)
|
||||||
|
// collapsed: only literal `true`; false, string "true", and the empty key are all dropped.
|
||||||
|
assertEquals(mapOf("g1" to true), prefs.collapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mutatingOneKeyPreservesUnknownKeysAndIntegerFormatting() {
|
||||||
|
val original = UiPrefs.decode(
|
||||||
|
"""
|
||||||
|
{"favourites":["/old"],"collapsed":{"g":true},"schemaVersion":7,"vendor":{"nested":42,"ratio":1.5}}
|
||||||
|
""".trimIndent().toByteArray(),
|
||||||
|
)!!
|
||||||
|
|
||||||
|
val mutated = original.withFavourites(listOf("/new"))
|
||||||
|
val encoded = mutated.encodeBody().decodeToString()
|
||||||
|
|
||||||
|
// Known key was replaced...
|
||||||
|
assertEquals(listOf("/new"), UiPrefs.decode(encoded.toByteArray())!!.favourites)
|
||||||
|
// ...collapsed (untouched known key) survived...
|
||||||
|
assertEquals(mapOf("g" to true), UiPrefs.decode(encoded.toByteArray())!!.collapsed)
|
||||||
|
// ...and every unknown key survived verbatim, integers still integers (42, not 42.0).
|
||||||
|
assertTrue(encoded.contains("\"schemaVersion\":7"), "unknown scalar key must round-trip: $encoded")
|
||||||
|
assertTrue(encoded.contains("\"nested\":42"), "nested integer must not become 42.0: $encoded")
|
||||||
|
assertTrue(encoded.contains("\"ratio\":1.5"), "nested double must round-trip: $encoded")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun withCollapsedReplacesOnlyThatKey() {
|
||||||
|
val original = UiPrefs.decode("""{"favourites":["/keep"],"extra":"x"}""".toByteArray())!!
|
||||||
|
val encoded = original.withCollapsed(mapOf("ns" to true)).encodeBody().decodeToString()
|
||||||
|
|
||||||
|
assertEquals(listOf("/keep"), UiPrefs.decode(encoded.toByteArray())!!.favourites)
|
||||||
|
assertEquals(mapOf("ns" to true), UiPrefs.decode(encoded.toByteArray())!!.collapsed)
|
||||||
|
assertTrue(encoded.contains("\"extra\":\"x\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createBuildsAFreshBlobWithBothKnownKeys() {
|
||||||
|
val encoded = UiPrefs.create(favourites = listOf("/a"), collapsed = mapOf("g" to true))
|
||||||
|
.encodeBody().decodeToString()
|
||||||
|
assertEquals("""{"favourites":["/a"],"collapsed":{"g":true}}""", encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nonObjectBodyDecodesToNull() {
|
||||||
|
assertNull(UiPrefs.decode("[]".toByteArray()))
|
||||||
|
assertNull(UiPrefs.decode("\"hi\"".toByteArray()))
|
||||||
|
assertNull(UiPrefs.decode("not json".toByteArray()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package wang.yaojia.webterm.api.pairing
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||||
|
import wang.yaojia.webterm.wire.AuthCookie
|
||||||
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B5 · `POST /auth` as the pairing-time validation probe (FROZEN contract, ios-completion §1.1).
|
||||||
|
*
|
||||||
|
* The four distinct server outcomes must stay distinguishable:
|
||||||
|
* | 204 **with** `Set-Cookie` | token correct → persist it |
|
||||||
|
* | 204 **without** `Set-Cookie` | the host has NO auth configured → do NOT persist, and do NOT treat as authenticated |
|
||||||
|
* | 401 | the token is wrong → UI says "令牌不正确" |
|
||||||
|
* | 429 | rate-limited (10/min/IP) → UI says "尝试过多" |
|
||||||
|
*
|
||||||
|
* Plus the request shape the server needs: JSON body `{"token":"<t>"}` and an `Accept` that does NOT
|
||||||
|
* contain `text/html` (an HTML-accepting request is treated as a browser form and answered with a 302
|
||||||
|
* instead of 204/401).
|
||||||
|
*/
|
||||||
|
class AuthProbeTest {
|
||||||
|
private companion object {
|
||||||
|
const val BASE = "http://192.168.1.5:3000"
|
||||||
|
const val TOKEN = "0123456789abcdefTOKEN"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE))
|
||||||
|
private val transport = FakeHttpTransport()
|
||||||
|
|
||||||
|
private fun queueAuth(status: Int, headers: Map<String, String> = emptyMap()) {
|
||||||
|
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/auth", status = status, headers = headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val setCookieHeader = mapOf(
|
||||||
|
"Set-Cookie" to "${AuthCookie.NAME}=$TOKEN; Path=/; Max-Age=2592000; HttpOnly; SameSite=Strict",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── the four frozen outcomes ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `204 with Set-Cookie means the token is correct`() = runTest {
|
||||||
|
queueAuth(204, setCookieHeader)
|
||||||
|
|
||||||
|
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, TOKEN))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `204 without Set-Cookie means the host has no auth configured`() = runTest {
|
||||||
|
queueAuth(204)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
AuthProbeResult.AuthDisabled,
|
||||||
|
probeAccessToken(endpoint, transport, TOKEN),
|
||||||
|
"204-no-cookie must NEVER be read as 'authenticated' (frozen §1.1)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `401 means the token is wrong`() = runTest {
|
||||||
|
queueAuth(401)
|
||||||
|
|
||||||
|
assertEquals(AuthProbeResult.InvalidToken, probeAccessToken(endpoint, transport, TOKEN))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `429 means rate-limited`() = runTest {
|
||||||
|
queueAuth(429)
|
||||||
|
|
||||||
|
assertEquals(AuthProbeResult.RateLimited, probeAccessToken(endpoint, transport, TOKEN))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `any other status is surfaced as Unexpected with the code`() = runTest {
|
||||||
|
queueAuth(302) // e.g. a proxy/login redirect — never silently treated as success
|
||||||
|
|
||||||
|
assertEquals(AuthProbeResult.Unexpected(302), probeAccessToken(endpoint, transport, TOKEN))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── request shape ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the probe posts the token as JSON with an Accept that excludes text-html`() = runTest {
|
||||||
|
queueAuth(204, setCookieHeader)
|
||||||
|
|
||||||
|
probeAccessToken(endpoint, transport, TOKEN)
|
||||||
|
|
||||||
|
val request = transport.recordedRequests.single()
|
||||||
|
assertEquals(HttpMethod.POST, request.method)
|
||||||
|
assertEquals("$BASE/auth", request.url)
|
||||||
|
assertEquals("""{"token":"$TOKEN"}""", request.body?.decodeToString())
|
||||||
|
assertEquals("application/json", request.headers["Content-Type"])
|
||||||
|
val accept = request.headers["Accept"].orEmpty()
|
||||||
|
assertTrue(accept.isNotEmpty(), "Accept must be explicit, not left to the HTTP stack")
|
||||||
|
assertFalse(accept.contains("text/html"), "text/html would make the server answer 302, not 204/401")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the token never appears in the URL`() = runTest {
|
||||||
|
queueAuth(401)
|
||||||
|
|
||||||
|
probeAccessToken(endpoint, transport, TOKEN)
|
||||||
|
|
||||||
|
assertFalse(
|
||||||
|
transport.recordedRequests.single().url.contains(TOKEN),
|
||||||
|
"a secret must never travel in a URL (query strings land in logs/history)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── boundary validation + transport failure ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a malformed token is rejected before any network IO`() = runTest {
|
||||||
|
assertEquals(AuthProbeResult.Malformed, probeAccessToken(endpoint, transport, "short"))
|
||||||
|
assertEquals(AuthProbeResult.Malformed, probeAccessToken(endpoint, transport, "has spaces in it here"))
|
||||||
|
|
||||||
|
assertTrue(transport.recordedRequests.isEmpty(), "a malformed token must not hit the network")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a whitespace-padded token is trimmed once and accepted`() = runTest {
|
||||||
|
queueAuth(204, setCookieHeader)
|
||||||
|
|
||||||
|
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, " $TOKEN\n"))
|
||||||
|
assertEquals("""{"token":"$TOKEN"}""", transport.recordedRequests.single().body?.decodeToString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a transport failure propagates so the caller can classify it`() = runTest {
|
||||||
|
transport.queueFailure(method = HttpMethod.POST, url = "$BASE/auth", error = IOException("refused"))
|
||||||
|
|
||||||
|
val thrown = runCatching { probeAccessToken(endpoint, transport, TOKEN) }.exceptionOrNull()
|
||||||
|
|
||||||
|
assertTrue(thrown is IOException, "network classification stays with PairingError.classify")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a Set-Cookie header is recognised case-insensitively`() = runTest {
|
||||||
|
queueAuth(204, mapOf("set-cookie" to "${AuthCookie.NAME}=$TOKEN; Path=/"))
|
||||||
|
|
||||||
|
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, TOKEN))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a Set-Cookie for some other cookie does not count as accepted`() = runTest {
|
||||||
|
queueAuth(204, mapOf("Set-Cookie" to "sessionid=abc; Path=/"))
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
AuthProbeResult.AuthDisabled,
|
||||||
|
probeAccessToken(endpoint, transport, TOKEN),
|
||||||
|
"only a webterm_auth cookie proves the token was accepted",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package wang.yaojia.webterm.api.pairing
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import wang.yaojia.webterm.wire.HostClassifier
|
||||||
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
|
import wang.yaojia.webterm.wire.HostNetworkTier
|
||||||
|
|
||||||
|
/** Ports the tier table of iOS `HostClassificationTests` (plan §5.4, fail-safe unknown→public). */
|
||||||
|
class HostClassifierTest {
|
||||||
|
@Test
|
||||||
|
fun loopbackHostsClassifyAsLoopback() {
|
||||||
|
val hosts = listOf("localhost", "LOCALHOST", "127.0.0.1", "127.5.9.200", "::1", "[::1]")
|
||||||
|
hosts.forEach { assertEquals(HostNetworkTier.LOOPBACK, HostClassifier.classify(it), it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rfc1918AndLinkLocalAndMdnsClassifyAsPrivateLan() {
|
||||||
|
val hosts = listOf(
|
||||||
|
"10.0.0.5", "10.255.255.255",
|
||||||
|
"172.16.0.1", "172.20.10.1", "172.31.255.255",
|
||||||
|
"192.168.0.9", "192.168.1.1",
|
||||||
|
"169.254.1.1",
|
||||||
|
"mac-mini.local", "MAC-MINI.LOCAL", "printer.local",
|
||||||
|
)
|
||||||
|
hosts.forEach { assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify(it), it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cgnatAndMagicDnsClassifyAsTailscale() {
|
||||||
|
val hosts = listOf(
|
||||||
|
"100.64.0.1", "100.100.1.1", "100.127.255.255",
|
||||||
|
"mac.tailnet.ts.net", "MAC.TAILNET.TS.NET", "host.ts.net",
|
||||||
|
)
|
||||||
|
hosts.forEach { assertEquals(HostNetworkTier.TAILSCALE, HostClassifier.classify(it), it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun everythingElseFailsSafeToPublic() {
|
||||||
|
val hosts = listOf(
|
||||||
|
// routable public IPs
|
||||||
|
"8.8.8.8", "203.0.113.7",
|
||||||
|
// hostnames
|
||||||
|
"example.com", "claude.ai",
|
||||||
|
// boundary-miss IPv4 (just outside the private/tailscale ranges)
|
||||||
|
"172.15.0.1", "172.32.0.1", "100.63.0.1", "100.128.0.1",
|
||||||
|
// malformed / out-of-range / wrong arity → fail-safe public
|
||||||
|
"256.1.1.1", "1.2.3", "999.999.999.999", "not a host", "",
|
||||||
|
// non-loopback IPv6 (ULA / documentation) → fail-safe public (iOS v1 scope)
|
||||||
|
"fd00::1", "2001:db8::1",
|
||||||
|
)
|
||||||
|
hosts.forEach { assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify(it), it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun classifyEndpointDelegatesToHost() {
|
||||||
|
val lan = requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000"))
|
||||||
|
val tailscale = requireNotNull(HostEndpoint.fromBaseUrl("https://mac.tailnet.ts.net"))
|
||||||
|
val public = requireNotNull(HostEndpoint.fromBaseUrl("https://example.com"))
|
||||||
|
|
||||||
|
assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify(lan))
|
||||||
|
assertEquals(HostNetworkTier.TAILSCALE, HostClassifier.classify(tailscale))
|
||||||
|
assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify(public))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package wang.yaojia.webterm.api.pairing
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertInstanceOf
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InterruptedIOException
|
||||||
|
import java.net.ConnectException
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
import java.net.UnknownServiceException
|
||||||
|
import java.security.cert.CertificateException
|
||||||
|
import javax.net.ssl.SSLException
|
||||||
|
import javax.net.ssl.SSLHandshakeException
|
||||||
|
|
||||||
|
/** Re-derives iOS `PairingError.classify` for JVM exceptions (plan R12: drop localNetworkDenied). */
|
||||||
|
class PairingErrorTest {
|
||||||
|
private val lan = requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun connectionRefusedMapsToHostUnreachable() {
|
||||||
|
val result = PairingError.classify(ConnectException("Connection refused"), lan)
|
||||||
|
assertInstanceOf(PairingError.HostUnreachable::class.java, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dnsFailureMapsToHostUnreachable() {
|
||||||
|
val result = PairingError.classify(UnknownHostException("no such host"), lan)
|
||||||
|
assertInstanceOf(PairingError.HostUnreachable::class.java, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun socketTimeoutMapsToTimeout() {
|
||||||
|
assertEquals(PairingError.Timeout, PairingError.classify(SocketTimeoutException("read timed out"), lan))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun interruptedIoMapsToTimeout() {
|
||||||
|
// OkHttp's overall call-timeout surfaces as a bare InterruptedIOException.
|
||||||
|
assertEquals(PairingError.Timeout, PairingError.classify(InterruptedIOException("timeout"), lan))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sslHandshakeFailureMapsToTlsFailure() {
|
||||||
|
assertEquals(PairingError.TlsFailure, PairingError.classify(SSLHandshakeException("handshake_failure"), lan))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun genericSslAndCertFailuresMapToTlsFailure() {
|
||||||
|
assertEquals(PairingError.TlsFailure, PairingError.classify(SSLException("tls"), lan))
|
||||||
|
assertEquals(PairingError.TlsFailure, PairingError.classify(CertificateException("bad cert"), lan))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cleartextBlockMapsToCleartextBlockedWithHost() {
|
||||||
|
val err = UnknownServiceException("CLEARTEXT communication to 192.168.1.5 not permitted")
|
||||||
|
val result = PairingError.classify(err, lan)
|
||||||
|
assertInstanceOf(PairingError.CleartextBlocked::class.java, result)
|
||||||
|
assertEquals("192.168.1.5", (result as PairingError.CleartextBlocked).host)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun wrappedCauseIsWalked() {
|
||||||
|
// OkHttp frequently wraps the real cause; the chain walk must see it.
|
||||||
|
val wrappedConnect = IOException("unexpected end of stream", ConnectException("refused"))
|
||||||
|
assertInstanceOf(PairingError.HostUnreachable::class.java, PairingError.classify(wrappedConnect, lan))
|
||||||
|
|
||||||
|
val wrappedTls = IOException("io", SSLHandshakeException("handshake"))
|
||||||
|
assertEquals(PairingError.TlsFailure, PairingError.classify(wrappedTls, lan))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unrecognizedErrorUsesFallbackWhenProvided() {
|
||||||
|
// Probe step ②: after ① passed, an unclassifiable connect failure is the Origin 401.
|
||||||
|
val fallback = PairingError.OriginRejected("hint")
|
||||||
|
assertEquals(fallback, PairingError.classify(IllegalStateException("weird"), lan, unrecognizedFallback = fallback))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unrecognizedErrorWithoutFallbackIsHostUnreachable() {
|
||||||
|
assertInstanceOf(
|
||||||
|
PairingError.HostUnreachable::class.java,
|
||||||
|
PairingError.classify(IllegalStateException("weird"), lan),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun causeCycleTerminates() {
|
||||||
|
// A→B→A cycle must not loop forever; B is a ConnectException so it classifies as reachable-failure.
|
||||||
|
val a = IOException("a")
|
||||||
|
val b = ConnectException("b")
|
||||||
|
a.initCause(b)
|
||||||
|
b.initCause(a)
|
||||||
|
assertInstanceOf(PairingError.HostUnreachable::class.java, PairingError.classify(a, lan))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun originRejectedHintDerivesFromOriginHeaderWithoutDefaultPort() {
|
||||||
|
val lanHint = PairingError.originRejectedHint(lan)
|
||||||
|
assertTrue(lanHint.contains("ALLOWED_ORIGINS=http://192.168.1.5:3000"))
|
||||||
|
|
||||||
|
val tls = requireNotNull(HostEndpoint.fromBaseUrl("https://mac.tailnet.ts.net"))
|
||||||
|
val tlsHint = PairingError.originRejectedHint(tls)
|
||||||
|
assertTrue(tlsHint.contains("ALLOWED_ORIGINS=https://mac.tailnet.ts.net"))
|
||||||
|
assertFalse(tlsHint.contains(":443"), "default https port must be omitted (no :443 迷信)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package wang.yaojia.webterm.api.pairing
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertInstanceOf
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||||
|
import wang.yaojia.webterm.testsupport.FakeTermTransport
|
||||||
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
|
import java.net.ConnectException
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
import java.net.UnknownServiceException
|
||||||
|
import javax.net.ssl.SSLHandshakeException
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ports iOS `PairingProbeTests`: the two-step probe (① GET reachability/shape, ② WS attach→adopt→
|
||||||
|
* kill-with-Origin), each failure mode mapped to a [PairingError], full-success leaves no orphan
|
||||||
|
* session, and the injected-deadline timeout — all under `runTest` virtual time (zero real waits).
|
||||||
|
*/
|
||||||
|
class PairingProbeTest {
|
||||||
|
private data class Fixture(
|
||||||
|
val endpoint: HostEndpoint,
|
||||||
|
val http: FakeHttpTransport,
|
||||||
|
val ws: FakeTermTransport,
|
||||||
|
val liveSessionsUrl: String,
|
||||||
|
val killUrl: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun fixture(base: String = BASE): Fixture {
|
||||||
|
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl(base))
|
||||||
|
return Fixture(
|
||||||
|
endpoint = endpoint,
|
||||||
|
http = FakeHttpTransport(),
|
||||||
|
ws = FakeTermTransport(),
|
||||||
|
liveSessionsUrl = "$base/live-sessions",
|
||||||
|
killUrl = "$base/live-sessions/$SESSION_ID",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-timeout cases use `timeout = null` so the probe never enters the race — deterministic. */
|
||||||
|
private suspend fun runProbe(f: Fixture, timeout: Duration? = null): PairingProbeResult =
|
||||||
|
runPairingProbeCore(f.endpoint, f.http, f.ws, timeout)
|
||||||
|
|
||||||
|
private fun failureError(result: PairingProbeResult): PairingError {
|
||||||
|
assertInstanceOf(PairingProbeResult.Failure::class.java, result)
|
||||||
|
return (result as PairingProbeResult.Failure).error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Probe ① failure branches ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stepOneConnectionRefusedMapsToHostUnreachableAndNeverTouchesWs() = runTest {
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueFailure(url = f.liveSessionsUrl, error = ConnectException("refused"))
|
||||||
|
|
||||||
|
val result = runProbe(f)
|
||||||
|
|
||||||
|
assertInstanceOf(PairingError.HostUnreachable::class.java, failureError(result))
|
||||||
|
assertTrue(f.ws.connectAttempts.isEmpty(), "probe must not touch WS when ① fails")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stepOneHtmlBodyMapsToNotWebTerminal() = runTest {
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "<html><body>router admin</body></html>".toByteArray())
|
||||||
|
|
||||||
|
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stepOne404MapsToNotWebTerminal() = runTest {
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, status = 404)
|
||||||
|
|
||||||
|
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stepOneNonArrayJsonMapsToNotWebTerminal() = runTest {
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "{\"ok\":true}".toByteArray())
|
||||||
|
|
||||||
|
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stepOneTlsFailureMapsToTlsFailure() = runTest {
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueFailure(url = f.liveSessionsUrl, error = SSLHandshakeException("handshake_failure"))
|
||||||
|
|
||||||
|
assertEquals(PairingError.TlsFailure, failureError(runProbe(f)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stepOneTransportTimeoutMapsToTimeout() = runTest {
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueFailure(url = f.liveSessionsUrl, error = SocketTimeoutException("read timed out"))
|
||||||
|
|
||||||
|
assertEquals(PairingError.Timeout, failureError(runProbe(f)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stepOneCleartextBlockMapsToCleartextBlockedWithHost() = runTest {
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueFailure(
|
||||||
|
url = f.liveSessionsUrl,
|
||||||
|
error = UnknownServiceException("CLEARTEXT communication to 192.168.1.5 not permitted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val error = failureError(runProbe(f))
|
||||||
|
assertInstanceOf(PairingError.CleartextBlocked::class.java, error)
|
||||||
|
assertEquals("192.168.1.5", (error as PairingError.CleartextBlocked).host)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stepOneDnsFailureMapsToHostUnreachable() = runTest {
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueFailure(url = f.liveSessionsUrl, error = UnknownHostException("nxdomain"))
|
||||||
|
|
||||||
|
assertInstanceOf(PairingError.HostUnreachable::class.java, failureError(runProbe(f)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Probe ② failure branches ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stepTwoUpgradeRejectionMapsToOriginRejectedWithActionableHint() = runTest {
|
||||||
|
// ① passes; ② upgrade fails (a 401 is a shapeless connect error at the transport layer).
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||||
|
f.ws.scriptConnectFailure()
|
||||||
|
|
||||||
|
val error = failureError(runProbe(f))
|
||||||
|
assertInstanceOf(PairingError.OriginRejected::class.java, error)
|
||||||
|
val hint = (error as PairingError.OriginRejected).hint
|
||||||
|
assertTrue(hint.contains("ALLOWED_ORIGINS=http://192.168.1.5:3000"), hint)
|
||||||
|
assertFalse(hint.contains(":443"), hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun originRejectedHintOmitsDefaultPortForHttps() = runTest {
|
||||||
|
val f = fixture(base = "https://mac.tailnet.ts.net")
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||||
|
f.ws.scriptConnectFailure()
|
||||||
|
|
||||||
|
val error = failureError(runProbe(f))
|
||||||
|
assertInstanceOf(PairingError.OriginRejected::class.java, error)
|
||||||
|
val hint = (error as PairingError.OriginRejected).hint
|
||||||
|
assertTrue(hint.contains("ALLOWED_ORIGINS=https://mac.tailnet.ts.net"), hint)
|
||||||
|
assertFalse(hint.contains(":443"), hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stepTwoStreamEndingBeforeAttachedMapsToNotWebTerminal() = runTest {
|
||||||
|
// A queued finish is flushed into the connection at connect → stream closes before `attached`.
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||||
|
f.ws.finishFrames()
|
||||||
|
|
||||||
|
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Full pass + kill round-trip ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fullProbeSuccessAttachesWithNullSessionIdThenKillsWithOrigin() = runTest {
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||||
|
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204)
|
||||||
|
f.ws.emit(ATTACHED_FRAME)
|
||||||
|
|
||||||
|
val result = runProbe(f)
|
||||||
|
|
||||||
|
// Success payload is the probed endpoint.
|
||||||
|
assertEquals(PairingProbeResult.Success(f.endpoint), result)
|
||||||
|
|
||||||
|
// attach(null) is the only WS frame, with an explicit JSON null sessionId key.
|
||||||
|
assertEquals(1, f.ws.sentFrames.size)
|
||||||
|
val attach = Json.parseToJsonElement(f.ws.sentFrames.single()).jsonObject
|
||||||
|
assertEquals("attach", attach["type"]?.jsonPrimitive?.content)
|
||||||
|
assertTrue(attach["sessionId"] is JsonNull, "sessionId key must be present and JSON null")
|
||||||
|
|
||||||
|
// Exactly two HTTP requests: RO GET (NO Origin) then guarded DELETE (byte-equal Origin).
|
||||||
|
val requests = f.http.recordedRequests
|
||||||
|
assertEquals(2, requests.size)
|
||||||
|
assertFalse(requests.first().headers.containsKey("Origin"), "RO GET must not carry Origin")
|
||||||
|
val kill = requests.last()
|
||||||
|
assertEquals(HttpMethod.DELETE, kill.method)
|
||||||
|
assertEquals(f.killUrl, kill.url)
|
||||||
|
assertEquals(f.endpoint.originHeader, kill.headers["Origin"])
|
||||||
|
|
||||||
|
// The probe never holds the connection.
|
||||||
|
assertEquals(1, f.ws.closeCallCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun publicWrapperHappyPathReturnsEndpoint() = runTest {
|
||||||
|
// The public entry uses the default Tunables deadline; immediate fakes never trip it.
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||||
|
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204)
|
||||||
|
f.ws.emit(ATTACHED_FRAME)
|
||||||
|
|
||||||
|
val result = runPairingProbe(f.endpoint, f.http, f.ws)
|
||||||
|
|
||||||
|
assertEquals(PairingProbeResult.Success(f.endpoint), result)
|
||||||
|
assertEquals(1, f.ws.closeCallCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun outputFrameBeforeAttachedIsSkippedAndProbeSucceeds() = runTest {
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||||
|
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204)
|
||||||
|
f.ws.emit("""{"type":"output","data":"[0mreplay"}""")
|
||||||
|
f.ws.emit(ATTACHED_FRAME)
|
||||||
|
|
||||||
|
assertEquals(PairingProbeResult.Success(f.endpoint), runProbe(f))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun killRejectedByOriginGuardMapsToOriginRejected() = runTest {
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||||
|
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 403)
|
||||||
|
f.ws.emit(ATTACHED_FRAME)
|
||||||
|
|
||||||
|
assertInstanceOf(PairingError.OriginRejected::class.java, failureError(runProbe(f)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun killReturning404StillCountsAsSuccess() = runTest {
|
||||||
|
// Session exited between attach and kill — the goal state (no orphan) is already reached.
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||||
|
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 404)
|
||||||
|
f.ws.emit(ATTACHED_FRAME)
|
||||||
|
|
||||||
|
assertEquals(PairingProbeResult.Success(f.endpoint), runProbe(f))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Timeout (virtual time, zero real waits) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun probeTimesOutWhenServerNeverSendsAttached() = runTest {
|
||||||
|
// ① passes; ② connects but the server never replies `attached` → the probe hangs on the
|
||||||
|
// frame stream until the injected deadline cancels it.
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||||
|
|
||||||
|
val result = runProbe(f, timeout = 10.seconds)
|
||||||
|
|
||||||
|
assertEquals(PairingProbeResult.Failure(PairingError.Timeout), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun timeoutPathAlwaysClosesTheConnectionExactlyOnce() = runTest {
|
||||||
|
// ① passes; ② connects but the server never sends `attached` → the probe hangs on the frame
|
||||||
|
// stream until the injected deadline cancels it MID-adopt. The connection must still close
|
||||||
|
// (try/finally + NonCancellable), or the probe leaks the WS + its orphan session.
|
||||||
|
val f = fixture()
|
||||||
|
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
|
||||||
|
|
||||||
|
val result = runProbe(f, timeout = 10.seconds)
|
||||||
|
|
||||||
|
assertEquals(PairingProbeResult.Failure(PairingError.Timeout), result)
|
||||||
|
assertEquals(1, f.ws.closeCallCount, "the probe must close the WS even on the timeout path")
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val BASE = "http://192.168.1.5:3000"
|
||||||
|
const val SESSION_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
|
||||||
|
const val ATTACHED_FRAME = """{"type":"attached","sessionId":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package wang.yaojia.webterm.api.pairing
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||||
|
import wang.yaojia.webterm.testsupport.FakeTermTransport
|
||||||
|
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||||
|
import wang.yaojia.webterm.wire.AuthCookie
|
||||||
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B5 · the two-step pairing probe under an access-token-protected host:
|
||||||
|
* - both HTTP legs (`GET /live-sessions` and the guarded `DELETE /live-sessions/:id`) carry the
|
||||||
|
* hand-written `Cookie: webterm_auth=<t>`;
|
||||||
|
* - a **401** on the reachability leg is [PairingError.AccessTokenRequired] — NOT
|
||||||
|
* "端口对吗?" ([PairingError.HttpOkButNotWebTerminal]) and NOT an Origin problem, so the UI can ask
|
||||||
|
* for the token instead of sending the user on a wild goose chase;
|
||||||
|
* - with no token configured nothing changes (no `Cookie` header at all).
|
||||||
|
*/
|
||||||
|
class PairingProbeTokenTest {
|
||||||
|
private companion object {
|
||||||
|
const val BASE = "http://192.168.1.5:3000"
|
||||||
|
const val TOKEN = "0123456789abcdefTOKEN"
|
||||||
|
const val SERVER_ID = "11111111-1111-4111-8111-111111111111"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE))
|
||||||
|
private val http = FakeHttpTransport()
|
||||||
|
private val ws = FakeTermTransport()
|
||||||
|
|
||||||
|
private fun scriptHappyPath() {
|
||||||
|
http.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
|
||||||
|
http.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$SERVER_ID", status = 204)
|
||||||
|
ws.emit("""{"type":"attached","sessionId":"$SERVER_ID"}""")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun probe(tokens: AccessTokenSource): PairingProbeResult =
|
||||||
|
runPairingProbeCore(endpoint, http, ws, timeout = null, tokens = tokens)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `both HTTP legs of the probe carry the auth cookie`() = runTest {
|
||||||
|
scriptHappyPath()
|
||||||
|
|
||||||
|
val result = probe(AccessTokenSource { TOKEN })
|
||||||
|
|
||||||
|
assertEquals(PairingProbeResult.Success(endpoint), result)
|
||||||
|
assertEquals(2, http.recordedRequests.size, "reachability GET + guarded kill DELETE")
|
||||||
|
http.recordedRequests.forEach { request ->
|
||||||
|
assertEquals(
|
||||||
|
"${AuthCookie.NAME}=$TOKEN",
|
||||||
|
request.headers[AuthCookie.HEADER_NAME],
|
||||||
|
"every probe request must present the token",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `with no token the probe requests are byte-identical to before the feature`() = runTest {
|
||||||
|
scriptHappyPath()
|
||||||
|
|
||||||
|
probe(AccessTokenSource.NONE)
|
||||||
|
|
||||||
|
http.recordedRequests.forEach { request ->
|
||||||
|
assertFalse(request.headers.containsKey(AuthCookie.HEADER_NAME))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a 401 on the reachability leg asks for an access token`() = runTest {
|
||||||
|
http.queueSuccess(url = "$BASE/live-sessions", status = 401, body = """{"error":"authentication required"}""".toByteArray())
|
||||||
|
|
||||||
|
val result = probe(AccessTokenSource.NONE)
|
||||||
|
|
||||||
|
assertEquals(PairingProbeResult.Failure(PairingError.AccessTokenRequired), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a 401 on the guarded kill leg also asks for an access token`() = runTest {
|
||||||
|
http.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
|
||||||
|
http.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$SERVER_ID", status = 401)
|
||||||
|
ws.emit("""{"type":"attached","sessionId":"$SERVER_ID"}""")
|
||||||
|
|
||||||
|
val result = probe(AccessTokenSource.NONE)
|
||||||
|
|
||||||
|
assertEquals(PairingProbeResult.Failure(PairingError.AccessTokenRequired), result)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user