diff --git a/.github/scripts/publish-release.mjs b/.github/scripts/publish-release.mjs index 47afcc7b..782b5c98 100644 --- a/.github/scripts/publish-release.mjs +++ b/.github/scripts/publish-release.mjs @@ -16,14 +16,15 @@ // release a draft and fails the run. A draft is one click away from being // published by hand, which is the recoverable direction to fail in. // -// node .github/scripts/publish-release.mjs +// node .github/scripts/publish-release.mjs [--prepare-only] +// prepare-only runs the same completeness and signature gates and stages the +// stable legacy manifest, leaving publication and both live channels untouched. // // Authenticates through gh via GITHUB_TOKEN and reads the repository from // GITHUB_REPOSITORY. The Android APK is deliberately not in the expected set: // it is built by a separate workflow (.github/workflows/android.yml) on its own // schedule, and that workflow answers for itself when it cannot produce one. -import { execFileSync } from "node:child_process"; import { fileURLToPath, pathToFileURL } from "node:url"; function escapeRegExp(s) { @@ -80,70 +81,31 @@ export function missingAssets(expected, attached) { ); } -/** The release for `tag`, looked up in a way that also finds it while a draft. */ -function fetchRelease(repo, tag) { - // gh falls back to a list-and-match when the by-tag endpoint 404s, which is - // what it does for a draft: GitHub only exposes drafts by id. - const out = execFileSync( - "gh", - ["release", "view", tag, "--repo", repo, "--json", "isDraft,isPrerelease,assets"], - { encoding: "utf8" }, - ); - return JSON.parse(out); +export function parsePublishArgs(args) { + const [tag, mode] = args; + if (!tag?.startsWith('v') || args.length > 2 || (mode !== undefined && mode !== '--prepare-only')) { + throw new Error('usage: publish-release.mjs [--prepare-only]'); + } + return { tag, prepareOnly: mode === '--prepare-only' }; } -function main() { - const [tag] = process.argv.slice(2); - if (!tag) { - console.error("usage: node .github/scripts/publish-release.mjs "); - process.exit(1); - } +async function main() { + const { tag, prepareOnly } = parsePublishArgs(process.argv.slice(2)); const repo = process.env.GITHUB_REPOSITORY; - if (!repo) { - console.error("publish-release: GITHUB_REPOSITORY is not set"); - process.exit(1); - } - - const version = tag.replace(/^v/, ""); - // Same rule the build jobs resolve the channel with: a SemVer prerelease - // suffix marks the release prerelease. - const prerelease = tag.includes("-"); - - const release = fetchRelease(repo, tag); - const attached = release.assets.map((a) => a.name).sort(); - const missing = missingAssets(expectedAssets({ version, prerelease }), attached); - - if (missing.length > 0) { - console.error( - `publish-release: ${tag} is missing ${missing.length} expected asset(s); leaving it a draft`, - ); - for (const asset of missing) { - console.error(` missing: ${asset.label} (${asset.want})`); - } - console.error(` attached: ${attached.join(", ") || "(nothing)"}`); - process.exit(1); - } - - if (!release.isDraft) { - // A re-run of a release that already went out: the set is complete, so - // there is nothing to publish and nothing to complain about. - console.log( - `publish-release: ${tag} is already published, with all ${attached.length} expected assets`, - ); + if (!tag || !repo) throw new Error('usage: GITHUB_REPOSITORY=owner/repo node .github/scripts/publish-release.mjs '); + const { publishCompleteRelease } = await import('../../scripts/release-lifecycle.mjs'); + const { githubReleaseApi } = await import('../../scripts/release-api.mjs'); + const result = await publishCompleteRelease({ tag, repo, api: githubReleaseApi(repo, tag), prepareOnly }); + if (result.prepared) { + console.log(`publish-release: ${tag} verified and held as a draft; native acceptance required before publication`); return; } - - execFileSync("gh", ["release", "edit", tag, "--repo", repo, "--draft=false"], { - stdio: "inherit", - }); - console.log( - `publish-release: ${tag} published with ${attached.length} assets: ${attached.join(", ")}`, - ); + console.log(`publish-release: ${tag} complete and public; beta ${result.switched ? 'updated atomically' : 'already at this or a newer version'}`); } // Run only when invoked as a script, so the pure helpers can be unit-tested. if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main(); + main().catch(error => { console.error(error.message); process.exitCode = 1; }); } // Referenced by the test runner without triggering main(). diff --git a/.github/scripts/publish-release.test.mjs b/.github/scripts/publish-release.test.mjs index 4f5322de..d45ac245 100644 --- a/.github/scripts/publish-release.test.mjs +++ b/.github/scripts/publish-release.test.mjs @@ -6,7 +6,15 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { expectedAssets, missingAssets } from "./publish-release.mjs"; +import { expectedAssets, missingAssets, parsePublishArgs } from "./publish-release.mjs"; + +test('publication arguments require the explicit prepare-only flag and reject typos', () => { + assert.deepEqual(parsePublishArgs(['v0.6.0']), { tag: 'v0.6.0', prepareOnly: false }); + assert.deepEqual(parsePublishArgs(['v0.6.0', '--prepare-only']), { tag: 'v0.6.0', prepareOnly: true }); + for (const args of [[], ['--prepare-only'], ['v0.6.0', '--prepare'], ['v0.6.0', '--prepare-only', '--extra']]) { + assert.throws(() => parsePublishArgs(args), /usage:/); + } +}); /** The eleven files v0.5.0 actually shipped with — the Arch package missing. */ const v050Assets = [ diff --git a/.github/scripts/workflows.test.mjs b/.github/scripts/workflows.test.mjs index 5f7b5194..fd7d6987 100644 --- a/.github/scripts/workflows.test.mjs +++ b/.github/scripts/workflows.test.mjs @@ -103,6 +103,33 @@ function stepNamed(text, name) { return found[0]; } +test("only the final release job can publish either updater channel", () => { + const releaseJobs = jobs(workflow("release.yml")); + const publishers = [...releaseJobs].filter(([, body]) => steps(body).some(s => /\bnode\s+\.github\/scripts\/publish-release\.mjs\b/.test(s))); + assert.deepEqual(publishers.map(([name]) => name), ["publish"]); + const required = /needs:\s*\[([^\]]+)\]/.exec(releaseJobs.get("publish"))?.[1].split(',').map(s => s.trim()); + assert.deepEqual(new Set(required), new Set(['windows', 'macos', 'linux', 'arch-package'])); + for (const [name, body] of releaseJobs) { + if (name === 'publish') continue; + for (const step of steps(body)) assert.doesNotMatch(step, /\bnode\s+(?:\.github\/)?scripts\/publish-(?:beta-manifest|release)\.mjs\b/, name); + } +}); + +test("every Go setup resolves one exact committed patch", () => { + const expected = readFileSync(new URL('../../.go-version', import.meta.url), 'utf8').trim(); + assert.match(expected, /^\d+\.\d+\.\d+$/); + let checked = 0; + for (const { name, text } of allWorkflows()) { + for (const step of steps(text).filter(s => /uses: actions\/setup-go@/.test(s))) { + const file = /go-version-file:\s*['"]?([^'"\s]+)/.exec(step)?.[1]; + assert.equal(file, '.go-version', name); + assert.doesNotMatch(step, /go-version:/, name); + checked++; + } + } + assert.ok(checked >= 3); +}); + test("the Arch attach step names the repository instead of asking git", () => { // The build step chowns the checkout to `builder` so makepkg can run, and this // step runs as root: gh's own repository resolution shells out to git, git @@ -136,6 +163,37 @@ test("no tauri job publishes the release before the assets are complete", () => } }); +test('Android can only upload its signed APK to an existing release', () => { + const attach = stepNamed(workflow('android.yml'), 'Attach the APK to the GitHub release'); + assert.match(attach, /APK_PATH: \$\{\{ steps\.sign\.outputs\.apk \}\}/); + assert.match(attach, /node scripts\/attach-android-release\.mjs "\$GITHUB_REF_NAME" "\$APK_PATH"/); + assert.match(attach, /timeout-minutes: 33/); + assert.doesNotMatch(attach, /softprops|draft:|releaseDraft:|prerelease:|release (?:create|edit)/); +}); + +test('release hold runs the same final gate in explicit prepare-only mode', () => { + const publish = jobs(workflow('release.yml')).get('publish'); + assert.match(publish, /TENEBRA_RELEASE_HOLD: \$\{\{ vars\.TENEBRA_RELEASE_HOLD \}\}/); + assert.match(publish, /if \[ "\$TENEBRA_RELEASE_HOLD" = "true" \]; then/); + assert.match(publish, /node \.github\/scripts\/publish-release\.mjs "\$GITHUB_REF_NAME" --prepare-only/); + assert.match(publish, /else\s+node \.github\/scripts\/publish-release\.mjs "\$GITHUB_REF_NAME"\s+fi/); + assert.doesNotMatch(publish, /^ {4}if:/m, 'hold must not skip asset verification'); +}); + +test('explicit Android release hold stops both tag jobs and reports the missing APK without affecting debug', () => { + const android = jobs(workflow('android.yml')); + for (const job of ['release-gate', 'release']) { + assert.match(android.get(job), /if: \$\{\{ startsWith\(github\.ref, 'refs\/tags\/'\) && vars\.TENEBRA_ANDROID_RELEASE_HOLD != 'true' \}\}/); + } + const held = android.get('release-held'); + assert.ok(held, 'an intentionally held release must be visible in the run'); + assert.match(held, /if: \$\{\{ startsWith\(github\.ref, 'refs\/tags\/'\) && vars\.TENEBRA_ANDROID_RELEASE_HOLD == 'true' \}\}/); + assert.match(held, /GITHUB_STEP_SUMMARY/); + assert.match(held, /No APK was built, signed, or attached/); + assert.doesNotMatch(held, /secrets\.|contents: write|uses:|release upload|publish-release/); + assert.doesNotMatch(android.get('debug'), /TENEBRA_ANDROID_RELEASE_HOLD/); +}); + test("a final job publishes the draft only after every build job", () => { const release = jobs(workflow("release.yml")); const publish = release.get("publish"); diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 90b0da0a..8e78d0ed 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -36,6 +36,9 @@ concurrency: permissions: contents: read +env: + GOTOOLCHAIN: local + jobs: debug: # Every Android-relevant push/PR (path-filtered above). Tag pushes skip this @@ -48,7 +51,7 @@ jobs: with: # >= the sing-box v1.13.14 floor (go 1.24.7) and the same version the # rest of CI pins, so every job builds the Go core with one toolchain. - go-version: '1.26' + go-version-file: '.go-version' cache: false - uses: actions/setup-java@v4 with: @@ -56,6 +59,10 @@ jobs: java-version: '17' # The SDK + build-tools Gradle needs to assemble the APK. - uses: android-actions/setup-android@v4 + with: + # Gradle installs the required platform/build-tools. Avoid the obsolete + # SDK Tools archive pulled in by the action's default "tools" package. + packages: platform-tools # The NDK gomobile needs to bind BOTH .aars. Pinned to r28 (what the # sing-box porting notes target); the generic SDK setup does not pin an NDK. - uses: nttld/setup-ndk@v1 @@ -117,6 +124,22 @@ jobs: path: ui-android/app/build/outputs/apk/debug/*.apk if-no-files-found: error + release-held: + # A desktop-only release may deliberately omit Android. The explicit hold + # does not apply to debug builds and is reported instead of a fake APK pass. + if: ${{ startsWith(github.ref, 'refs/tags/') && vars.TENEBRA_ANDROID_RELEASE_HOLD == 'true' }} + runs-on: ubuntu-latest + steps: + - name: Report the Android release hold + run: | + set -eu + echo "::notice::Android release is intentionally held for $GITHUB_REF_NAME." + { + echo "## Android release intentionally held: $GITHUB_REF_NAME" + echo "TENEBRA_ANDROID_RELEASE_HOLD=true. No APK was built, signed, or attached." + echo "Desktop release validation continues separately; Android debug builds are unaffected." + } >> "$GITHUB_STEP_SUMMARY" + release-gate: # A v* tag drives the desktop release too, and Android can only join it if # the release signing secrets exist. This job used to resolve them into a @@ -127,7 +150,7 @@ jobs: # signed is a failed Android release, so say so out loud. This is its own # workflow run — a red job here does not touch the desktop release — and the # day the secrets are added it arms itself. - if: ${{ startsWith(github.ref, 'refs/tags/') }} + if: ${{ startsWith(github.ref, 'refs/tags/') && vars.TENEBRA_ANDROID_RELEASE_HOLD != 'true' }} runs-on: ubuntu-latest steps: - name: Require the release signing key @@ -153,21 +176,26 @@ jobs: # release. The desktop release.yml publishes to this same tag's release; this # job only adds the APK asset and never rewrites the release body. needs: release-gate - if: ${{ startsWith(github.ref, 'refs/tags/') }} + if: ${{ startsWith(github.ref, 'refs/tags/') && vars.TENEBRA_ANDROID_RELEASE_HOLD != 'true' }} runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 - uses: actions/setup-go@v6 with: - go-version: '1.26' + go-version-file: '.go-version' cache: false - uses: actions/setup-java@v4 with: distribution: temurin java-version: '17' - uses: android-actions/setup-android@v4 + with: + packages: platform-tools - uses: nttld/setup-ndk@v1 id: ndk with: @@ -233,9 +261,10 @@ jobs: "${build_tools}apksigner" verify --verbose "$signed" echo "apk=$signed" >> "$GITHUB_OUTPUT" - name: Attach the APK to the GitHub release - uses: softprops/action-gh-release@v2 - with: - files: ${{ steps.sign.outputs.apk }} - fail_on_unmatched_files: true + # Wait at most 30 minutes for desktop to create the release, then only + # upload an asset. No metadata update can race desktop publication. + timeout-minutes: 33 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + APK_PATH: ${{ steps.sign.outputs.apk }} + run: node scripts/attach-android-release.mjs "$GITHUB_REF_NAME" "$APK_PATH" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d69ee17..dc1c3413 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: workflow_call: +env: + GOTOOLCHAIN: local + jobs: workflows: # The pipeline's own checks. Every other job here tests the product; this one @@ -24,14 +27,14 @@ jobs: # Quoted so node expands the pattern itself: its test-file discovery # walks past directories whose name begins with a dot, so handing it # .github/scripts as a directory finds nothing. - run: node --test ".github/scripts/*.test.mjs" + run: node --test ".github/scripts/*.test.mjs" "scripts/*.test.mjs" core: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: - go-version: '1.26' + go-version-file: '.go-version' cache: false - run: go vet ./... # gofmt reports rather than rewrites here, and the diff is printed: a @@ -80,7 +83,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: - go-version: '1.26' + go-version-file: '.go-version' cache: false - uses: actions/setup-node@v6 with: @@ -88,6 +91,8 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy + - name: Check uninstall trust policy without native side effects + run: powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts/test-uninstall-policy.ps1 - name: Check Rust formatting working-directory: ui-desktop/src-tauri run: cargo fmt --check @@ -133,7 +138,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: - go-version: '1.26' + go-version-file: '.go-version' cache: false - uses: actions/setup-node@v6 with: @@ -212,15 +217,34 @@ jobs: # files are gated on `//go:build darwin`, so this is the only job that # exercises them. The hosted macOS runners are all arm64. runs-on: macos-14 + timeout-minutes: 25 steps: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: - go-version: '1.26' + go-version-file: '.go-version' cache: false - run: go vet ./... - run: go build ./... - - run: go test ./... -race -count=1 + - name: Run Go race tests with retained diagnostics + id: race + timeout-minutes: 12 + shell: bash + # A package timeout emits goroutine stacks; JSON streams the last active + # test even if the outer step limit is reached during build or execution. + # pipefail keeps a failing test red when tee successfully saves its log. + run: | + set -o pipefail + go test ./... -race -count=1 -timeout=4m -json 2>&1 | tee "$RUNNER_TEMP/tenebra-macos-go-race.log" + - name: Retain macOS Go test progress and failure stacks + if: ${{ always() && steps.race.outcome != 'skipped' }} + timeout-minutes: 2 + uses: actions/upload-artifact@v6 + with: + name: tenebra-macos-go-race-log + path: ${{ runner.temp }}/tenebra-macos-go-race.log + retention-days: 7 + if-no-files-found: error # TODO(macos): add the Tauri universal-DMG bundle build here once the # darwin resource fetch (scripts/fetch-resources.sh: sing-box darwin + # lipo) and the externalBin/notarization caveat are resolved. See @@ -241,7 +265,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: - go-version: '1.26' + go-version-file: '.go-version' cache: false - run: go vet ./... - run: go build ./... diff --git a/.github/workflows/desktop-candidate.yml b/.github/workflows/desktop-candidate.yml new file mode 100644 index 00000000..85ebbd65 --- /dev/null +++ b/.github/workflows/desktop-candidate.yml @@ -0,0 +1,273 @@ +name: Signed desktop candidate + +on: + workflow_dispatch: + inputs: + mode: + description: 'Prepare immutable signed files, or promote already accepted bytes' + required: true + type: choice + options: [prepare, promote] + source_sha: + description: 'Full current main commit SHA; must equal this dispatch checkout' + required: true + type: string + acceptance_json: + description: 'Promote only: root-reviewed native acceptance JSON with run/artifact/file SHA pins' + required: false + type: string + +permissions: + contents: read + +# Share the release lock with the historical tag workflow. Preparing never +# creates a tag/release or touches installed clients. +concurrency: + group: tenebra-release + cancel-in-progress: false + +env: + GOTOOLCHAIN: local + SOURCE_SHA: ${{ inputs.source_sha }} + +jobs: + identity: + if: github.repository == 'Divaaaan/tenebra' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: 24 + - name: Require the exact main checkout and version agreement + run: | + node scripts/candidate-files.mjs identity + node scripts/set-version.mjs --check + ci: + if: inputs.mode == 'prepare' + needs: identity + uses: ./.github/workflows/ci.yml + windows: + needs: [identity, ci] + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-go@v6 + with: + go-version-file: '.go-version' + cache: false + - uses: actions/setup-node@v6 + with: + node-version: 24 + - uses: dtolnay/rust-toolchain@stable + - name: Fetch pinned resources + run: powershell -NoProfile -ExecutionPolicy Bypass -File scripts/fetch-resources.ps1 + - name: Build and record the exact core + run: | + go build -o ui-desktop/src-tauri/binaries/tenebra-core-x86_64-pc-windows-msvc.exe ./cmd/tenebra-core + node scripts/verify-core-build.mjs ui-desktop/src-tauri/binaries/tenebra-core-x86_64-pc-windows-msvc.exe windows amd64 core-buildinfo-windows.json + - name: Install locked frontend dependencies + working-directory: ui-desktop + run: npm ci + - name: Build signed installer without creating a release + working-directory: ui-desktop + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: npm run tauri build -- --bundles nsis -- --locked + - name: Stage signed Windows files + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: node scripts/candidate-files.mjs collect windows + - uses: actions/upload-artifact@v6 + with: + name: candidate-part-windows-${{ github.run_attempt }} + path: candidate-part/* + if-no-files-found: error + retention-days: 30 + macos: + needs: [identity, ci] + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-go@v6 + with: + go-version-file: '.go-version' + cache: false + - uses: actions/setup-node@v6 + with: + node-version: 24 + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-apple-darwin + - name: Fetch pinned resources + run: bash scripts/fetch-resources.sh + - name: Build both core slices and record provenance + run: | + set -euo pipefail + bins=ui-desktop/src-tauri/binaries + mkdir -p "$bins" + GOOS=darwin GOARCH=arm64 go build -o "$bins/tenebra-core-aarch64-apple-darwin" ./cmd/tenebra-core + GOOS=darwin GOARCH=amd64 go build -o "$bins/tenebra-core-x86_64-apple-darwin" ./cmd/tenebra-core + node scripts/verify-core-build.mjs "$bins/tenebra-core-aarch64-apple-darwin" darwin arm64 core-buildinfo-macos-arm64.json + node scripts/verify-core-build.mjs "$bins/tenebra-core-x86_64-apple-darwin" darwin amd64 core-buildinfo-macos-amd64.json + lipo -create "$bins/tenebra-core-aarch64-apple-darwin" "$bins/tenebra-core-x86_64-apple-darwin" -output "$bins/tenebra-core-universal-apple-darwin" + - name: Install locked frontend dependencies + working-directory: ui-desktop + run: npm ci + - name: Build signed macOS updater without creating a release + working-directory: ui-desktop + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: npm run tauri build -- --target universal-apple-darwin --bundles app,dmg -- --locked + - name: Stage signed macOS files + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: node scripts/candidate-files.mjs collect macos + - uses: actions/upload-artifact@v6 + with: + name: candidate-part-macos-${{ github.run_attempt }} + path: candidate-part/* + if-no-files-found: error + retention-days: 30 + linux: + needs: [identity, ci] + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-go@v6 + with: + go-version-file: '.go-version' + cache: false + - uses: actions/setup-node@v6 + with: + node-version: 24 + - uses: dtolnay/rust-toolchain@stable + - name: Install bundle dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf file + - name: Fetch pinned resources + run: bash scripts/fetch-resources.sh --arch amd64 + - name: Build and record the exact core + run: | + go build -o ui-desktop/src-tauri/binaries/tenebra-core-x86_64-unknown-linux-gnu ./cmd/tenebra-core + node scripts/verify-core-build.mjs ui-desktop/src-tauri/binaries/tenebra-core-x86_64-unknown-linux-gnu linux amd64 core-buildinfo-linux.json + - name: Install locked frontend dependencies + working-directory: ui-desktop + run: npm ci + - name: Build signed Linux updater without creating a release + working-directory: ui-desktop + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: npm run tauri build -- --bundles deb,appimage -- --locked + - name: Stage signed Linux files + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: node scripts/candidate-files.mjs collect linux + - uses: actions/upload-artifact@v6 + with: + name: candidate-part-linux-${{ github.run_attempt }} + path: candidate-part/* + if-no-files-found: error + retention-days: 30 + arch: + needs: [identity, ci] + runs-on: ubuntu-latest + container: archlinux:base-devel + steps: + - name: Install packaging tools + run: pacman -Syu --noconfirm --needed git nodejs npm + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Build from the immutable commit without a stable tag + env: + TENEBRA_SOURCE_COMMIT: ${{ inputs.source_sha }} + run: | + set -eu + useradd -m builder + echo 'builder ALL=(ALL) NOPASSWD: /usr/bin/pacman' > /etc/sudoers.d/builder + chown -R builder . + su builder -c 'cd packaging/arch && makepkg --syncdeps --noconfirm --needed' + su builder -c 'cd ui-desktop && npm ci' + - name: Stage signed Arch files + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: su builder -c 'node scripts/candidate-files.mjs collect arch' + - uses: actions/upload-artifact@v6 + with: + name: candidate-part-arch-${{ github.run_attempt }} + path: candidate-part/* + if-no-files-found: error + retention-days: 30 + assemble: + needs: [windows, macos, linux, arch] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: 24 + - uses: actions/download-artifact@v5 + with: + pattern: candidate-part-*-${{ github.run_attempt }} + merge-multiple: true + path: candidate-input + - name: Cryptographically verify every file and bind source provenance + run: node scripts/candidate-files.mjs manifest candidate-input candidate-output + - uses: actions/upload-artifact@v6 + id: candidate + with: + name: tenebra-signed-desktop-${{ github.run_id }}-${{ github.run_attempt }} + path: candidate-output/* + if-no-files-found: error + retention-days: 30 + - name: Record acquisition pins + env: + ARTIFACT_ID: ${{ steps.candidate.outputs.artifact-id }} + ARTIFACT_SHA256: ${{ steps.candidate.outputs.artifact-digest }} + run: | + echo "Signed candidate artifact ID: $ARTIFACT_ID" >> "$GITHUB_STEP_SUMMARY" + echo "Archive SHA256: $ARTIFACT_SHA256" >> "$GITHUB_STEP_SUMMARY" + echo 'No tag, draft, public release or updater channel was created.' >> "$GITHUB_STEP_SUMMARY" + promote: + if: inputs.mode == 'promote' + needs: identity + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + actions: read + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: 24 + - name: Install archive reader + run: sudo apt-get install -y --no-install-recommends unzip + - name: Promote only the accepted signed bytes + env: + # No signing key here. Using this token suppresses recursive tag + # builds and release-event workflows such as Winget. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ACCEPTANCE_JSON: ${{ inputs.acceptance_json }} + run: node scripts/candidate-github.mjs promote diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index acea638d..aff400d4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,15 @@ on: permissions: contents: write +# Channel commits use optimistic concurrency as well; serializing publish runs +# prevents older overlapping platform jobs from racing release visibility. +concurrency: + group: tenebra-release + cancel-in-progress: false + +env: + GOTOOLCHAIN: local + jobs: # Gate the release on the full CI suite so a tag can never point at an # untested commit: the signed build below only runs once these pass. @@ -29,7 +38,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: - go-version: '1.26' + go-version-file: '.go-version' cache: false - uses: actions/setup-node@v6 with: @@ -39,6 +48,13 @@ jobs: run: powershell -ExecutionPolicy Bypass -File scripts/fetch-resources.ps1 - name: Build core sidecar run: go build -o ui-desktop/src-tauri/binaries/tenebra-core-x86_64-pc-windows-msvc.exe ./cmd/tenebra-core + - name: Verify and retain Windows core build evidence + run: node scripts/verify-core-build.mjs ui-desktop/src-tauri/binaries/tenebra-core-x86_64-pc-windows-msvc.exe windows amd64 core-buildinfo-windows.json + - uses: actions/upload-artifact@v6 + with: + name: core-buildinfo-windows + path: core-buildinfo-windows.json + if-no-files-found: error - name: Install front-end dependencies working-directory: ui-desktop run: npm ci @@ -109,15 +125,6 @@ jobs: Updates are delivered in-app and verified against the project's minisign key before they install (on macOS the updater refreshes the app, not the hand-installed daemon). - - name: Publish the beta channel manifest - shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # tauri-action published latest.json on this release; mirror it to - # beta.json on whichever release backs /releases/latest/download/ so beta - # clients pick up this build (a prerelease, or a newer stable) while - # stable clients keep reading latest.json untouched. - run: node scripts/publish-beta-manifest.mjs "$GITHUB_REF_NAME" "${{ steps.channel.outputs.prerelease }}" macos: # Runs after the Windows job on purpose: both jobs upload assets to the same # tag release and tauri-action merges its platform entries into the release's @@ -130,7 +137,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: - go-version: '1.26' + go-version-file: '.go-version' cache: false - uses: actions/setup-node@v6 with: @@ -156,8 +163,15 @@ jobs: mkdir -p "$bins" GOOS=darwin GOARCH=arm64 go build -o "$bins/tenebra-core-aarch64-apple-darwin" ./cmd/tenebra-core GOOS=darwin GOARCH=amd64 go build -o "$bins/tenebra-core-x86_64-apple-darwin" ./cmd/tenebra-core + node scripts/verify-core-build.mjs "$bins/tenebra-core-aarch64-apple-darwin" darwin arm64 core-buildinfo-macos-arm64.json + node scripts/verify-core-build.mjs "$bins/tenebra-core-x86_64-apple-darwin" darwin amd64 core-buildinfo-macos-amd64.json lipo -create "$bins/tenebra-core-aarch64-apple-darwin" "$bins/tenebra-core-x86_64-apple-darwin" \ -output "$bins/tenebra-core-universal-apple-darwin" + - uses: actions/upload-artifact@v6 + with: + name: core-buildinfo-macos + path: core-buildinfo-macos-*.json + if-no-files-found: error - name: Install front-end dependencies working-directory: ui-desktop run: npm ci @@ -217,14 +231,6 @@ jobs: Updates are delivered in-app and verified against the project's minisign key before they install (on macOS the updater refreshes the app, not the hand-installed daemon). - - name: Publish the beta channel manifest - shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Re-run after the macOS assets land so beta.json mirrors the final - # latest.json carrying both platforms; the Windows job already published - # a Windows-only interim copy, which this overwrite supersedes. - run: node scripts/publish-beta-manifest.mjs "$GITHUB_REF_NAME" "${{ steps.channel.outputs.prerelease }}" linux: # Third in the chain for the same reason macOS is second: all three jobs # upload to one release and tauri-action merges its platform entries into @@ -240,7 +246,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: - go-version: '1.26' + go-version-file: '.go-version' cache: false - uses: actions/setup-node@v6 with: @@ -256,6 +262,13 @@ jobs: run: bash scripts/fetch-resources.sh --arch amd64 - name: Build core sidecar run: go build -o ui-desktop/src-tauri/binaries/tenebra-core-x86_64-unknown-linux-gnu ./cmd/tenebra-core + - name: Verify Linux core build evidence + run: node scripts/verify-core-build.mjs ui-desktop/src-tauri/binaries/tenebra-core-x86_64-unknown-linux-gnu linux amd64 core-buildinfo-linux.json + - uses: actions/upload-artifact@v6 + with: + name: core-buildinfo-linux + path: core-buildinfo-linux.json + if-no-files-found: error - name: Install front-end dependencies working-directory: ui-desktop run: npm ci @@ -317,13 +330,6 @@ jobs: Updates are delivered in-app and verified against the project's minisign key before they install (on macOS the updater refreshes the app, not the hand-installed daemon). - - name: Publish the beta channel manifest - shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Final overwrite of beta.json, now that latest.json carries all three - # platforms; the Windows and macOS jobs published interim copies. - run: node scripts/publish-beta-manifest.mjs "$GITHUB_REF_NAME" "${{ steps.channel.outputs.prerelease }}" arch-package: # Builds the pacman package the Arch users actually want, so they do not have # to run makepkg themselves. It is a separate job from the Tauri bundles @@ -437,10 +443,18 @@ jobs: - uses: actions/setup-node@v6 with: node-version: 24 - - name: Verify every expected asset landed, then publish + - name: Verify every expected asset landed, then prepare or publish env: # gh authenticates with GITHUB_TOKEN; the script reads the repository # from GITHUB_REPOSITORY, which the runner always sets, and hands it to # gh explicitly rather than letting gh work it out from git. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: node .github/scripts/publish-release.mjs "$GITHUB_REF_NAME" + TENEBRA_RELEASE_HOLD: ${{ vars.TENEBRA_RELEASE_HOLD }} + run: | + set -eu + if [ "$TENEBRA_RELEASE_HOLD" = "true" ]; then + node .github/scripts/publish-release.mjs "$GITHUB_REF_NAME" --prepare-only + echo "Release held as a verified draft for native acceptance." >> "$GITHUB_STEP_SUMMARY" + else + node .github/scripts/publish-release.mjs "$GITHUB_REF_NAME" + fi diff --git a/.go-version b/.go-version new file mode 100644 index 00000000..25691b4f --- /dev/null +++ b/.go-version @@ -0,0 +1 @@ +1.26.8 diff --git a/README.md b/README.md index 5474d443..79ba3eb6 100644 --- a/README.md +++ b/README.md @@ -8,19 +8,19 @@ [![Platform](https://img.shields.io/badge/platform-Windows_%7C_macOS_%7C_Linux-0e0e0e.svg)](#project-status) **A cross-platform VPN client built on [sing-box](https://github.com/SagerNet/sing-box).**
-Desktop first — Windows is user-ready; macOS and Linux ship but are for advanced users (see below). The same Go core drives an Android client, in alpha and installed by hand; iOS is a scaffold. +Desktop first — Windows has an installer-managed service; the current audit candidate still requires native acceptance. macOS and Linux ship for advanced users (see below). The same Go core drives an Android client, in alpha and installed by hand; iOS is a scaffold. A total eclipse: intercepted noise enters the dark, one clean signal leaves it. In tenebris lux. > **Project status — early development.** The desktop client is the current -> focus. The core, the control protocol and the UI are in good shape and well -> tested, and the Windows tunnel path (wintun + sing-box under the service) is -> exercised against real servers rather than only in tests — but no automated -> test stands up a real tunnel on any platform, and the macOS and Linux tunnels -> have had no privileged live run signed off. Treat this as pre-release: not -> yet "production-ready", +> focus. Earlier Windows releases have been exercised against real servers; +> that evidence does not establish native acceptance of the current audit +> candidate, including its persistent host guard. The required packet, BFE and +> reboot gates are [documented here](docs/host-protection-acceptance.md). +> macOS and Linux have had no privileged live-tunnel run signed off. +> Treat this as pre-release: not yet "production-ready", > and expect things to move around. See > [Project status](#project-status) for the honest breakdown. @@ -82,9 +82,12 @@ Everything below is implemented in this repo today (the UI features are desktop) minimized to the tray), single-instance, live traffic graphs, light/dark themes, and English / Russian UI. -The kill-switch (drop proxied traffic instead of leaking when the tunnel drops) is a -UI toggle — best-effort by design, with the exact guarantee described in the -[changelog](CHANGELOG.md); LAN bypass is a core routing option. +The v0.5.11 kill switch was best-effort; its behavior is recorded in the +[changelog](CHANGELOG.md). The current Windows audit candidate adds persistent +host protection, with desired settings separate from confirmed policy state. +Its engine/service-death and reboot guarantees still require +[native acceptance](docs/host-protection-acceptance.md); they are not established +by unit tests or prior-release tunnel runs. LAN bypass remains a routing option. ## DPI bypass @@ -175,7 +178,7 @@ get one: | Go core (parsing, profiles, routing, config gen, fallback, leak logic) | Implemented, unit-tested, no third-party deps | | Control protocol (core ↔ UI) | Implemented; covered by Go tests **and** a real-binary e2e | | Desktop UI (Tauri 2 + React) | Implemented: all screens, reactive tray, notifications, deep links, autostart, i18n, themes | -| Windows tunnel (wintun + sing-box) | Implemented — a background **service** runs the tunnel, so the app connects without an elevated GUI; installer sets it up, the in-app updater refreshes both app and service | +| Windows tunnel (wintun + sing-box) | A background **service** owns the tunnel; installer/update code coordinates app and service. The current audit candidate still needs standard-user installer, live-tunnel and [host-protection acceptance](docs/host-protection-acceptance.md). INC-01 remains open; cause unknown. | | macOS tunnel (utun + sing-box) | Builds and runs — universal `.app`/DMG — but see the **macOS note** below: it needs a hand-installed root daemon and is not yet a click-to-run product. No live-tunnel sign-off yet | | Linux tunnel (`/dev/net/tun` + sing-box) | Builds and runs — a root **systemd service** owns the tunnel, installed by an Arch package or a `sudo` script; see the **Linux note** below. No live-tunnel sign-off yet | | Android (`VpnService` + libbox) | **Alpha, hand-installed** — a Kotlin / Compose client in [`ui-android/`](ui-android/README.md) builds and runs on a device: subscription import, node list with latency badges, an AUTO exit, switching the live exit without a reconnect, connect-on-boot, a Quick Settings tile, in-app logs and crash reports. Routing is *Global* only and there is no DPI bypass. CI builds a debug APK; a tagged release carries a signed one only once the signing key is in CI secrets | @@ -205,8 +208,8 @@ The click-to-run macOS path — a signed, notarized build with an `SMAppService` daemon bundled inside the app (so it installs and updates like the Windows service) — needs an Apple Developer ID and is **planned, not done**. Until then, use the DMG only if you're comfortable running the install script yourself. -**Windows users are unaffected** — the Windows installer sets up the service and -the updater keeps everything current automatically. +Windows uses an installer-managed service; the current audit candidate's +installation and update path still requires [delivery acceptance](docs/delivery-acceptance.md). ### Linux note — the tunnel needs a root service diff --git a/adapters/windows/identity.go b/adapters/windows/identity.go new file mode 100644 index 00000000..1db5fb98 --- /dev/null +++ b/adapters/windows/identity.go @@ -0,0 +1,5 @@ +package windows + +// ExecutablePath uses the same resolution as Start. The protection adapter +// validates this path and its ACL before granting it a persistent exception. +func (r *Runner) ExecutablePath() (string, error) { return r.resolveSingbox() } diff --git a/adapters/windows/process_lifetime.go b/adapters/windows/process_lifetime.go new file mode 100644 index 00000000..5a5d106c --- /dev/null +++ b/adapters/windows/process_lifetime.go @@ -0,0 +1,36 @@ +package windows + +import "errors" + +// suspendedChild keeps the external process boundary injectable. Production +// Windows starts suspended, assigns an owner-only job, then resumes execution. +type suspendedChild interface { + Prepare() error + StartSuspended() error + Assign() error + Resume() error + Kill() error + Wait() error + Close() error +} + +func startWithLifetime(child suspendedChild) (func() error, error) { + if err := child.Prepare(); err != nil { + return nil, errors.Join(err, child.Close()) + } + if err := child.StartSuspended(); err != nil { + return nil, errors.Join(err, child.Close()) + } + if err := child.Assign(); err != nil { + return nil, errors.Join(err, child.Kill(), child.Close(), child.Wait()) + } + if err := child.Resume(); err != nil { + return nil, errors.Join(err, child.Kill(), child.Close(), child.Wait()) + } + return child.Close, nil +} + +type localStartError struct{ error } + +func (localStartError) LocalSetupFailure() bool { return true } +func (e localStartError) Unwrap() error { return e.error } diff --git a/adapters/windows/process_lifetime_other.go b/adapters/windows/process_lifetime_other.go new file mode 100644 index 00000000..00f4c2d6 --- /dev/null +++ b/adapters/windows/process_lifetime_other.go @@ -0,0 +1,12 @@ +//go:build !windows + +package windows + +import "os/exec" + +func startOwnedCommand(cmd *exec.Cmd) (func() error, error) { + if err := cmd.Start(); err != nil { + return nil, err + } + return func() error { return nil }, nil +} diff --git a/adapters/windows/process_lifetime_test.go b/adapters/windows/process_lifetime_test.go new file mode 100644 index 00000000..ffc9a82e --- /dev/null +++ b/adapters/windows/process_lifetime_test.go @@ -0,0 +1,88 @@ +package windows + +import ( + "errors" + "reflect" + "testing" +) + +type fakeSuspendedChild struct { + fail string + steps []string + running bool +} + +func (f *fakeSuspendedChild) step(name string) error { + f.steps = append(f.steps, name) + if f.fail == name { + return errors.New(name + " failed") + } + return nil +} +func (f *fakeSuspendedChild) Prepare() error { return f.step("prepare") } +func (f *fakeSuspendedChild) StartSuspended() error { return f.step("start suspended") } +func (f *fakeSuspendedChild) Assign() error { return f.step("assign") } +func (f *fakeSuspendedChild) Resume() error { + if err := f.step("resume"); err != nil { + return err + } + f.running = true + return nil +} +func (f *fakeSuspendedChild) Kill() error { f.running = false; return f.step("kill") } +func (f *fakeSuspendedChild) Wait() error { return f.step("wait") } +func (f *fakeSuspendedChild) Close() error { f.running = false; return f.step("close") } + +func TestOwnedEngineCannotRunBeforeJobAssignment(t *testing.T) { + f := &fakeSuspendedChild{} + close, err := startWithLifetime(f) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(f.steps, []string{"prepare", "start suspended", "assign", "resume"}) || !f.running { + t.Fatalf("unsafe startup order: %v", f.steps) + } + if err := close(); err != nil { + t.Fatal(err) + } + if f.running { + t.Fatal("closing the owner did not stop its child") + } +} + +func TestOwnedEngineAssignmentFailureNeverResumes(t *testing.T) { + f := &fakeSuspendedChild{fail: "assign"} + if close, err := startWithLifetime(f); err == nil || close != nil { + t.Fatal("unowned engine start accepted") + } + if !reflect.DeepEqual(f.steps, []string{"prepare", "start suspended", "assign", "kill", "close", "wait"}) || f.running { + t.Fatalf("failed assignment escaped cleanup: %v", f.steps) + } +} + +func TestOwnedEngineResumeFailureKillsAndReaps(t *testing.T) { + f := &fakeSuspendedChild{fail: "resume"} + if _, err := startWithLifetime(f); err == nil { + t.Fatal("resume failure accepted") + } + if !reflect.DeepEqual(f.steps, []string{"prepare", "start suspended", "assign", "resume", "kill", "close", "wait"}) { + t.Fatalf("failed resume escaped cleanup: %v", f.steps) + } +} + +func TestOwnedEnginePreStartFailureDoesNotKillOtherProcesses(t *testing.T) { + for _, stage := range []string{"prepare", "start suspended"} { + f := &fakeSuspendedChild{fail: stage} + if _, err := startWithLifetime(f); err == nil { + t.Fatal("startup failure accepted") + } + for _, step := range f.steps { + if step == "kill" || step == "wait" || step == "resume" { + t.Fatalf("nonexistent child operated on: %v", f.steps) + } + } + if f.steps[len(f.steps)-1] != "close" { + t.Fatal("job handle leaked") + } + } +} diff --git a/adapters/windows/process_lifetime_windows.go b/adapters/windows/process_lifetime_windows.go new file mode 100644 index 00000000..c5c54b72 --- /dev/null +++ b/adapters/windows/process_lifetime_windows.go @@ -0,0 +1,121 @@ +//go:build windows + +package windows + +import ( + "errors" + "fmt" + "os/exec" + "sync" + "syscall" + "unsafe" + + win "golang.org/x/sys/windows" +) + +var procEngineThreadOwner = win.NewLazySystemDLL("kernel32.dll").NewProc("GetProcessIdOfThread") + +type jobChild struct { + cmd *exec.Cmd + job win.Handle + closeOnce sync.Once + closeErr error +} + +func startOwnedCommand(cmd *exec.Cmd) (func() error, error) { + return startWithLifetime(&jobChild{cmd: cmd}) +} + +func (c *jobChild) Prepare() error { + job, err := win.CreateJobObject(nil, nil) // unnamed, non-inheritable, owned only by core + if err != nil { + return fmt.Errorf("create engine lifetime job: %w", err) + } + c.job = job + limits := win.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{} + limits.BasicLimitInformation.LimitFlags = win.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if _, err := win.SetInformationJobObject(job, win.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&limits)), uint32(unsafe.Sizeof(limits))); err != nil { + return fmt.Errorf("set engine lifetime job: %w", err) + } + return nil +} + +func (c *jobChild) StartSuspended() error { + attr := new(syscall.SysProcAttr) + if c.cmd.SysProcAttr != nil { + *attr = *c.cmd.SysProcAttr + } + attr.CreationFlags |= win.CREATE_SUSPENDED | win.CREATE_NO_WINDOW + attr.HideWindow = true + c.cmd.SysProcAttr = attr + return c.cmd.Start() +} + +func (c *jobChild) Assign() error { + // Cmd retains its process handle until Wait, preventing reuse of this PID. + process, err := win.OpenProcess(win.PROCESS_SET_QUOTA|win.PROCESS_TERMINATE, false, uint32(c.cmd.Process.Pid)) + if err != nil { + return fmt.Errorf("open suspended engine: %w", err) + } + defer win.CloseHandle(process) + if err := win.AssignProcessToJobObject(c.job, process); err != nil { + return fmt.Errorf("assign engine lifetime job: %w", err) + } + return nil +} + +func (c *jobChild) Resume() error { + pid := uint32(c.cmd.Process.Pid) + snapshot, err := win.CreateToolhelp32Snapshot(win.TH32CS_SNAPTHREAD, 0) + if err != nil { + return err + } + defer win.CloseHandle(snapshot) + entry := win.ThreadEntry32{Size: uint32(unsafe.Sizeof(win.ThreadEntry32{}))} + err = win.Thread32First(snapshot, &entry) + var ids []uint32 + for err == nil { + if entry.OwnerProcessID == pid { + ids = append(ids, entry.ThreadID) + } + err = win.Thread32Next(snapshot, &entry) + } + if !errors.Is(err, win.ERROR_NO_MORE_FILES) { + return fmt.Errorf("enumerate suspended engine thread: %w", err) + } + if len(ids) != 1 { + return fmt.Errorf("suspended engine has %d threads; startup refused", len(ids)) + } + thread, err := win.OpenThread(win.THREAD_SUSPEND_RESUME|win.THREAD_QUERY_LIMITED_INFORMATION, false, ids[0]) + if err != nil { + return err + } + defer win.CloseHandle(thread) + owner, _, ownerErr := procEngineThreadOwner.Call(uintptr(thread)) + if owner == 0 { + return fmt.Errorf("verify engine thread owner: %w", ownerErr) + } + if uint32(owner) != pid { + return errors.New("suspended engine thread identity changed") + } + previous, err := win.ResumeThread(thread) + if err != nil { + return fmt.Errorf("resume owned engine: %w", err) + } + if previous != 1 { + return fmt.Errorf("unexpected engine thread suspension count %d", previous) + } + return nil +} + +func (c *jobChild) Kill() error { return c.cmd.Process.Kill() } +func (c *jobChild) Wait() error { return c.cmd.Wait() } +func (c *jobChild) Close() error { + c.closeOnce.Do(func() { + if c.job != 0 { + c.closeErr = win.CloseHandle(c.job) + c.job = 0 + } + }) + return c.closeErr +} diff --git a/adapters/windows/runner.go b/adapters/windows/runner.go index b8f24400..6b58c516 100644 --- a/adapters/windows/runner.go +++ b/adapters/windows/runner.go @@ -118,7 +118,14 @@ func New() *Runner { // spawned; the tunnel coming up (or failing) is observed through Done. Starting // while a process is already running is rejected — the caller is expected to // Stop first. -func (r *Runner) Start(ctx context.Context, configJSON []byte) error { +func (r *Runner) Start(ctx context.Context, configJSON []byte) (startErr error) { + // A missing binary/driver, temp-file failure or denied process/job setup is + // local to this installation. Trying other servers cannot repair it. + defer func() { + if startErr != nil { + startErr = localStartError{startErr} + } + }() bin, err := r.resolveSingbox() if err != nil { return err @@ -156,13 +163,17 @@ func (r *Runner) Start(ctx context.Context, configJSON []byte) error { } stderr, err := cmd.StderrPipe() if err != nil { + _ = stdout.Close() cancel() os.Remove(cfgPath) return fmt.Errorf("windows: stderr pipe: %w", err) } - if err := cmd.Start(); err != nil { + releaseProcess, err := startOwnedCommand(cmd) + if err != nil { cancel() + _ = stdout.Close() + _ = stderr.Close() os.Remove(cfgPath) return fmt.Errorf("windows: start sing-box: %w", err) } @@ -183,6 +194,7 @@ func (r *Runner) Start(ctx context.Context, configJSON []byte) error { // the running state so the Runner can be started again. go func() { werr := cmd.Wait() + werr = errors.Join(werr, releaseProcess()) cancel() os.Remove(cfgPath) diff --git a/cmd/tenebra-core/main.go b/cmd/tenebra-core/main.go index bbb5ce86..ae9cb4e8 100644 --- a/cmd/tenebra-core/main.go +++ b/cmd/tenebra-core/main.go @@ -35,6 +35,8 @@ var pipeMode = flag.Bool("pipe", false, "serve the control protocol on the named // daemon's transport without installing one, and what that daemon runs with. var socketMode = flag.Bool("socket", false, "serve the control protocol on a unix domain socket instead of stdin/stdout (macOS and Linux only)") +var releaseHostProtection = flag.Bool("release-host-protection", false, "explicitly remove only Tenebra-owned persistent host protection (administrator recovery/uninstall)") + // fileLogTail reads back the trailing lines of the process log when this run // writes one to disk — the Windows service sets it to its rotating writer's // Tail. It stays nil in the console and sidecar modes, whose diagnostics come @@ -43,7 +45,21 @@ var socketMode = flag.Bool("socket", false, "serve the control protocol on a uni var fileLogTail func(n int) []string func main() { + if handled, err := control.RunUserProxyHelper(os.Args[1:]); handled { + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } flag.Parse() + if *releaseHostProtection { + if err := releaseNativeHostProtection(); err != nil { + log.Printf("host protection cleanup: %v", err) + os.Exit(1) + } + return + } // The service control manager starts us with no console and no usable // stdio, so the service path must be detected before anything touches // them. Off Windows this is always a no-op. @@ -109,6 +125,7 @@ func run(usePipe, useSocket bool) error { if err != nil { return err } + startProductionConnection(daemon) // Belt-and-suspenders for the system-proxy guard: Serve already calls // daemon.Close() (which clears any armed OS proxy) on a clean or signalled exit, // but a defer here also covers the --pipe/--socket paths and any early return, @@ -239,16 +256,18 @@ func buildDaemon() (*control.Daemon, error) { } else if cleared { log.Printf("tenebra-core: cleared a stale system proxy left by a previous run") } - // Autoconnect: if the preference is armed and a last connect is recorded, - // re-issue it now. This is the daemon's own start — shared by the sidecar, - // the --pipe console and the Windows service — so with the service the - // tunnel comes up with the machine, before anyone logs in or a UI attaches. - // The attempt runs in the background and never delays the control plane; a - // client connecting mid-attempt simply sees the connecting state. + return daemon, nil +} + +// Kept outside buildDaemon so ordinary constructor/unit fixtures never apply +// native policy or change the process resolver. Recovery precedes autoconnect. +func startProductionConnection(daemon *control.Daemon) { + configureHostProtection(daemon) + // Sidecar, --pipe console and service share this one startup attempt. It + // runs in the background; clients attaching during it see connecting. if daemon.AutoconnectOnStart() { log.Printf("tenebra-core: autoconnect: reconnecting the last profile") } - return daemon, nil } // ruleSetFiles are the RU geodata binaries that decide which resource directory diff --git a/cmd/tenebra-core/protection_other.go b/cmd/tenebra-core/protection_other.go new file mode 100644 index 00000000..a97dfac2 --- /dev/null +++ b/cmd/tenebra-core/protection_other.go @@ -0,0 +1,13 @@ +//go:build !windows + +package main + +import ( + "errors" + "github.com/Divaaaan/tenebra/core/control" +) + +func configureHostProtection(d *control.Daemon) { d.UseLegacyEngineProtection() } +func releaseNativeHostProtection() error { + return errors.New("persistent host protection cleanup is Windows-only") +} diff --git a/cmd/tenebra-core/protection_windows.go b/cmd/tenebra-core/protection_windows.go new file mode 100644 index 00000000..bd81e2a6 --- /dev/null +++ b/cmd/tenebra-core/protection_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package main + +import ( + "errors" + "log" + "net" + + "github.com/Divaaaan/tenebra/core/control" + "github.com/Divaaaan/tenebra/core/protection" +) + +func releaseNativeHostProtection() error { + b := protection.NewWindowsBackend(nil) + if b == nil { + return errors.New("host protection cleanup is unsupported on this Windows architecture") + } + return b.Remove() // does not require the engine, settings, or a running service +} + +// Called only by real process entry points, before background jobs/autoconnect. +// Neither buildDaemon nor NewDaemon installs a resolver or invokes native WFP. +func configureHostProtection(d *control.Daemon) { + d.SetProtection(protection.New(protection.NewWindowsBackend(d.EngineExecutablePath))) + net.DefaultResolver = protection.NewResolver(d.ProtectionDNS) + if err := d.RecoverProtectionAtStartup(); err != nil { + log.Printf("tenebra-core: %v", err) + } +} diff --git a/cmd/tenebra-core/service_process_access_windows.go b/cmd/tenebra-core/service_process_access_windows.go new file mode 100644 index 00000000..1d6e2ece --- /dev/null +++ b/cmd/tenebra-core/service_process_access_windows.go @@ -0,0 +1,135 @@ +//go:build windows + +package main + +import ( + "errors" + "fmt" + "os" + "runtime" + + "golang.org/x/sys/windows" +) + +// The GUI authenticates the pipe server using SCM, its process image, and a +// retained process handle. A LocalSystem process's inherited DACL need not let +// an ordinary console user query that image. Grant just that metadata right on +// OUR process before listening; never weaken the GUI's identity checks or grant +// process memory, duplication, termination, token, or security-editing rights. +// This is a process-lifetime ACL change, not a machine/service/token policy. +func enableServiceProcessQuery() error { + identity, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return fmt.Errorf("read service identity: %w", err) + } + if identity.User.Sid == nil || !identity.User.Sid.IsWellKnown(windows.WinLocalSystemSid) { + return errors.New("service must run as LocalSystem") + } + process, err := windows.OpenProcess(windows.READ_CONTROL|windows.WRITE_DAC, false, uint32(os.Getpid())) + if err != nil { + return fmt.Errorf("open own process security: %w", err) + } + defer windows.CloseHandle(process) + original, err := windows.GetSecurityInfo(process, windows.SE_KERNEL_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + return fmt.Errorf("read own process DACL: %w", err) + } + updated, err := serviceProcessQueryACL(original) + if err != nil { + return err + } + // DACL only: preserve the owner, primary group, SACL/integrity label, and + // protection flags. The merge preserves all existing grants and denials. + if err := windows.SetSecurityInfo(process, windows.SE_KERNEL_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, updated, nil); err != nil { + return fmt.Errorf("grant own process metadata query: %w", err) + } + actual, err := windows.GetSecurityInfo(process, windows.SE_KERNEL_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + return fmt.Errorf("read back own process DACL: %w", err) + } + if err := verifyServiceProcessQueryACL(updated, actual); err != nil { + return err + } + before, _, err := original.Control() + if err != nil { + return fmt.Errorf("read original process DACL flags: %w", err) + } + after, _, err := actual.Control() + if err != nil || before&windows.SE_DACL_PROTECTED != after&windows.SE_DACL_PROTECTED { + return errors.New("own process DACL protection changed") + } + return nil +} + +func serviceProcessQueryACL(original *windows.SECURITY_DESCRIPTOR) (*windows.ACL, error) { + if original == nil || !original.IsValid() { + return nil, errors.New("invalid own process security descriptor") + } + dacl, _, err := original.DACL() + if err != nil || dacl == nil { + return nil, errors.New("own process must have an explicit non-null DACL") + } + interactive, err := windows.CreateWellKnownSid(windows.WinInteractiveSid) + if err != nil { + return nil, fmt.Errorf("create interactive SID: %w", err) + } + entries := []windows.EXPLICIT_ACCESS{{ + AccessPermissions: windows.PROCESS_QUERY_LIMITED_INFORMATION, + AccessMode: windows.GRANT_ACCESS, + Inheritance: windows.NO_INHERITANCE, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_WELL_KNOWN_GROUP, + TrusteeValue: windows.TrusteeValueFromSID(interactive), + }, + }} + merged, err := windows.ACLFromEntries(entries, dacl) + runtime.KeepAlive(interactive) + if err != nil { + return nil, fmt.Errorf("merge own process metadata query ACL: %w", err) + } + return merged, nil +} + +// Compare the entire DACL, not only the new ACE: a failed/partial readback must +// not publish a service as ready, nor conceal lost existing permissions. +func verifyServiceProcessQueryACL(expected *windows.ACL, actual *windows.SECURITY_DESCRIPTOR) error { + if actual == nil || !actual.IsValid() { + return errors.New("invalid own process security readback") + } + dacl, _, err := actual.DACL() + if err != nil || dacl == nil || expected == nil { + return errors.New("own process security readback has no explicit DACL") + } + want, err := processACLString(expected) + if err != nil { + return err + } + got, err := processACLString(dacl) + if err != nil { + return err + } + if want != got { + return errors.New("own process metadata-query DACL readback differs") + } + return nil +} + +func processACLString(dacl *windows.ACL) (string, error) { + if dacl == nil { + return "", errors.New("null process DACL") + } + sd, err := windows.NewSecurityDescriptor() + if err != nil { + return "", err + } + if err := sd.SetDACL(dacl, true, false); err != nil { + return "", err + } + value := sd.String() + runtime.KeepAlive(dacl) + if value == "" { + return "", errors.New("cannot encode process DACL") + } + return value, nil +} diff --git a/cmd/tenebra-core/service_process_access_windows_test.go b/cmd/tenebra-core/service_process_access_windows_test.go new file mode 100644 index 00000000..81b39466 --- /dev/null +++ b/cmd/tenebra-core/service_process_access_windows_test.go @@ -0,0 +1,224 @@ +//go:build windows + +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +// Execute cannot be started in a unit test: it owns a real service and daemon. +// Guard its startup boundary without launching either on the developer machine. +func TestServicePublishesQueryableProcessBeforeDaemon(t *testing.T) { + file, err := parser.ParseFile(token.NewFileSet(), "service_windows.go", nil, 0) + if err != nil { + t.Fatal(err) + } + var execute *ast.FuncDecl + for _, declaration := range file.Decls { + if fn, ok := declaration.(*ast.FuncDecl); ok && fn.Name.Name == "Execute" && fn.Recv != nil { + execute = fn + } + } + if execute == nil { + t.Fatal("service Execute method missing") + } + guard, daemon := token.NoPos, token.NoPos + ast.Inspect(execute, func(node ast.Node) bool { + if call, ok := node.(*ast.CallExpr); ok { + if name, ok := call.Fun.(*ast.Ident); ok { + switch name.Name { + case "enableServiceProcessQuery": + guard = call.Pos() + case "buildDaemon": + daemon = call.Pos() + } + } + } + return true + }) + if guard == token.NoPos || daemon == token.NoPos || guard >= daemon { + t.Fatal("service must grant and verify its metadata query ACL before daemon construction/listening/Running") + } + // A grant/readback failure must return a failed service start, rather than + // merely logging and continuing toward the listener or Running state. + guardedFailure := false + for _, statement := range execute.Body.List { + branch, ok := statement.(*ast.IfStmt) + if !ok || branch.Init == nil { + continue + } + assignment, ok := branch.Init.(*ast.AssignStmt) + if !ok || len(assignment.Rhs) != 1 { + continue + } + call, ok := assignment.Rhs[0].(*ast.CallExpr) + if !ok { + continue + } + name, ok := call.Fun.(*ast.Ident) + if !ok || name.Name != "enableServiceProcessQuery" { + continue + } + condition, ok := branch.Cond.(*ast.BinaryExpr) + if !ok || condition.Op != token.NEQ { + t.Fatal("process-query startup error is not checked") + } + left, leftOK := condition.X.(*ast.Ident) + right, rightOK := condition.Y.(*ast.Ident) + if !leftOK || !rightOK || left.Name != "err" || right.Name != "nil" { + t.Fatal("process-query startup error condition changed") + } + last, ok := branch.Body.List[len(branch.Body.List)-1].(*ast.ReturnStmt) + if ok && len(last.Results) == 2 { + failure, ok := last.Results[1].(*ast.BasicLit) + guardedFailure = ok && failure.Kind == token.INT && failure.Value == "1" + } + } + if !guardedFailure { + t.Fatal("process-query ACL failure can continue service startup") + } +} + +// These tests use only in-memory security descriptors and the Windows ACL +// parser/merger. They never open a service, process, pipe, or network interface. +func TestServiceProcessQueryACLAddsOnlyInteractiveMetadata(t *testing.T) { + original := processDescriptor(t, "O:SYG:SYD:P(A;;GA;;;SY)(A;;GA;;;BA)") + before := original.String() + merged, err := serviceProcessQueryACL(original) + if err != nil { + t.Fatal(err) + } + text, err := processACLString(merged) + if err != nil { + t.Fatal(err) + } + if original.String() != before { + t.Fatal("merging changed the original descriptor") + } + for _, retained := range []string{"(A;;GA;;;SY)", "(A;;GA;;;BA)"} { + if !strings.Contains(text, retained) { + t.Fatalf("lost original ACE %s: %s", retained, text) + } + } + if merged.AceCount != 3 { + t.Fatalf("expected exactly the two original ACEs and interactive query grant: %s", text) + } + iu, err := windows.CreateWellKnownSid(windows.WinInteractiveSid) + if err != nil { + t.Fatal(err) + } + found := false + for i := uint32(0); i < uint32(merged.AceCount); i++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(merged, i, &ace); err != nil { + t.Fatal(err) + } + if ace.Header.AceType == windows.ACCESS_ALLOWED_ACE_TYPE && windows.EqualSid((*windows.SID)(unsafe.Pointer(&ace.SidStart)), iu) { + found = true + if ace.Mask != windows.PROCESS_QUERY_LIMITED_INFORMATION || ace.Header.AceFlags != 0 { + t.Fatalf("interactive grant widened or became inheritable: mask=%#x flags=%#x", ace.Mask, ace.Header.AceFlags) + } + } + } + if !found { + t.Fatal("interactive metadata query ACE missing") + } + // Re-applying on a descriptor that already carries the grant adds nothing. + second, err := serviceProcessQueryACL(processDescriptor(t, text)) + if err != nil { + t.Fatal(err) + } + again, err := processACLString(second) + if err != nil || again != text { + t.Fatalf("grant is not idempotent: first=%s again=%s err=%v", text, again, err) + } +} + +func TestServiceProcessQueryACLPreservesExistingDenialsAndGrants(t *testing.T) { + // Existing denials stay authoritative, including a denial of query itself. + // The grant never revokes them or rewrites a machine's stricter policy. + for _, sddl := range []string{ + "D:P(D;;0x1;;;IU)(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x20000;;;LS)", + "D:P(D;;0x1000;;;WD)(A;;GA;;;SY)(A;;GA;;;BA)", + } { + original := processDescriptor(t, sddl) + merged, err := serviceProcessQueryACL(original) + if err != nil { + t.Fatal(err) + } + text, err := processACLString(merged) + if err != nil { + t.Fatal(err) + } + originalACL, _, _ := original.DACL() + if merged.AceCount != originalACL.AceCount+1 { + t.Fatalf("existing entries lost or unexpected entries added: %s", text) + } + for _, ace := range strings.Split(original.String(), "(")[1:] { + if !strings.Contains(text, "("+ace) { + t.Fatalf("existing ACE changed: (%s in %s", ace, text) + } + } + } +} + +func TestServiceProcessQueryACLRejectsUnrestrictedOrInvalidDescriptor(t *testing.T) { + for name, original := range map[string]*windows.SECURITY_DESCRIPTOR{ + "nil": nil, + "invalid": new(windows.SECURITY_DESCRIPTOR), + "absent": processDescriptor(t, "O:SY"), + "null": processDescriptor(t, "D:NO_ACCESS_CONTROL"), + } { + t.Run(name, func(t *testing.T) { + if _, err := serviceProcessQueryACL(original); err == nil { + t.Fatal("accepted an invalid or fully permissive process DACL") + } + }) + } +} + +func TestServiceProcessQueryACLReadbackRejectsMissingOrWidenedEntries(t *testing.T) { + original := processDescriptor(t, "D:P(D;;0x1;;;IU)(A;;GA;;;SY)(A;;GA;;;BA)") + expected, err := serviceProcessQueryACL(original) + if err != nil { + t.Fatal(err) + } + text, err := processACLString(expected) + if err != nil { + t.Fatal(err) + } + if err := verifyServiceProcessQueryACL(expected, processDescriptor(t, text)); err != nil { + t.Fatal(err) + } + for name, actual := range map[string]*windows.SECURITY_DESCRIPTOR{ + "nil": nil, + "invalid": new(windows.SECURITY_DESCRIPTOR), + "null": processDescriptor(t, "D:NO_ACCESS_CONTROL"), + "missing-grant": original, + "lost-denial": processDescriptor(t, "D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x1000;;;IU)"), + "lost-admin": processDescriptor(t, "D:(D;;0x1;;;IU)(A;;GA;;;SY)(A;;0x1000;;;IU)"), + "broad-iu": processDescriptor(t, "D:(D;;0x1;;;IU)(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;IU)"), + } { + t.Run(name, func(t *testing.T) { + if err := verifyServiceProcessQueryACL(expected, actual); err == nil { + t.Fatal("unverified process ACL accepted as ready") + } + }) + } +} + +func processDescriptor(t *testing.T, sddl string) *windows.SECURITY_DESCRIPTOR { + t.Helper() + sd, err := windows.SecurityDescriptorFromString(sddl) + if err != nil { + t.Fatal(err) + } + return sd +} diff --git a/cmd/tenebra-core/service_windows.go b/cmd/tenebra-core/service_windows.go index 9f1c645a..bcb53fd2 100644 --- a/cmd/tenebra-core/service_windows.go +++ b/cmd/tenebra-core/service_windows.go @@ -62,6 +62,10 @@ type coreService struct{} func (coreService) Execute(args []string, req <-chan svc.ChangeRequest, status chan<- svc.Status) (svcSpecificEC bool, exitCode uint32) { status <- svc.Status{State: svc.StartPending} + if err := enableServiceProcessQuery(); err != nil { + log.Printf("fatal: service process authentication: %v", err) + return false, 1 + } if err := configureServicePaths(); err != nil { log.Printf("fatal: %v", err) return false, 1 @@ -71,6 +75,7 @@ func (coreService) Execute(args []string, req <-chan svc.ChangeRequest, status c log.Printf("fatal: %v", err) return false, 1 } + startProductionConnection(daemon) l, err := control.ListenPipe(control.PipeName) if err != nil { log.Printf("fatal: %v", err) @@ -86,7 +91,7 @@ func (coreService) Execute(args []string, req <-chan svc.ChangeRequest, status c // missing on every ordinary Windows install: see startBackgroundJobs. startBackgroundJobs(ctx, daemon) - status <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown} + status <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown | svc.AcceptSessionChange} for { select { @@ -100,6 +105,8 @@ func (coreService) Execute(args []string, req <-chan svc.ChangeRequest, status c switch c.Cmd { case svc.Interrogate: status <- c.CurrentStatus + case svc.SessionChange: + go daemon.ReconcileSystemProxyWhenIdle() case svc.Stop, svc.Shutdown: // The teardown stops sing-box and waits for the connection // goroutines to drain; give the SCM an explicit budget for that diff --git a/core/buildinfo/buildinfo.go b/core/buildinfo/buildinfo.go index 02ee727f..841b94d5 100644 --- a/core/buildinfo/buildinfo.go +++ b/core/buildinfo/buildinfo.go @@ -16,4 +16,4 @@ package buildinfo // Version is the semantic version of this build, kept in sync with the // desktop app's version by scripts/set-version.mjs. -const Version = "0.5.11" +const Version = "0.6.0" diff --git a/core/control/connect.go b/core/control/connect.go index 4a76f6ee..3a85ba56 100644 --- a/core/control/connect.go +++ b/core/control/connect.go @@ -3,6 +3,7 @@ package control import ( "context" "encoding/json" + "errors" "fmt" "strings" "time" @@ -10,6 +11,7 @@ import ( "github.com/Divaaaan/tenebra/core/fallback" "github.com/Divaaaan/tenebra/core/model" "github.com/Divaaaan/tenebra/core/profile" + "github.com/Divaaaan/tenebra/core/protection" "github.com/Divaaaan/tenebra/core/routing" "github.com/Divaaaan/tenebra/core/singbox" ) @@ -210,6 +212,21 @@ func (d *Daemon) logConnectPlan(p profile.Profile, m *fallback.Machine, explicit // reconnecting the one it just abandoned. It is ignored for an explicit-node // connect (the user pinned that exact exit) and when empty. func (d *Daemon) startConnect(ctx context.Context, p profile.Profile, explicitNode string, auto, remember bool, avoid string) (State, error) { + d.mu.Lock() + mh := d.multihop + d.mu.Unlock() + if err := validateMultihopProfile(p, mh); err != nil { + return State{}, err + } + requestedNode := explicitNode + if mh.Enabled { + // A chain has one effective exit. Use it for the attempt, state, last-good + // and leak check, while keeping the user's original connect intent. + if avoid == mh.ExitID { + return State{}, fmt.Errorf("connect: multihop has no alternative exit; select another chain") + } + explicitNode = mh.ExitID + } // Build the fallback candidates. An explicit node request collapses the walk // to that single node: the user asked for a specific exit, so we honour it and // do not silently wander to another protocol behind their back. Without an @@ -237,6 +254,14 @@ func (d *Daemon) startConnect(ctx context.Context, p profile.Profile, explicitNo return State{}, fmt.Errorf("connect: no alternative node to fail over to") } } + // Lock down before candidate pings or any process replacement. Validation + // above remains a read-only refusal; errors here never start an engine. + d.mu.Lock() + protectionRouting := d.routing + d.mu.Unlock() + if err := d.prepareProtection(protectionRouting); err != nil { + return State{}, fmt.Errorf("host protection: %w", err) + } // Choose the candidate ordering. The default is protocol preference (the // anti-DPI strategy). When the request asks for auto AND named no explicit @@ -274,7 +299,6 @@ func (d *Daemon) startConnect(ctx context.Context, p profile.Profile, explicitNo d.mu.Lock() ro := d.routing tun := d.tun - mh := d.multihop d.mu.Unlock() nodes := profileNodes(p) @@ -282,8 +306,7 @@ func (d *Daemon) startConnect(ctx context.Context, p profile.Profile, explicitNo tags := serverTags(p) // Resolve the multihop selection (server IDs) into the builder-facing outbound // tags now that the profile's tag map is in hand, so every per-candidate config - // this loop builds carries the same chain. An unresolvable pair (a node that - // vanished, or one the builder won't render) leaves ro untouched — a single hop. + // this loop builds carries the same chain, validated before any teardown. ro = resolveMultihop(ro, mh, tags) // Say it out loud when smart mode is about to run without its geodata. The // connect still succeeds — the geo rules are simply not emitted and everything @@ -299,7 +322,9 @@ func (d *Daemon) startConnect(ctx context.Context, p profile.Profile, explicitNo // Tear down any existing connection (and any in-flight loop) before starting a // new one so we never run two sing-box processes at once. - d.teardown(StateConnecting, p.ID, "") + if err := d.teardown(StateConnecting, p.ID, ""); err != nil { + return State{}, err + } runCtx, cancel := context.WithCancel(context.Background()) d.mu.Lock() @@ -322,18 +347,19 @@ func (d *Daemon) startConnect(ctx context.Context, p profile.Profile, explicitNo tun: tun, machine: m, remember: remember, - requestedNode: explicitNode, + requestedNode: requestedNode, } + // Publish the initial phase before the worker can finish, so a fast result + // cannot be followed by a stale Connecting event. The caller holds connMu + // through this publication and the launch, excluding a concurrent teardown. + st := State{State: StateConnecting, Profile: p.ID, Routing: string(ro.Mode)} + d.setState(st) d.wg.Add(1) go func() { defer d.wg.Done() d.runFallback(runCtx, loop) }() - // connect reports connecting immediately; connected/error arrive later as - // state events from the loop. - st := State{State: StateConnecting, Profile: p.ID, Routing: string(ro.Mode)} - d.setState(st) return d.snapshotState(), nil } @@ -422,8 +448,17 @@ func (d *Daemon) handleDisconnect(req Request) Response { // an in-flight relaunch/reconcile: that goroutine, blocked on connMu, wakes to // find the generation moved and yields instead of resurrecting the tunnel. d.connMu.Lock() - d.teardown(StateIdle, "", "") + cleanupErr := d.teardown(StateIdle, "", "") + d.protectionOp.Lock() + protectionErr := d.protection.Release() + d.protectionOp.Unlock() d.connMu.Unlock() + if protectionErr != nil { + protectionErr = fmt.Errorf("host protection release: %w", protectionErr) + } + if err := errors.Join(cleanupErr, protectionErr); err != nil { + return newError(req.ID, "disconnect: "+err.Error()) + } st := d.snapshotState() resp, err := newResult(req.ID, st) if err != nil { @@ -442,7 +477,13 @@ func (d *Daemon) handleDisconnect(req Request) Response { // follows it) is serialized against the off-command relaunch/reconcile connects. // It waits on d.wg, which never tracks those goroutines (they run under // relaunchWG), so the wait cannot deadlock against a connMu holder. -func (d *Daemon) teardown(newState ConnState, profileID, nodeID string) { +func (d *Daemon) teardown(newState ConnState, profileID, nodeID string) error { + // Acceptance holds protectionOp until Active/Connected is published. Claim + // cancellation under the same fence so that publication either finishes + // before teardown, or observes this generation as cancelled before any gates. + // Lock order is connMu -> protectionOp -> mu; recordSuccess never waits on + // connMu, and setting commands release protectionOp before reapplyLive. + d.protectionOp.Lock() d.mu.Lock() cancel := d.cancel d.cancel = nil @@ -461,12 +502,16 @@ func (d *Daemon) teardown(newState ConnState, profileID, nodeID string) { if cancel != nil { cancel() } + // An old fallback goroutine may be waiting to enter recordSuccess. It must + // acquire the fence, see cancellation and drain, so never hold it for wg.Wait. + d.protectionOp.Unlock() // Stop the process and wait for connection goroutines (the fallback loop, then // any watcher/poller it started) to finish before we declare the new state, so // events don't interleave across connections. The loop also stops the runner // itself on cancel; Stop is idempotent. _ = d.runner.Stop() d.wg.Wait() + d.protection.Interrupted() // Clear the OS system proxy AFTER the goroutines have drained — this is the // guard's single busiest chokepoint (every disconnect, hot-swap, connect @@ -475,7 +520,11 @@ func (d *Daemon) teardown(newState ConnState, profileID, nodeID string) { // be arming the proxy as it unwinds, and a disarm that ran earlier would leave // that late arm standing. It is idempotent and a no-op unless we armed it, so // tun-mode teardowns and the pre-start teardown of a fresh connect pay nothing. - d.disarmSystemProxy() + if err := d.disarmSystemProxy(); err != nil { + msg := fmt.Errorf("system proxy cleanup failed: %w", err) + d.setState(State{State: StateError, Error: msg.Error(), Routing: d.snapshotState().Routing}) + return msg + } switch newState { case StateIdle: @@ -485,6 +534,7 @@ func (d *Daemon) teardown(newState ConnState, profileID, nodeID string) { // interim value so a failure in between is still coherent. d.setState(State{State: newState, Profile: profileID, Node: nodeID, Routing: d.snapshotState().Routing}) } + return nil } // fallbackLoop bundles the immutable inputs of one connect's fallback walk so @@ -686,7 +736,7 @@ func (d *Daemon) runFallback(ctx context.Context, loop fallbackLoop) { switch outcome, reason := d.attemptNode(ctx, loop, attempt, tracker); outcome { case nodeConnected: return // attemptNode promoted to connected and started the lifecycle - case nodeSuperseded: + case nodeSuperseded, nodeLocalFailure: return // teardown owns the state; the runner is already stopped default: // nodeFailed tracker.blockedWithReason(attempt, reason) @@ -709,6 +759,8 @@ const ( // nodeFailed: the node did not come up under any strategy (or could not be // rendered/started); the loop marks it blocked and advances to the next node. nodeFailed + // nodeLocalFailure is an OS setup refusal; changing the remote node cannot fix it. + nodeLocalFailure ) // attemptNode tries one node across the transport-strategy cascade. It begins @@ -755,6 +807,15 @@ func (d *Daemon) attemptNode(ctx context.Context, loop fallbackLoop, attempt fal _ = d.runner.Stop() return nodeSuperseded, "" } + var local interface{ LocalSetupFailure() bool } + if errors.As(err, &local) && local.LocalSetupFailure() { + _ = d.runner.Stop() + msg := "local tunnel setup failed: " + err.Error() + tracker.blockedWithReason(attempt, "local setup failed") + d.emitLog(LogError, msg) + d.setState(State{State: StateError, Profile: loop.profileID, Error: msg, Routing: d.snapshotState().Routing}) + return nodeLocalFailure, "" + } d.emitLog(LogWarn, fmt.Sprintf("connect: sing-box would not start for %s: %v", who, err)) return nodeFailed, "" } @@ -768,7 +829,16 @@ func (d *Daemon) attemptNode(ctx context.Context, loop fallbackLoop, attempt fal if up { // Superseded mid-probe returns up=false, so reaching here is a genuine // success on the current generation. - d.recordSuccess(ctx, loop, attempt, tracker, strat, sel) + if err := d.recordSuccess(ctx, loop, attempt, tracker, strat, sel); err != nil { + _ = d.runner.Stop() + d.protection.Interrupted() + if d.isCurrent(loop.gen) { + tracker.blockedWithReason(attempt, "local setup failed") + d.emitLog(LogError, err.Error()) + d.setState(State{State: StateError, Profile: loop.profileID, Error: err.Error(), Routing: d.snapshotState().Routing}) + } + return nodeLocalFailure, "" + } return nodeConnected, "" } if !d.isCurrent(loop.gen) { @@ -819,7 +889,23 @@ func (d *Daemon) attemptNode(ctx context.Context, loop fallbackLoop, attempt fal // connection to the watcher/poller, and reconciles any option toggled during the // connecting window. strat is the strategy the node came up under, so a // non-default one is surfaced in the snapshot. -func (d *Daemon) recordSuccess(ctx context.Context, loop fallbackLoop, attempt fallback.Attempt, tracker *attemptTracker, strat fallback.Strategy, sel selectorShape) { +func (d *Daemon) recordSuccess(ctx context.Context, loop fallbackLoop, attempt fallback.Attempt, tracker *attemptTracker, strat fallback.Strategy, sel selectorShape) error { + d.protectionOp.Lock() + defer d.protectionOp.Unlock() + if ctx.Err() != nil || !d.isCurrent(loop.gen) { + return context.Canceled + } + if err := d.activateProtectionLocked(loop.ro, loop.tun); err != nil { + return fmt.Errorf("host protection: %w", err) + } + if loop.tun.IsSystemProxy() { + if err := d.armSystemProxy(loop.tun.MixedHostPort()); err != nil { + return fmt.Errorf("system proxy setup failed: %w", err) + } + } + if ctx.Err() != nil || !d.isCurrent(loop.gen) { + return context.Canceled + } loop.machine.Success(attempt) // Record what the process that just came up can be steered to, so a later exit // change can be decided against the config actually running rather than against @@ -850,17 +936,7 @@ func (d *Daemon) recordSuccess(ctx context.Context, loop fallbackLoop, attempt f // Capture the connected instant before publishing it, so the uptime the // relaunch budget reads later is measured from a fixed point. connectedAt := d.now() - // In system-proxy mode, point the OS at the loopback mixed inbound before we - // announce "connected", so the state never claims the tunnel is up while system - // traffic still egresses direct. The probe already confirmed the inbound carries - // traffic. The guard clears it again on any teardown (disconnect, hot-swap, - // shutdown) and on a tunnel-process death, so the OS is never left pointing at a - // proxy that is no longer listening. The address comes from loop.tun — the - // snapshot this connect built its config from — so a mid-connect mode change - // can't point the OS at the wrong port. - if loop.tun.IsSystemProxy() { - d.armSystemProxy(loop.tun.MixedHostPort()) - } + d.protection.Accepted() d.setState(State{State: StateConnected, Profile: loop.profileID, Node: attempt.NodeID, Routing: d.snapshotState().Routing}) // Hand the live connection off to the watcher/poller. @@ -868,6 +944,7 @@ func (d *Daemon) recordSuccess(ctx context.Context, loop fallbackLoop, attempt f // A kill-switch/tun toggle that landed during the connecting window was // recorded but not baked into this config; reconcile it now. d.reconcileConnectingOptions(loop, attempt.NodeID) + return nil } // probeUntilUp waits for the clash API to come up, then probes the selector @@ -895,8 +972,8 @@ func (d *Daemon) probeUntilUp(ctx context.Context, gen uint64, wantTag string) ( // not name — and then the probe measures one exit while the state reports // another. Pinning the selector to this config's default closes that, at the // cost of one loopback call. It is retried alongside the probe because the API - // may not be listening yet, and it is best-effort: a runner that cannot select - // is the runner whose probe is about to fail anyway. + // may not be listening yet. A successful probe is meaningful only after this + // pin succeeds; an unconfirmed selector may still carry a cached old exit. pinned := wantTag == "" for { @@ -916,6 +993,16 @@ func (d *Daemon) probeUntilUp(ctx context.Context, gen uint64, wantTag string) ( pinErr := d.runner.Select(pinCtx, proxySelectorTag, wantTag) cancelPin() pinned = pinErr == nil + if !pinned { + select { + case <-budget.Done(): + return false, false + case <-done: + return false, false + case <-time.After(d.probeRetry): + continue + } + } } probeCtx, cancelProbe := context.WithTimeout(budget, d.probeTimeout) @@ -1036,12 +1123,15 @@ func (d *Daemon) watchProcess(ctx context.Context, gen uint64, profileID, nodeID msg = err.Error() } d.emitLog(LogError, "tunnel process exited: "+msg) + d.protection.Interrupted() // The mixed inbound died with the process, so an armed system proxy now // points at nothing — clear it immediately to restore direct connectivity. // A kill-switch relaunch below re-arms it once the tunnel is back // (recordSuccess); the plain error path leaves it cleared, which is the // honest outcome (proxy mode has no strict_route to fail closed on). - d.disarmSystemProxy() + if restoreErr := d.disarmSystemProxy(); restoreErr != nil { + msg += "; system proxy restore failed: " + restoreErr.Error() + } if d.killSwitchRelaunch(gen, profileID, nodeID, d.now().Sub(connectedAt)) { return // the relaunch owns the state from here } @@ -1071,12 +1161,9 @@ const defaultRelaunchReset = 30 * time.Second // state; false means the caller should fall through to the plain error state. // uptime is how long the dead tunnel held the connection. // -// Why restart at all: strict_route only holds while sing-box runs — the moment -// the process dies, its filter rules and the tun route die with it, and traffic -// would fall back to the physical interface. The honest mitigation the daemon -// can offer is to put the tunnel (and its filters) back immediately, pinned to -// the node the user was on. During the gap the OS is unprotected; that window -// is why this relaunches eagerly rather than waiting for the user. +// Persistent host protection remains installed through process death and retry +// exhaustion. Relaunch restores availability on the user's node; it is not the +// mechanism that blocks direct traffic while the process is absent. // // The budget: a relaunch that held the tunnel up past relaunchResetAfter proved a // recovery, not one more turn of a crash-loop, so its eventual death refunds the @@ -1404,6 +1491,7 @@ func (d *Daemon) isCurrent(gen uint64) bool { // setState replaces the connection state and emits a state event reflecting it. func (d *Daemon) setState(s State) { + s.Protection = d.protection.Snapshot() d.mu.Lock() // Preserve the routing label if the new state didn't set one. if s.Routing == "" { @@ -1430,16 +1518,17 @@ func (d *Daemon) setState(s State) { } // stateEventBody projects a State into the state event payload (which omits the -// profile field — the protocol's state event carries state/node/error only). +// profile field — the protocol carries state/node/error and protection). func stateEventBody(s State) stateEvent { - return stateEvent{State: s.State, Node: s.Node, Error: s.Error} + return stateEvent{State: s.State, Node: s.Node, Error: s.Error, Protection: s.Protection} } // stateEvent is the wire body of a state event. type stateEvent struct { - State ConnState `json:"state"` - Node string `json:"node,omitempty"` - Error string `json:"error,omitempty"` + State ConnState `json:"state"` + Node string `json:"node,omitempty"` + Error string `json:"error,omitempty"` + Protection protection.State `json:"protection"` } // emitTraffic pushes a traffic counter event to the UI, if an emitter is set. @@ -1543,12 +1632,12 @@ func (d *Daemon) Close() error { // where it came from. Best-effort — a failure here must not hold up shutdown. d.stopZapretQuietly() d.connMu.Lock() - d.teardown(StateIdle, "", "") + teardownErr := d.teardown(StateIdle, "", "") d.connMu.Unlock() // teardown already disarmed the system proxy; clear it once more defensively so // process shutdown never leaves the OS pointing at a dead proxy even if some // path armed it after the teardown. Idempotent — a no-op when already clear. - d.disarmSystemProxy() + cleanupErr := d.disarmSystemProxy() // The teardown above bumped the generation, so any kill-switch relaunch or // connecting-window reconcile still in flight will observe it and abort instead // of starting a tunnel. Wait for those goroutines to unwind (after releasing @@ -1557,7 +1646,7 @@ func (d *Daemon) Close() error { // The entitlement lookups were cancelled above; wait for them to unwind so // none outlives us writing to the store. d.entWG.Wait() - return nil + return errors.Join(teardownErr, cleanupErr) } // selectorShape is what the built config's proxy selector looks like: the tag it diff --git a/core/control/connect_publication_audit_test.go b/core/control/connect_publication_audit_test.go new file mode 100644 index 00000000..0bdb8961 --- /dev/null +++ b/core/control/connect_publication_audit_test.go @@ -0,0 +1,81 @@ +package control + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestConnectInitialPublicationPrecedesFallbackCompletion(t *testing.T) { + d, _, p := coreAuditDaemon(t) + initial, release, connected := make(chan struct{}), make(chan struct{}), make(chan struct{}) + var releaseOnce, connectedOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + defer unblock() + var connecting atomic.Int32 + var mu sync.Mutex + var published []ConnState + d.SetEmitter(func(name string, body any) { + state, ok := body.(stateEvent) + if name != EventState || !ok { + return + } + // The first Connecting comes from teardown. Park the connect's own + // initial publication before delivery, as a preemption before enqueue + // can do; no production state or scheduler hooks are changed. + if state.State == StateConnecting && connecting.Add(1) == 2 { + close(initial) + <-release + } + mu.Lock() + published = append(published, state.State) + mu.Unlock() + if state.State == StateConnected { + connectedOnce.Do(func() { close(connected) }) + } + }) + done := make(chan error, 1) + go func() { + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, "", false, false, "") + d.connMu.Unlock() + done <- err + }() + select { + case <-initial: + case <-time.After(time.Second): + t.Fatal("initial publication barrier not reached") + } + select { + case <-connected: + case <-time.After(50 * time.Millisecond): + // Correct ordering may not start fallback until this publication returns. + } + unblock() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("connect did not return") + } + select { + case <-connected: + case <-time.After(time.Second): + t.Fatal("fallback never connected") + } + mu.Lock() + states := append([]ConnState(nil), published...) + mu.Unlock() + finished := false + for _, state := range states { + if state == StateConnected { + finished = true + } else if finished && state == StateConnecting { + t.Fatalf("initial state overtook fallback result: events=%v final=%s", states, d.snapshotState().State) + } + } +} diff --git a/core/control/core_audit_regression_test.go b/core/control/core_audit_regression_test.go new file mode 100644 index 00000000..a257ee1b --- /dev/null +++ b/core/control/core_audit_regression_test.go @@ -0,0 +1,411 @@ +package control + +import ( + "context" + "errors" + "net" + "net/http" + "runtime" + "strings" + "testing" + "time" + + "github.com/Divaaaan/tenebra/core/fallback" + "github.com/Divaaaan/tenebra/core/model" + "github.com/Divaaaan/tenebra/core/profile" + "github.com/Divaaaan/tenebra/core/zapret" +) + +// No Server/Daemon.Close: those cleanups can stop the host's real bypass. +// All engine, network and bypass operations used below are injected. +func coreAuditDaemon(t *testing.T) (*Daemon, *fakeRunner, profile.Profile) { + t.Helper() + s, err := profile.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + p, err := profile.NewProfile("audit", profile.SourceManual, "", []model.Node{ + {Protocol: model.VLESS, Name: "Entry", Server: "192.0.2.1", Port: 443, UUID: "123e4567-e89b-12d3-a456-426614174000"}, + {Protocol: model.VLESS, Name: "Exit", Server: "192.0.2.2", Port: 443, UUID: "123e4567-e89b-12d3-a456-426614174001"}, + }) + if err != nil { + t.Fatal(err) + } + if err = s.Add(p); err != nil { + t.Fatal(err) + } + r := newFakeRunner() + d := newUnitTestDaemon(s, r) + d.localAddrs = func() []net.Addr { return nil } + d.tunWatchInterval, d.healthInterval, d.bypassVerifyDelay = 0, 0, 0 + d.proxy = &fakeProxyController{} + d.classify = func(context.Context, model.Node, bool) fallback.FailureClass { return fallback.Unknown } + d.probeWarmup, d.probeRetry, d.probeTimeout, d.probeBudget = time.Millisecond, time.Millisecond, 20*time.Millisecond, 30*time.Millisecond + d.lastGood = fallback.NewMemLastGood() + d.netFingerprint = func() string { return "test-network" } + d.zapretExclude = func(string, []string, []zapret.Lookup) (zapret.ExcludeReport, error) { + return zapret.ExcludeReport{}, nil + } + stubStarts(d) + t.Cleanup(func() { + d.connMu.Lock() + d.teardown(StateIdle, "", "") + d.connMu.Unlock() + d.relaunchWG.Wait() + d.entCancel() + }) + return d, r, p +} + +func coreAuditConnect(t *testing.T, d *Daemon, p profile.Profile, node string) State { + t.Helper() + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, node, false, false, "") + d.connMu.Unlock() + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + s := d.snapshotState() + if s.State == StateConnected { + return s + } + if s.State == StateError { + t.Fatal(s.Error) + } + time.Sleep(time.Millisecond) + } + t.Fatal("no connected state") + return State{} +} + +func TestCoreAuditMultihopUsesEffectiveExit(t *testing.T) { + d, r, p := coreAuditDaemon(t) + d.multihop = model.Multihop{Enabled: true, EntryID: p.Servers[0].ID, ExitID: p.Servers[1].ID} + s := coreAuditConnect(t, d, p, p.Servers[0].ID) + exit, entry := selectedOutboundDetour(t, r.startCfgs()[0]) + if exit != "Exit" || entry != "Entry" { + t.Fatalf("wrong topology %s via %s", exit, entry) + } + if s.Node != p.Servers[1].ID { + t.Errorf("state node=%s, want exit %s", s.Node, p.Servers[1].ID) + } + if id, _ := d.lastGood.Get(p.ID); id != p.Servers[1].ID { + t.Errorf("last-good=%s, want exit", id) + } + if got := d.exitServer(s); got != "192.0.2.2" { + t.Errorf("leak-check exit=%s, want 192.0.2.2", got) + } +} + +func TestCoreAuditMultihopRejectsStaleOrUnsupportedConnect(t *testing.T) { + for _, bad := range []string{"missing-entry", "missing-exit", "unsupported-entry", "unsupported-exit"} { + t.Run(bad, func(t *testing.T) { + d, r, p := coreAuditDaemon(t) + d.multihop = model.Multihop{Enabled: true, EntryID: p.Servers[0].ID, ExitID: p.Servers[1].ID} + switch bad { + case "missing-entry": + p.Servers = p.Servers[1:] + case "missing-exit": + p.Servers = p.Servers[:1] + case "unsupported-entry": + p.Servers[0].Protocol = model.AmneziaWG + case "unsupported-exit": + p.Servers[1].Protocol = model.AmneziaWG + } + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, "", false, false, "") + d.connMu.Unlock() + if err == nil { + t.Error("connect accepted an invalid multihop chain") + } + if r.starts() != 0 || r.stops() != 0 { + t.Errorf("invalid chain touched engine: starts=%d stops=%d", r.starts(), r.stops()) + } + }) + } +} + +func TestCoreAuditMultihopRejectsUnsupportedCommand(t *testing.T) { + d, _, p := coreAuditDaemon(t) + p.Servers[0].Protocol = model.AmneziaWG + if err := d.store.Update(p); err != nil { + t.Fatal(err) + } + r := d.handleSetMultihop(Request{ID: 1, Enabled: true, Profile: p.ID, EntryID: p.Servers[0].ID, ExitID: p.Servers[1].ID}) + if r.Ok || d.multihop.Enabled { + t.Fatal("unsupported entry accepted and persisted") + } +} + +func TestCoreAuditRefreshRejectsLossOfSelectedMultihop(t *testing.T) { + d, _, p := coreAuditDaemon(t) + p.Source, p.URL = profile.SourceSubscription, "https://example.test/sub" + if err := d.store.Update(p); err != nil { + t.Fatal(err) + } + d.multihop = model.Multihop{Enabled: true, EntryID: p.Servers[0].ID, ExitID: p.Servers[1].ID} + d.fetch = func(context.Context, string) ([]byte, http.Header, error) { + return []byte("vless://123e4567-e89b-12d3-a456-426614174001@192.0.2.2:443#Exit"), http.Header{}, nil + } + r := d.handleRefreshSubscription(context.Background(), Request{ID: 1, Profile: p.ID}) + if r.Ok { + t.Error("refresh accepted loss of enabled entry") + } + stored, _ := d.store.Get(p.ID) + if len(stored.Servers) != 2 { + t.Errorf("refresh replaced valid stored chain: nodes=%d", len(stored.Servers)) + } +} + +func TestCoreAuditSelectorPinMustSucceed(t *testing.T) { + d, r, _ := coreAuditDaemon(t) + _ = r.Start(context.Background(), nil) + r.selectErr = errors.New("selector unavailable") + up, _ := d.probeUntilUp(context.Background(), 0, "desired-node") + if up { + t.Fatal("successful probe of unconfirmed selector accepted") + } + if len(r.selectCalls()) < 2 { + t.Error("failed selector was not retried") + } +} + +func TestCoreAuditStartupBypassHonorsOffAfterMutexWait(t *testing.T) { + d, _, _ := coreAuditDaemon(t) + seedBypassBundle(t, d.store.Dir(), "general (FAKE TLS AUTO)") + starts := stubStarts(d) + d.zapretOpMu.Lock() + done := make(chan bool, 1) + go func() { done <- d.autoStartZapret(context.Background(), false) }() + deadline := time.Now().Add(time.Second) + waiting := false + for time.Now().Before(deadline) { + b := make([]byte, 65536) + n := runtime.Stack(b, true) + s := string(b[:n]) + if strings.Contains(s, "(*Daemon).acquireZapretOp") && strings.Contains(s, "(*Daemon).autoStartZapret") { + waiting = true + break + } + time.Sleep(time.Millisecond) + } + if !waiting { + d.zapretOpMu.Unlock() + t.Fatal("startup did not wait for operation mutex") + } + d.recordZapretWish(false) + d.zapretOpMu.Unlock() + select { + case up := <-done: + if up || len(starts.names()) != 0 { + t.Fatal("startup raised bypass after authoritative OFF") + } + case <-time.After(time.Second): + t.Fatal("startup remained blocked") + } + if !d.zapretSwitchedOff() { + t.Fatal("OFF wish lost") + } +} + +func TestCoreAuditHealthRecoveryRespectsCooldownAndBudget(t *testing.T) { + for _, steerable := range []bool{false, true} { + for _, exhausted := range []bool{false, true} { + d, r, p := coreAuditDaemon(t) + _ = r.Start(context.Background(), nil) + d.generation = 1 + d.setState(State{State: StateConnected, Profile: p.ID, Node: p.Servers[0].ID}) + if steerable { + d.setLiveConfig(1, p.ID, serverTags(p), selectorShape{Default: "Entry", Members: []string{"Entry", "Exit"}}) + } + d.autoSwitches = []time.Time{d.now()} + if exhausted { + d.autoSwitches = nil + for i := 0; i < d.maxAutoSwitches; i++ { + d.autoSwitches = append(d.autoSwitches, d.now().Add(-d.autoSwitchCooldown-time.Second)) + } + } + d.healthInterval, d.healthFailThreshold = time.Millisecond, 1 + d.healthProbe = func(context.Context) error { return errors.New("synthetic unhealthy tunnel") } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + d.healthWatch(ctx, 1, p.ID, p.Servers[0].ID) + cancel() + d.relaunchWG.Wait() + if r.starts() != 1 { + t.Errorf("steerable=%v exhausted=%v: recovery bypassed policy, starts=%d", steerable, exhausted, r.starts()) + } + if d.snapshotState().Node != p.Servers[0].ID { + t.Errorf("steerable=%v exhausted=%v: recovery switched exit despite policy", steerable, exhausted) + } + } + } +} + +func TestCoreAuditFallbackReconnectConsumesSharedBudget(t *testing.T) { + d, r, p := coreAuditDaemon(t) + _ = r.Start(context.Background(), nil) + r.selectErr, r.selectErrThroughStart = errors.New("old API unavailable"), 1 + d.generation = 1 + d.setState(State{State: StateConnected, Profile: p.ID, Node: p.Servers[0].ID}) + if got := d.healthFailover(1, p.ID, p.Servers[0].ID); got != failoverStarted { + t.Fatalf("reconnect not scheduled: %v", got) + } + d.relaunchWG.Wait() + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if d.snapshotState().State == StateConnected { + break + } + time.Sleep(time.Millisecond) + } + s := d.snapshotState() + if s.State != StateConnected || s.Node != p.Servers[1].ID { + t.Fatalf("fallback did not connect alternative: %+v", s) + } + d.mu.Lock() + gen := d.generation + spent := len(d.autoSwitches) + d.mu.Unlock() + if spent != 1 { + t.Errorf("fallback consumed %d budget entries, want 1", spent) + } + if d.allowAutoSwitch(gen, p.ID, p.Servers[1].ID) { + t.Error("fallback reconnect did not constrain subsequent live switch") + } + if got := d.healthFailover(gen, p.ID, p.Servers[1].ID); got == failoverStarted { + t.Error("second fallback scheduled inside cooldown") + } +} + +func TestCoreAuditQueuedRecoveryYieldsToManualSwitch(t *testing.T) { + d, r, p := coreAuditDaemon(t) + _ = r.Start(context.Background(), nil) + d.generation = 1 + d.setState(State{State: StateConnected, Profile: p.ID, Node: p.Servers[0].ID}) + queued, release := make(chan struct{}), make(chan struct{}) + d.beforeReconnect = func() { close(queued); <-release } + if d.healthFailover(1, p.ID, p.Servers[0].ID) != failoverStarted { + t.Fatal("recovery not scheduled") + } + <-queued + d.setState(State{State: StateConnected, Profile: p.ID, Node: p.Servers[1].ID}) + close(release) + d.relaunchWG.Wait() + if r.starts() != 1 || d.snapshotState().Node != p.Servers[1].ID { + t.Fatal("queued recovery overruled manual switch") + } + if len(d.autoSwitches) != 0 { + t.Fatal("cancelled recovery spent a budget entry") + } +} + +func TestCoreAuditMultihopHasNoAutomaticAlternativeExit(t *testing.T) { + d, r, p := coreAuditDaemon(t) + d.multihop = model.Multihop{Enabled: true, EntryID: p.Servers[0].ID, ExitID: p.Servers[1].ID} + _ = r.Start(context.Background(), nil) + d.generation = 1 + d.setState(State{State: StateConnected, Profile: p.ID, Node: p.Servers[1].ID}) + if got := d.healthFailover(1, p.ID, p.Servers[1].ID); got != failoverNoAlternative { + t.Errorf("fixed chain scheduled another exit: %v", got) + } + d.relaunchWG.Wait() + if len(d.autoSwitches) != 0 { + t.Error("unavailable chain failover spent budget") + } +} + +type retryPinRunner struct { + *fakeRunner + failures int +} + +func (r *retryPinRunner) Select(ctx context.Context, group, tag string) error { + if err := r.fakeRunner.Select(ctx, group, tag); err != nil { + return err + } + if r.failures > 0 { + r.failures-- + return errors.New("selector not ready") + } + return nil +} + +func TestCoreAuditSelectorRetriesBeforeTestingTraffic(t *testing.T) { + d, r, _ := coreAuditDaemon(t) + _ = r.Start(context.Background(), nil) + d.runner = &retryPinRunner{fakeRunner: r, failures: 2} + up, _ := d.probeUntilUp(context.Background(), 0, "Exit") + if !up { + t.Fatal("eventual successful selector pin did not connect") + } + if got := len(r.selectCalls()); got != 3 { + t.Errorf("pin calls=%d, want 3", got) + } + r.mu.Lock() + probes := r.probeN + r.mu.Unlock() + if probes != 1 { + t.Errorf("traffic tested %d times, want only after confirmed pin", probes) + } +} + +func TestCoreAuditHealthWatchSurvivesCancelledQueuedRecovery(t *testing.T) { + d, r, p := coreAuditDaemon(t) + _ = r.Start(context.Background(), nil) + d.generation = 1 + d.setState(State{State: StateConnected, Profile: p.ID, Node: p.Servers[0].ID}) + d.healthInterval, d.healthFailThreshold = time.Millisecond, 1 + queued, release, probedAgain := make(chan struct{}), make(chan struct{}), make(chan struct{}, 1) + d.beforeReconnect = func() { close(queued); <-release } + calls := 0 + d.healthProbe = func(context.Context) error { + calls++ + if calls == 1 { + return errors.New("unhealthy") + } + select { + case probedAgain <- struct{}{}: + default: + } + return nil + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { d.healthWatch(ctx, 1, p.ID, p.Servers[0].ID); close(done) }() + <-queued + d.setState(State{State: StateConnected, Profile: p.ID, Node: p.Servers[1].ID}) + close(release) + d.relaunchWG.Wait() + select { + case <-probedAgain: + case <-time.After(40 * time.Millisecond): + t.Error("watchdog stopped after queued recovery yielded to user") + } + cancel() + <-done +} + +func TestCoreAuditMultihopRejectsChainForAnotherLiveProfile(t *testing.T) { + d, r, p := coreAuditDaemon(t) + _ = r.Start(context.Background(), nil) + d.generation = 1 + d.setState(State{State: StateConnected, Profile: p.ID, Node: p.Servers[0].ID}) + nodes := profileNodes(p) + nodes[0].Server, nodes[1].Server = "192.0.2.3", "192.0.2.4" + other, err := profile.NewProfile("other", profile.SourceManual, "", nodes) + if err != nil { + t.Fatal(err) + } + if err = d.store.Add(other); err != nil { + t.Fatal(err) + } + resp := d.handleSetMultihop(Request{ID: 1, Enabled: true, Profile: other.ID, EntryID: other.Servers[0].ID, ExitID: other.Servers[1].ID}) + if resp.Ok { + t.Error("accepted chain incompatible with the live profile") + } + if d.multihop.Enabled || d.snapshotState().State != StateConnected || r.starts() != 1 || r.stops() != 0 { + t.Error("rejected chain changed current tunnel/state") + } +} diff --git a/core/control/daemon.go b/core/control/daemon.go index 1d39cce8..efa2cc5a 100644 --- a/core/control/daemon.go +++ b/core/control/daemon.go @@ -19,6 +19,7 @@ import ( "github.com/Divaaaan/tenebra/core/model" "github.com/Divaaaan/tenebra/core/nodecheck" "github.com/Divaaaan/tenebra/core/profile" + "github.com/Divaaaan/tenebra/core/protection" "github.com/Divaaaan/tenebra/core/routing" "github.com/Divaaaan/tenebra/core/singbox" "github.com/Divaaaan/tenebra/core/subscription" @@ -97,8 +98,10 @@ const defaultClientWriteTimeout = 30 * time.Second // lifecycle also spawns goroutines (traffic poll, process watch) that mutate // state, so every field touched from more than one goroutine is guarded by mu. type Daemon struct { - store *profile.Store - runner Runner + store *profile.Store + runner Runner + protection *protection.Guard + protectionOp sync.Mutex // serializes desired-setting changes with apply/activate // proxy applies and clears the OS-wide system proxy for ModeSystemProxy. It is // set once at construction (realSystemProxy in production, a fake in tests) and @@ -190,10 +193,13 @@ type Daemon struct { routing routing.Options state State tun singbox.TunOptions - // proxyArmed records whether the daemon currently has the OS system proxy - // pointed at our mixed inbound, so disarmSystemProxy clears it exactly once and - // never touches a proxy we didn't set. Guarded by mu. - proxyArmed bool + // proxyMu serializes apply/rollback; the fields below are also protected by mu + // when inspected with the rest of the daemon state. Armed means cleanup is + // owed, including a partial apply. Applied is true only after confirmed success. + proxyMu sync.Mutex + proxyArmed bool + proxyApplied bool + proxyTarget string // emit is set by the server via SetEmitter before serving; the daemon calls // it to publish state/traffic/log events. Guarded by mu. @@ -496,9 +502,10 @@ type Daemon struct { // with the stack pinned explicitly so the reported state always names it. func NewDaemon(store *profile.Store, runner Runner) *Daemon { d := &Daemon{ - store: store, - runner: runner, - proxy: realSystemProxy{}, + store: store, + runner: runner, + proxy: newSystemProxyController(), + protection: protection.New(nil), // Only UnblockServices ships on, and the split is by direction rather than // by convenience. It pins censored domains *to* the tunnel ahead of the geo // rule, which is what stops YouTube from being sent direct because @@ -1014,8 +1021,10 @@ func (d *Daemon) Handle(ctx context.Context, req Request) Response { // snapshotState returns a copy of the current state under lock. func (d *Daemon) snapshotState() State { d.mu.Lock() - defer d.mu.Unlock() - return d.state + s := d.state + d.mu.Unlock() + s.Protection = d.protection.Snapshot() + return s } // snapshotRouting returns a copy of the live routing options under lock. The @@ -1359,6 +1368,17 @@ func (d *Daemon) refreshProfile(ctx context.Context, p profile.Profile) (profile before := p p.Servers = rebuilt.Servers p.UpdatedAt = rebuilt.UpdatedAt + // Keep the last valid profile if this refresh removes an enabled hop. The + // refresh command returns the error, and background refresh logs it; neither + // may replace a promised chain with a silently downgraded next connection. + d.mu.Lock() + mh := d.multihop + d.mu.Unlock() + if mh.Enabled && (hasServer(before, mh.EntryID) || hasServer(before, mh.ExitID)) { + if err := validateMultihopProfile(p, mh); err != nil { + return profile.Profile{}, false, fmt.Errorf("refresh refused: %w", err) + } + } // Only refresh traffic/expiry when this response actually carries a user-info // header. A refresh that returns the node list but no Subscription-Userinfo // (some panels send it only intermittently) must preserve the known quota and @@ -1636,18 +1656,38 @@ func sameStrings(a, b []string) bool { // persisted, and — unlike set_routing/set_split — applied to a live tunnel in // place: the daemon rebuilds the config for the node it is already on and // hot-swaps the sing-box process (see reapplyLive), so arming doesn't wait for -// the user to reconnect. Armed means strict_route on the tun (sing-box installs -// filter rules that drop any packet trying to escape the tunnel) plus an -// automatic relaunch if the tunnel process itself dies (see watchProcess). +// the user to reconnect. The preference alone does not claim enforcement: +// State.Protection reports the independent persistent guard's actual result. func (d *Daemon) handleSetKillSwitch(req Request) Response { + d.protectionOp.Lock() + before := d.protection.Snapshot() d.mu.Lock() changed := d.routing.KillSwitch != req.On d.routing.KillSwitch = req.On applySettingsToState(&d.state, d.routing, d.tun, d.autoconnect, d.autoFailover, d.crashReports, d.multihop) d.mu.Unlock() + var protectionErr error + if !req.On { + // Retry even if the desired value is already OFF: an earlier removal may + // have failed after settings were saved. Only explicit commands release. + protectionErr = d.protection.Release() + } else if !d.protection.LegacyEngineOnly() { + cur := d.snapshotState() + if (changed || before.Status != "active") && (cur.State == StateConnected || cur.State == StateConnecting || cur.Protection.Enforced || before.Status == "error") { + if err := protection.ValidateDNS(d.snapshotRouting().Normalize().DNSDirect); err != nil { + protectionErr = d.protection.Reject(err) + } else { + protectionErr = d.protection.Prepare() + } + } + } + d.protectionOp.Unlock() d.persistSettings() - if changed { + if protectionErr != nil { + return newError(req.ID, "host protection: "+protectionErr.Error()) + } + if changed || before.Status == "error" { d.reapplyLive() } @@ -1693,7 +1733,7 @@ func (d *Daemon) handleSetTLSFragment(req Request) Response { // so a bad pick is rejected whole rather than half-applied; disabling ignores the // IDs but keeps them recorded so the UI can re-enable the last pick. The IDs are // resolved to outbound tags against the connecting profile later (resolveMultihop), -// so a selection that no longer resolves simply degrades to a single hop. +// and revalidated on connect and subscription refresh. func (d *Daemon) handleSetMultihop(req Request) Response { mh := model.Multihop{Enabled: req.Enabled, EntryID: req.EntryID, ExitID: req.ExitID} if mh.Enabled { @@ -1713,6 +1753,18 @@ func (d *Daemon) handleSetMultihop(req Request) Response { if !hasServer(p, mh.ExitID) { return newError(req.ID, "set_multihop: exit node not in profile") } + if err := validateMultihopProfile(p, mh); err != nil { + return newError(req.ID, "set_multihop: "+err.Error()) + } + // The setting applies to the active tunnel immediately. Validating only + // req.Profile could advertise its chain over a different live profile. + cur := d.snapshotState() + if (cur.State == StateConnected || cur.State == StateConnecting) && cur.Profile != p.ID { + liveProfile, ok := d.store.Get(cur.Profile) + if !ok || validateMultihopProfile(liveProfile, mh) != nil { + return newError(req.ID, "set_multihop: selected chain is incompatible with the current connection") + } + } } d.mu.Lock() @@ -1744,24 +1796,30 @@ func hasServer(p profile.Profile, id string) bool { return false } +func validateMultihopProfile(p profile.Profile, mh model.Multihop) error { + if !mh.Enabled { + return nil + } + if !mh.Valid() { + return fmt.Errorf("multihop: entry and exit must name distinct nodes") + } + if !hasServer(p, mh.EntryID) || !hasServer(p, mh.ExitID) { + return fmt.Errorf("multihop: selected entry or exit is no longer in this profile") + } + tags := serverTags(p) + return singbox.ValidateMultihop(profileNodes(p), tags[mh.EntryID], tags[mh.ExitID]) +} + // resolveMultihop folds a stored multihop selection (server IDs) into the routing // options the builder consumes (outbound tags), using the tag map the connecting // profile produces (serverTags). It engages only for a valid, distinct pair whose -// IDs both resolve to a tag the builder will actually emit; anything else leaves -// the options untouched so the build degrades to a normal single hop rather than -// carrying a dangling detour. tags maps a server ID to its outbound tag. +// IDs both resolve to a tag the builder will actually emit. Validation happens +// before connect; even an invalid enabled selection remains enabled here so the +// builder rejects it. tags maps a server ID to its outbound tag. func resolveMultihop(ro routing.Options, mh model.Multihop, tags map[string]string) routing.Options { - if !mh.Valid() { - return ro - } - entryTag := tags[mh.EntryID] - exitTag := tags[mh.ExitID] - if entryTag == "" || exitTag == "" || entryTag == exitTag { - return ro - } - ro.Multihop = true - ro.MultihopEntry = entryTag - ro.MultihopExit = exitTag + ro.Multihop = mh.Enabled + ro.MultihopEntry = tags[mh.EntryID] + ro.MultihopExit = tags[mh.ExitID] return ro } @@ -1919,6 +1977,18 @@ func (d *Daemon) handleSetDNS(req Request) Response { if !routing.ValidDNSServer(req.DNSDirect) { return newError(req.ID, fmt.Sprintf("set_dns: invalid direct resolver %q", req.DNSDirect)) } + // Serialize the protection check and preference write with ON/OFF. Otherwise + // ON can validate the old encrypted endpoint while this command saves a new + // plaintext endpoint based on an earlier unprotected snapshot. + d.protectionOp.Lock() + if _, required := d.ProtectionDNS(); required { + next := d.snapshotRouting() + next.DNSDirect = req.DNSDirect + if err := protection.ValidateDNS(next.Normalize().DNSDirect); err != nil { + d.protectionOp.Unlock() + return newError(req.ID, "set_dns: "+err.Error()) + } + } d.mu.Lock() // d.routing is always kept normalized, so "before" already holds the effective @@ -1935,6 +2005,7 @@ func (d *Daemon) handleSetDNS(req Request) Response { changed := dnsPrefsDiffer(before, d.routing) applySettingsToState(&d.state, d.routing, d.tun, d.autoconnect, d.autoFailover, d.crashReports, d.multihop) d.mu.Unlock() + d.protectionOp.Unlock() // reapplyLive acquires connMu and later protectionOp d.persistSettings() if changed { diff --git a/core/control/daemon_fixture_test.go b/core/control/daemon_fixture_test.go new file mode 100644 index 00000000..24b80750 --- /dev/null +++ b/core/control/daemon_fixture_test.go @@ -0,0 +1,82 @@ +package control + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "strings" + "testing" + + "github.com/Divaaaan/tenebra/core/profile" +) + +// A fake engine does not isolate the daemon's other OS adapters. In particular, +// a successful system-proxy connect otherwise runs networksetup on macOS even +// though the fake engine has no listening proxy. Unit fixtures must opt into +// fakes before starting any connection; individual tests may replace them with +// a different scripted implementation when exercising an error path. +func newUnitTestDaemon(store *profile.Store, runner Runner) *Daemon { + d := NewDaemon(store, runner) + d.proxy = &fakeProxyController{} + return d +} + +func TestUnitDaemonStartsWithFakeSystemProxy(t *testing.T) { + store, err := profile.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + d := newUnitTestDaemon(store, newFakeRunner()) + t.Cleanup(d.entCancel) + if _, ok := d.proxy.(*fakeProxyController); !ok { + t.Fatalf("unit fixture uses host proxy controller %T", d.proxy) + } +} + +// A forgotten injection in a new fixture must fail without needing to mutate +// the test machine to discover it. The only direct production constructor call +// in unit tests belongs to the adapter-injecting factory above. +func TestUnitDaemonConstructorsCannotBypassProxyIsolation(t *testing.T) { + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + fset := token.NewFileSet() + calls := 0 + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), "_test.go") { + continue + } + file, err := parser.ParseFile(fset, entry.Name(), nil, 0) + if err != nil { + t.Fatal(err) + } + var factory *ast.FuncDecl + if entry.Name() == "daemon_fixture_test.go" { + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == "newUnitTestDaemon" { + factory = fn + } + } + } + ast.Inspect(file, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + name, ok := call.Fun.(*ast.Ident) + if !ok || name.Name != "NewDaemon" { + return true + } + calls++ + if factory == nil || call.Pos() < factory.Pos() || call.End() > factory.End() { + t.Errorf("%s: use newUnitTestDaemon so the fixture cannot apply the host proxy", fset.Position(call.Pos())) + } + return true + }) + } + if calls != 1 { + t.Errorf("direct NewDaemon calls in unit fixtures = %d, want the one isolated factory", calls) + } +} diff --git a/core/control/daemon_test.go b/core/control/daemon_test.go index e6cdd65f..2da8a69a 100644 --- a/core/control/daemon_test.go +++ b/core/control/daemon_test.go @@ -77,7 +77,7 @@ func daemonWithSecretProfile(t *testing.T) (*Daemon, profile.Profile) { if err := store.Add(p); err != nil { t.Fatalf("add profile: %v", err) } - return NewDaemon(store, newFakeRunner()), p + return newUnitTestDaemon(store, newFakeRunner()), p } // assertNoSecrets fails if any seeded secret sentinel appears anywhere in the diff --git a/core/control/fake_runner_test.go b/core/control/fake_runner_test.go index 32113f0f..34260311 100644 --- a/core/control/fake_runner_test.go +++ b/core/control/fake_runner_test.go @@ -75,6 +75,9 @@ type fakeRunner struct { // cannot steer, which must degrade to a full reconnect. selects []selectCall selectErr error + // Positive limits selectErr to the first N processes, allowing tests to model + // a broken live API that recovers after the process is restarted. + selectErrThroughStart int // viaDelays and viaErrs script ProbeVia per outbound tag: a tag present in // viaErrs fails, otherwise the delay from viaDelays (or viaDefault) is @@ -176,6 +179,9 @@ func (f *fakeRunner) Select(ctx context.Context, group, tag string) error { f.mu.Lock() f.selects = append(f.selects, selectCall{group: group, tag: tag}) err := f.selectErr + if f.selectErrThroughStart > 0 && f.startN > f.selectErrThroughStart { + err = nil + } f.mu.Unlock() if cerr := ctx.Err(); cerr != nil { return cerr diff --git a/core/control/health.go b/core/control/health.go index 4444a24d..e504d3b8 100644 --- a/core/control/health.go +++ b/core/control/health.go @@ -83,14 +83,18 @@ func (d *Daemon) healthWatch(ctx context.Context, gen uint64, profileID, nodeID // session, and everything already open finishes on the old exit instead of // being cut. Only when that is impossible or does not hold up does this // fall through to the reconnect-based failover. - if d.autoSwitchAway(ctx, gen, profileID, active) { + switch d.autoSwitchAway(ctx, gen, profileID, active) { + case autoSwitchSucceeded, autoSwitchSuppressed: fails, warnedNoAlt = 0, false continue } switch d.healthFailover(gen, profileID, active) { case failoverStarted: - return // the reconnect owns the connection from here + // Scheduling does not yet transfer ownership: the queued reconnect + // can yield to a manual switch or fail validation. Keep monitoring + // until an actual teardown cancels this context/generation. + fails, warnedNoAlt = 0, false case failoverNoAlternative: // A single-node profile has nowhere to fail over to. Keep monitoring so // a later subscription refresh or a recovery is still picked up, but warn @@ -133,8 +137,8 @@ func (d *Daemon) defaultHealthProbe(ctx context.Context) error { type failoverResult int const ( - // failoverStarted: a reconnect to another node was launched; it now owns the - // connection and the watchdog should return. + // failoverStarted: a reconnect was queued. The current watchdog remains until + // that reconnect actually cancels its generation. failoverStarted failoverResult = iota // failoverNoAlternative: the profile has no other node to move to, so the // current connection is left as-is and the watchdog keeps monitoring. @@ -148,10 +152,21 @@ const ( // connect walk with that node excluded so it lands on a different exit. It first // confirms another renderable node exists — otherwise there is nothing to fail // over to and the (possibly recoverable) tunnel is left running. The reconnect -// goes through startConnectIfCurrent so it runs off the watchdog's own stack (its -// teardown waits on d.wg, which the watchdog is part of), re-checks the generation -// under connMu, and yields cleanly to any user command that raced it. +// runs off the watchdog's own stack (teardown waits on d.wg, which the watchdog +// is part of), re-checks generation and policy under connMu, and yields to user +// commands that raced it. func (d *Daemon) healthFailover(gen uint64, profileID, nodeID string) failoverResult { + if !d.allowAutoRecovery(gen, profileID, nodeID) { + return failoverAborted + } + d.mu.Lock() + chain := d.multihop.Enabled + d.mu.Unlock() + if chain { + // The selected chain has exactly one exit. Other stored nodes are not + // authorization to change that chain or fall back to a single hop. + return failoverNoAlternative + } p, ok := d.store.Get(profileID) if !ok { d.emitLog(LogWarn, "health: cannot fail over, profile no longer stored") @@ -169,23 +184,34 @@ func (d *Daemon) healthFailover(gen uint64, profileID, nodeID string) failoverRe } d.emitLog(LogWarn, fmt.Sprintf("health: active node failed %d health probes in a row; failing over to another node", d.healthFailThreshold)) - d.startConnectIfCurrent(gen, p, "", nodeID, - func() { - // Runs under connMu with the generation confirmed current: announce the - // health-driven switch before the reconnect's teardown moves the state to - // connecting, so a UI can tell an automatic failover from a manual connect. - // If a user command already superseded us this never runs and no - // health_reconnecting is emitted. - d.setState(State{State: StateHealthReconnecting, Profile: profileID, Node: nodeID, - Routing: d.snapshotState().Routing}) - }, - func(err error) { - // startConnect only errors before it tears the old tunnel down (a build or - // no-alternative failure, reachable here only if the last other node - // vanished in the meantime), so the degraded tunnel is still up: log rather - // than forcing an error state over a live connection. + // Run off the watchdog stack: teardown waits for that watchdog. Re-check and + // spend the shared budget under connMu, where no user or automatic switch can + // interleave. A queued reconnect must yield if the exit changed during its wait. + d.relaunchWG.Add(1) + go func() { + defer d.relaunchWG.Done() + if d.beforeReconnect != nil { + d.beforeReconnect() + } + d.connMu.Lock() + defer d.connMu.Unlock() + if !d.allowAutoRecovery(gen, profileID, nodeID) || d.liveNode("") != nodeID { + return + } + latest, ok := d.store.Get(profileID) + if !ok { + return + } + d.recordAutoSwitch() + d.setState(State{State: StateHealthReconnecting, Profile: profileID, Node: nodeID, + Routing: d.snapshotState().Routing}) + if _, err := d.startConnect(context.Background(), latest, "", false, false, nodeID); err != nil { + // Validation errors leave the old engine up. Preserve that state while + // counting the failed recovery attempt so repeated failures cannot churn. + d.setState(State{State: StateConnected, Profile: profileID, Node: nodeID}) d.emitLog(LogWarn, fmt.Sprintf("health: failover reconnect could not start: %v", err)) - }) + } + }() return failoverStarted } diff --git a/core/control/health_test.go b/core/control/health_test.go index 7fa06102..ceb051cf 100644 --- a/core/control/health_test.go +++ b/core/control/health_test.go @@ -124,10 +124,11 @@ func TestHealthWatchReconnectsWhenTheExitCannotBeSteered(t *testing.T) { t.Fatalf("initial connect landed on %v, want vless-id", c["node"]) } - // From here the clash API refuses every selection, so the live switch is not - // available and the watchdog must still get the user off the degraded exit. + // The current process refuses selections; a restarted API recovers. A + // permanently failing selector must never reach Connected, even after restart. h.runner.mu.Lock() h.runner.selectErr = errSelectRefused + h.runner.selectErrThroughStart = 1 h.runner.mu.Unlock() if hr := h.awaitState(StateHealthReconnecting); hr["node"] != "vless-id" { @@ -201,12 +202,19 @@ func TestHealthWatchStopsOnDisconnect(t *testing.T) { func TestHealthWatchYieldsToUserCommand(t *testing.T) { h := newHarness(t) p := seedMultiProto(t, h) + // This fixture exercises reconnect arbitration. Live exit probes must fail; + // otherwise the watchdog successfully switches exits and honours the shared + // production cooldown instead of reaching the reconnect barrier promptly. + h.runner.failAllVia() probe := &scriptedProbe{verdict: func(int) error { return errors.New("probe: node down") }} h.tuneHealth(5*time.Millisecond, 100*time.Millisecond, 3, probe.fn) parked := make(chan struct{}) release := make(chan struct{}) + var releaseOnce sync.Once + unpark := func() { releaseOnce.Do(func() { close(release) }) } + defer unpark() var once sync.Once h.daemon.beforeReconnect = func() { once.Do(func() { close(parked) }) @@ -218,7 +226,11 @@ func TestHealthWatchYieldsToUserCommand(t *testing.T) { h.awaitState(StateConnected) // The watchdog trips and the failover reconnect parks before claiming connMu. - <-parked + select { + case <-parked: + case <-time.After(3 * time.Second): + t.Fatal("watchdog did not reach the reconnect barrier") + } starts := h.runner.starts() // The user disconnects while the failover is parked. @@ -227,7 +239,7 @@ func TestHealthWatchYieldsToUserCommand(t *testing.T) { h.awaitState(StateIdle) // Release the failover: it must see the bumped generation and yield. - close(release) + unpark() time.Sleep(50 * time.Millisecond) // give the goroutine a chance to (wrongly) act if got := h.runner.starts(); got != starts { t.Errorf("failover started a tunnel over the user's disconnect (starts %d -> %d)", starts, got) diff --git a/core/control/hotswitch.go b/core/control/hotswitch.go index fcce02f0..db24e588 100644 --- a/core/control/hotswitch.go +++ b/core/control/hotswitch.go @@ -269,22 +269,32 @@ func (d *Daemon) emitSwitchAttempt(gen uint64, profileID, nodeID string) { }) } -// autoSwitchAway moves the tunnel off a degraded exit onto one that is measurably -// working, without a reconnect. It reports whether it took ownership; false leaves -// the caller to fall back to the reconnect-based failover. +type autoSwitchResult int + +const ( + autoSwitchReconnect autoSwitchResult = iota + autoSwitchSucceeded + autoSwitchSuppressed +) + +// autoSwitchAway distinguishes an unavailable live switch from a policy refusal. +// Only the former permits reconnect-based recovery. // // It is the automatic counterpart of a user tapping another node, and it is gated // by the hysteresis in allowAutoSwitch: the tunnel must be steerable, the daemon // must not have moved the exit too recently or too often, and the candidate must // pass a real measurement before anything moves. -func (d *Daemon) autoSwitchAway(ctx context.Context, gen uint64, profileID, degraded string) bool { +func (d *Daemon) autoSwitchAway(ctx context.Context, gen uint64, profileID, degraded string) autoSwitchResult { + if !d.allowAutoRecovery(gen, profileID, degraded) { + return autoSwitchSuppressed + } if !d.allowAutoSwitch(gen, profileID, degraded) { - return false + return autoSwitchReconnect } target, ok := d.scanForExit(ctx, profileID, degraded) if !ok { - return false + return autoSwitchReconnect } // TryLock, not Lock. This runs on the health watchdog's goroutine, which @@ -295,20 +305,20 @@ func (d *Daemon) autoSwitchAway(ctx context.Context, gen uint64, profileID, degr // miss simply falls through to the reconnect-based failover, which needs no // lock of its own (see startConnectIfCurrent). if !d.connMu.TryLock() { - return false + return autoSwitchReconnect } defer d.connMu.Unlock() // The generation is re-checked under connMu for the same reason every // off-command connect re-checks it: a user command may have landed while the // scan ran, and it wins. - if !d.isCurrent(gen) { - return false + if !d.allowAutoRecovery(gen, profileID, degraded) || d.liveNode("") != degraded { + return autoSwitchSuppressed } if !d.switchNode(ctx, profileID, target, "the previous exit stopped carrying traffic", false) { - return false + return autoSwitchReconnect } d.recordAutoSwitch() - return true + return autoSwitchSucceeded } // allowAutoSwitch is the hysteresis gate. It marks the degraded node so nothing @@ -318,6 +328,17 @@ func (d *Daemon) autoSwitchAway(ctx context.Context, gen uint64, profileID, degr // about restraint rather than capability are logged once, because a user whose // exit is degraded and is NOT being moved deserves to know that is a decision. func (d *Daemon) allowAutoSwitch(gen uint64, profileID, degraded string) bool { + if !d.allowAutoRecovery(gen, profileID, degraded) { + return false + } + d.mu.Lock() + defer d.mu.Unlock() + return d.live != nil && d.live.gen == gen && d.live.profileID == profileID +} + +// allowAutoRecovery applies one cooldown and window budget to live switches and +// full health reconnects. Capability is deliberately outside this policy gate. +func (d *Daemon) allowAutoRecovery(gen uint64, profileID, degraded string) bool { now := d.now() d.mu.Lock() @@ -327,7 +348,7 @@ func (d *Daemon) allowAutoSwitch(gen uint64, profileID, degraded string) bool { if degraded != "" { d.degradedAt[degraded] = now } - steerable := d.live != nil && d.live.gen == gen && d.live.gen == d.generation && d.live.profileID == profileID + current := d.generation == gen && d.state.Profile == profileID && d.state.State == StateConnected && d.autoFailover // Only the switches inside the window count, so a quiet session recovers its // full budget without anything having to reset it. recent := d.autoSwitches[:0:0] @@ -344,7 +365,7 @@ func (d *Daemon) allowAutoSwitch(gen uint64, profileID, degraded string) bool { } d.mu.Unlock() - if !steerable { + if !current { return false } if spent > 0 && sinceLast < d.autoSwitchCooldown { diff --git a/core/control/hotswitch_test.go b/core/control/hotswitch_test.go index 6b1b37e2..8e9f5ec2 100644 --- a/core/control/hotswitch_test.go +++ b/core/control/hotswitch_test.go @@ -106,6 +106,7 @@ func TestSwitchFallsBackToReconnectWhenTheSelectorRefuses(t *testing.T) { h.runner.mu.Lock() h.runner.selectErr = errSelectRefused + h.runner.selectErrThroughStart = 1 // restart restores the selector API h.runner.mu.Unlock() h.send(Request{ID: 2, Cmd: CmdConnect, Profile: p.ID, Node: "hy2-id"}) @@ -183,7 +184,7 @@ func TestLiveSwitchTargetRefusesWhatTheRunningConfigCannotReach(t *testing.T) { if err != nil { t.Fatalf("open store: %v", err) } - d := NewDaemon(store, newFakeRunner()) + d := newUnitTestDaemon(store, newFakeRunner()) d.generation = 7 d.state = State{State: StateConnected, Profile: "prof", Node: "a"} d.live = &liveConfig{ @@ -224,7 +225,7 @@ func switchDaemon(t *testing.T, nodeID string) *Daemon { if err != nil { t.Fatalf("open store: %v", err) } - d := NewDaemon(store, newFakeRunner()) + d := newUnitTestDaemon(store, newFakeRunner()) d.generation = 1 d.state = State{State: StateConnected, Profile: "prof", Node: nodeID} d.live = &liveConfig{ diff --git a/core/control/killswitch_tun_test.go b/core/control/killswitch_tun_test.go index 64595634..53eaca25 100644 --- a/core/control/killswitch_tun_test.go +++ b/core/control/killswitch_tun_test.go @@ -18,6 +18,22 @@ import ( // walk), relaunching a tunnel whose process died while the switch was armed, // and persisting both preferences across a daemon restart. +// awaitRestartConnected requires the replacement's connecting transition and +// exact process count. Protection notifications may repeat Connected for the old +// process (including OFF), so they cannot acknowledge a completed restart. +func (h *harness) awaitRestartConnected(starts int) map[string]any { + h.t.Helper() + h.awaitState(StateConnecting) + ev := h.awaitState(StateConnected) + if got := h.runner.starts(); got != starts { + h.t.Fatalf("connected after restart: starts = %d, want %d", got, starts) + } + if st := h.daemon.snapshotState(); st.State != StateConnected || st.Node != ev["node"] { + h.t.Fatalf("restart event does not match current connection: event=%v state=%+v", ev, st) + } + return ev +} + // tunFromConfig extracts strict_route and the stack from the (single) tun // inbound of a built config. func tunFromConfig(t *testing.T, cfgJSON []byte) (strictRoute bool, stack string) { @@ -123,7 +139,7 @@ func TestSetKillSwitchLiveHotSwapsSameNode(t *testing.T) { } // The swap dips through connecting and lands connected on the same node. - re := h.awaitState(StateConnected) + re := h.awaitRestartConnected(2) if re["node"] != node { t.Errorf("reconnected node = %v, want the same node %s", re["node"], node) } @@ -209,7 +225,7 @@ func TestSetTunLiveHotSwapsSameNode(t *testing.T) { h.send(Request{ID: 2, Cmd: CmdSetTun, Stack: singbox.StackMixed}) h.await() - re := h.awaitState(StateConnected) + re := h.awaitRestartConnected(2) if re["node"] != connected["node"] { t.Errorf("reconnected node = %v, want %v", re["node"], connected["node"]) } @@ -258,7 +274,7 @@ func TestKillSwitchRelaunchesDeadTunnel(t *testing.T) { h.runner.exit(errors.New("boom")) h.awaitLogContains("kill switch: tunnel process died") - re := h.awaitState(StateConnected) + re := h.awaitRestartConnected(2) if re["node"] != connected["node"] { t.Errorf("relaunched node = %v, want %v", re["node"], connected["node"]) } @@ -309,7 +325,7 @@ func TestKillSwitchRelaunchBudget(t *testing.T) { for i := 0; i < maxRelaunches; i++ { h.runner.exit(errors.New("boom")) - h.awaitState(StateConnected) // each death within budget is answered + h.awaitRestartConnected(i + 2) // each death within budget is answered } // Back-to-back deaths never clear the reset window, so the budget still runs // out and the daemon gives up (see the honest wording in killSwitchRelaunch). @@ -323,9 +339,9 @@ func TestKillSwitchRelaunchBudget(t *testing.T) { // A user reconnect resets the budget: the next death relaunches again. h.send(Request{ID: 3, Cmd: CmdConnect, Profile: p.ID}) h.await() - h.awaitState(StateConnected) + h.awaitRestartConnected(maxRelaunches + 2) h.runner.exit(errors.New("boom")) - h.awaitState(StateConnected) + h.awaitRestartConnected(maxRelaunches + 3) } // TestReapplyDefersWhenNodeVanished: if the connected node is gone from the @@ -630,7 +646,7 @@ func TestKillSwitchRelaunchBudgetRefundedByUptime(t *testing.T) { for i := 0; i < maxRelaunches+3; i++ { advance(2 * defaultRelaunchReset) h.runner.exit(errors.New("boom")) - h.awaitState(StateConnected) // relaunched, not degraded to error + h.awaitRestartConnected(i + 2) // relaunched, not degraded to error } } diff --git a/core/control/leakcheck_test.go b/core/control/leakcheck_test.go index 685a012d..44b03b55 100644 --- a/core/control/leakcheck_test.go +++ b/core/control/leakcheck_test.go @@ -49,7 +49,7 @@ func newBareDaemon(t *testing.T) *Daemon { if err != nil { t.Fatalf("open store: %v", err) } - return NewDaemon(store, newFakeRunner()) + return newUnitTestDaemon(store, newFakeRunner()) } // connectTo forces the daemon into a connected state pointing at a one-node diff --git a/core/control/listener_test.go b/core/control/listener_test.go index ed95e6de..fd5412c6 100644 --- a/core/control/listener_test.go +++ b/core/control/listener_test.go @@ -89,7 +89,7 @@ func newTestDaemon(t *testing.T) (*Daemon, *fakeRunner) { t.Fatalf("open store: %v", err) } runner := newFakeRunner() - d := NewDaemon(store, runner) + d := newUnitTestDaemon(store, runner) d.probeWarmup = time.Millisecond d.probeRetry = time.Millisecond d.probeTimeout = 200 * time.Millisecond diff --git a/core/control/local_setup_failure_test.go b/core/control/local_setup_failure_test.go new file mode 100644 index 00000000..14a375b8 --- /dev/null +++ b/core/control/local_setup_failure_test.go @@ -0,0 +1,54 @@ +package control + +import ( + "context" + "strings" + "sync/atomic" + "testing" + "time" +) + +type fakeLocalSetupError struct{} + +func (fakeLocalSetupError) Error() string { return "engine lifetime job assignment denied" } +func (fakeLocalSetupError) LocalSetupFailure() bool { return true } + +type localSetupFailRunner struct { + *fakeRunner + attempts atomic.Int32 +} + +func (r *localSetupFailRunner) Start(context.Context, []byte) error { + r.attempts.Add(1) + return fakeLocalSetupError{} +} + +func TestLocalSetupFailureDoesNotMarkEveryServerUnavailable(t *testing.T) { + d, base, p := proxySafetyDaemon(t) + second := p.Servers[0] + second.ID = "second-server" + p.Servers = append(p.Servers, second) + r := &localSetupFailRunner{fakeRunner: base} + d.runner = r + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, "", false, false, "") + d.connMu.Unlock() + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + st := d.snapshotState() + if st.State == StateError { + if !strings.Contains(st.Error, "job assignment denied") { + t.Fatalf("local failure hidden as remote failure: %q", st.Error) + } + if got := r.attempts.Load(); got != 1 { + t.Fatalf("retried %d servers for a machine-wide setup failure", got) + } + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("local setup failure never surfaced") +} diff --git a/core/control/peer_admission_windows_test.go b/core/control/peer_admission_windows_test.go new file mode 100644 index 00000000..681fb568 --- /dev/null +++ b/core/control/peer_admission_windows_test.go @@ -0,0 +1,154 @@ +//go:build windows + +package control + +import ( + "encoding/binary" + "errors" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +func TestWindowsPeerAdmission(t *testing.T) { + const self = "S-1-5-18" + const console = "S-1-5-21-1-1001" + const other = "S-1-5-21-1-1000" + tests := []struct { + name, peer, self, console string + admin bool + consoleErr error + want bool + }{ + {"elevated installer under different admin", other, self, console, true, nil, true}, + {"elevated installer before logon", other, self, "", true, errors.New("no console"), true}, + {"elevated installer with failed self lookup", other, "", console, true, nil, true}, + {"filtered different admin", other, self, console, false, nil, false}, + {"ordinary console user", console, self, console, false, nil, true}, + {"ordinary unrelated user", other, self, console, false, nil, false}, + {"ordinary user with missing console", other, self, "", false, errors.New("no console"), false}, + {"daemon account before logon", self, self, "", false, errors.New("no console"), true}, + {"unknown SID despite admin claim", "", self, console, true, nil, false}, + {"empty identities", "", "", "", false, nil, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := windowsPeerAllowed(tt.peer, tt.self, tt.admin, func() (string, error) { return tt.console, tt.consoleErr }, func(string) {}) + if got != tt.want { + t.Fatalf("channel admission = %v, want %v", got, tt.want) + } + if got && tt.peer == console && !tt.admin && peerPrivileged(tt.peer, tt.self, tt.admin) { + t.Fatal("ordinary console user's channel must not grant privileged commands") + } + }) + } +} + +func TestGroupEnabledRejectsContradictoryDenyOnly(t *testing.T) { + admins, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + t.Fatal(err) + } + groups := []windows.SIDAndAttributes{{Sid: admins, Attributes: windows.SE_GROUP_ENABLED | windows.SE_GROUP_USE_FOR_DENY_ONLY}} + if groupEnabled(groups, admins) { + t.Fatal("deny-only membership must never grant authority, even with the enabled bit") + } +} + +// Each fixture represents complete results from the token-information boundary; +// the policy below is production code, with only the Windows query substituted. +func TestTokenFullAdminRights(t *testing.T) { + tests := []struct { + name string + elevated, restricted, integrity uint32 + errorClass, shortClass uint32 + restrictionResult []byte + restrictionPadding byte + invalidSID, outsideBuffer, missingIntegrityAttribute bool + want bool + }{ + {name: "full elevated admin", elevated: 1, integrity: 0x3000, want: true}, + {name: "system integrity", elevated: 1, integrity: 0x4000, want: true}, + {name: "native one byte unrestricted BOOLEAN", elevated: 1, integrity: 0x3000, restrictionResult: []byte{0}, want: true}, + {name: "one byte excludes bytes outside returned result", elevated: 1, integrity: 0x3000, restrictionResult: []byte{0}, restrictionPadding: 0xff, want: true}, + {name: "one byte restricted BOOLEAN", elevated: 1, integrity: 0x3000, restrictionResult: []byte{1}}, + {name: "one byte noncanonical nonzero BOOLEAN", elevated: 1, integrity: 0x3000, restrictionResult: []byte{0x80}}, + {name: "four byte restriction in second byte", elevated: 1, integrity: 0x3000, restrictionResult: []byte{0, 1, 0, 0}}, + {name: "four byte restriction in third byte", elevated: 1, integrity: 0x3000, restrictionResult: []byte{0, 0, 1, 0}}, + {name: "four byte restriction in fourth byte", elevated: 1, integrity: 0x3000, restrictionResult: []byte{0, 0, 0, 0x80}}, + {name: "zero byte restriction result", elevated: 1, integrity: 0x3000, restrictionResult: []byte{}}, + {name: "two byte restriction result", elevated: 1, integrity: 0x3000, restrictionResult: []byte{0, 0}}, + {name: "three byte restriction result", elevated: 1, integrity: 0x3000, restrictionResult: []byte{0, 0, 0}}, + {name: "oversize restriction result", elevated: 1, integrity: 0x3000, restrictionResult: []byte{0, 0, 0, 0, 0}}, + {name: "not elevated", integrity: 0x3000}, + {name: "restricted elevated token", elevated: 1, restricted: 1, integrity: 0x3000}, + {name: "low integrity elevated token", elevated: 1, integrity: 0x1000}, + {name: "medium integrity elevated token", elevated: 1, integrity: 0x2000}, + {name: "medium plus integrity elevated token", elevated: 1, integrity: 0x2100}, + {name: "elevation query error", elevated: 1, integrity: 0x3000, errorClass: windows.TokenElevation}, + {name: "restriction query error", elevated: 1, integrity: 0x3000, errorClass: windows.TokenHasRestrictions}, + {name: "integrity query error", elevated: 1, integrity: 0x3000, errorClass: windows.TokenIntegrityLevel}, + {name: "short elevation result", elevated: 1, integrity: 0x3000, shortClass: windows.TokenElevation}, + {name: "short restriction result", elevated: 1, integrity: 0x3000, shortClass: windows.TokenHasRestrictions}, + {name: "short integrity result", elevated: 1, integrity: 0x3000, shortClass: windows.TokenIntegrityLevel}, + {name: "invalid integrity authority", elevated: 1, integrity: 0x3000, invalidSID: true}, + {name: "integrity SID outside returned buffer", elevated: 1, integrity: 0x3000, outsideBuffer: true}, + {name: "missing integrity attribute", elevated: 1, integrity: 0x3000, missingIntegrityAttribute: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + query := func(_ windows.Token, class uint32, info *byte, size uint32, out *uint32) error { + if class == tt.errorClass { + return windows.ERROR_ACCESS_DENIED + } + buffer := unsafe.Slice(info, size) + switch class { + case windows.TokenElevation: + binary.LittleEndian.PutUint32(buffer, tt.elevated) + *out = 4 + case windows.TokenHasRestrictions: + binary.LittleEndian.PutUint32(buffer, tt.restricted) + *out = 4 + if tt.restrictionResult != nil { + for i := range buffer { + buffer[i] = tt.restrictionPadding + } + copy(buffer, tt.restrictionResult) + *out = uint32(len(tt.restrictionResult)) + } + case windows.TokenIntegrityLevel: + header := int(unsafe.Sizeof(windows.Tokenmandatorylabel{})) + if len(buffer) < header+12 { + return windows.ERROR_INSUFFICIENT_BUFFER + } + label := (*windows.Tokenmandatorylabel)(unsafe.Pointer(info)) + label.Label.Attributes = windows.SE_GROUP_INTEGRITY + if tt.missingIntegrityAttribute { + label.Label.Attributes = 0 + } + label.Label.Sid = (*windows.SID)(unsafe.Add(unsafe.Pointer(info), header)) + sid := buffer[header : header+12] + copy(sid, []byte{1, 1, 0, 0, 0, 0, 0, 16, 0, 0, 0, 0}) + binary.LittleEndian.PutUint32(sid[8:], tt.integrity) + if tt.invalidSID { + sid[7] = 5 + } + *out = uint32(header + 12) + if tt.outsideBuffer { + label.Label.Sid = (*windows.SID)(unsafe.Add(unsafe.Pointer(info), *out)) + } + default: + return windows.ERROR_INVALID_PARAMETER + } + if class == tt.shortClass { + *out = 2 + } + return nil + } + if got := tokenHasFullAdminRights(0, query); got != tt.want { + t.Fatalf("full admin rights = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/core/control/peer_auth_windows.go b/core/control/peer_auth_windows.go index 14e0150e..f2676bbe 100644 --- a/core/control/peer_auth_windows.go +++ b/core/control/peer_auth_windows.go @@ -3,8 +3,10 @@ package control import ( + "encoding/binary" "fmt" "net" + "unsafe" "golang.org/x/sys/windows" ) @@ -12,8 +14,9 @@ import ( // authorizePeer decides whether the just-accepted named-pipe peer may drive the // daemon, and whether it holds the daemon's own authority (see peerPrivileged). // It resolves the connecting process's user SID and administrative membership -// and runs the shared policies against the console user's SID (see peer_auth.go -// for the trust rationale). +// and admits a fully elevated administrator or the shared self/console policy. +// In particular, an installer elevated with another account must still reach +// the LocalSystem service while the ordinary desktop user remains logged in. // // A conn whose peer cannot be identified is REFUSED. The production listener // only ever yields winio pipe conns, whose client process is always resolvable, @@ -31,7 +34,7 @@ func (d *Daemon) authorizePeer(conn net.Conn) (allowed, privileged bool) { // check below still governs. Empty never matches a real peer SID. self = "" } - if !peerAllowed(sid, self, consoleUserSID, func(msg string) { + if !windowsPeerAllowed(sid, self, admin, consoleUserSID, func(msg string) { d.emitLog(LogWarn, msg) }) { return false, false @@ -80,20 +83,20 @@ func processIdentity(pid uint32) (sid string, admin, ok bool) { return tu.User.Sid.String(), tokenIsAdmin(tok), true } -// tokenIsAdmin reports whether tok carries BUILTIN\Administrators as an ENABLED -// group. +// tokenIsAdmin reports whether tok has unrestricted, elevated administrative +// authority at High integrity or above, including enabled Administrators. // // The group list is walked directly rather than asking CheckTokenMembership, // for two reasons. CheckTokenMembership wants an impersonation token, which // would mean duplicating a token opened from someone else's process; and the -// attribute check is exactly the distinction that matters here. A UAC-filtered +// attribute check distinguishes the group memberships. A UAC-filtered // token — what every non-elevated process of an administrator runs with — still // LISTS Administrators, but marks it SE_GROUP_USE_FOR_DENY_ONLY with // SE_GROUP_ENABLED cleared. Treating that as administrative would hand the // service's authority to any process the user launched by double-clicking it, // which is the escalation this check exists to stop; the elevated half of the // same account, obtained through the UAC prompt, has the group enabled and -// passes. +// passes, provided the token has not been restricted or lowered in integrity. // // A token whose groups can't be read is not administrative as far as this // answers: the caller then falls back on the peer==self shortcut, which is @@ -107,16 +110,16 @@ func tokenIsAdmin(tok windows.Token) bool { if err != nil { return false } - return groupEnabled(groups.AllGroups(), admins) + return groupEnabled(groups.AllGroups(), admins) && tokenHasFullAdminRights(tok, windows.GetTokenInformation) } // groupEnabled reports whether want appears in groups as an ENABLED membership. -// It is the whole of the deny-only distinction tokenIsAdmin rests on, split out +// It enforces the deny-only distinction tokenIsAdmin rests on, split out // so it can be tested against a group list a test builds by hand — a real // UAC-filtered token cannot be minted inside a test process. func groupEnabled(groups []windows.SIDAndAttributes, want *windows.SID) bool { for _, g := range groups { - if g.Attributes&windows.SE_GROUP_ENABLED == 0 { + if g.Sid == nil || g.Attributes&windows.SE_GROUP_ENABLED == 0 || g.Attributes&windows.SE_GROUP_USE_FOR_DENY_ONLY != 0 { continue } if windows.EqualSid(g.Sid, want) { @@ -159,3 +162,58 @@ func consoleUserSID() (string, error) { } return tu.User.Sid.String(), nil } + +func windowsPeerAllowed(peer, self string, admin bool, console consoleUser, warn func(string)) bool { + // Full administrators already control this service. Requiring them also to + // own the console session breaks over-the-shoulder UAC and unattended setup. + // A failed identity lookup must never turn an admin claim into admission. + if peer != "" && admin { + return true + } + return peerAllowed(peer, self, console, warn) +} + +type tokenInformationQuery func(windows.Token, uint32, *byte, uint32, *uint32) error + +func tokenHasFullAdminRights(tok windows.Token, query tokenInformationQuery) bool { + var elevated, size uint32 + if err := query(tok, windows.TokenElevation, (*byte)(unsafe.Pointer(&elevated)), 4, &size); err != nil || size != 4 || elevated == 0 { + return false + } + // Unlike IsTokenRestricted (which only checks restricting SIDs), this also + // rejects tokens filtered by removing privileges or disabling groups. + // Windows also returns this as a one-byte BOOLEAN, although the documented + // form is a DWORD. Only those two sizes are valid; every returned byte must + // be zero. Do not read padding beyond the reported result. + var restricted [4]byte + if err := query(tok, windows.TokenHasRestrictions, &restricted[0], uint32(len(restricted)), &size); err != nil || (size != 1 && size != 4) { + return false + } + for _, b := range restricted[:size] { + if b != 0 { + return false + } + } + // TOKEN_MANDATORY_LABEL plus a SID fits in 128 bytes (maximum SID: 68). + // Keep the SID in the returned buffer and validate its framing before use. + var buffer [128]byte + header := uintptr(unsafe.Sizeof(windows.Tokenmandatorylabel{})) + if err := query(tok, windows.TokenIntegrityLevel, &buffer[0], uint32(len(buffer)), &size); err != nil || uintptr(size) < header+12 || size > uint32(len(buffer)) { + return false + } + label := (*windows.Tokenmandatorylabel)(unsafe.Pointer(&buffer[0])) + start := uintptr(unsafe.Pointer(&buffer[0])) + ptr := uintptr(unsafe.Pointer(label.Label.Sid)) + if label.Label.Attributes&windows.SE_GROUP_INTEGRITY == 0 || ptr < start+header || ptr-start > uintptr(size)-12 { + return false + } + // Integrity labels use revision 1, exactly one RID, and authority 16. + // Reading from buffer rather than dereferencing the returned pointer keeps + // malformed or truncated results fail-closed. + sid := buffer[ptr-start : ptr-start+12] + if sid[0] != 1 || sid[1] != 1 || sid[2] != 0 || sid[3] != 0 || sid[4] != 0 || sid[5] != 0 || sid[6] != 0 || sid[7] != 16 { + return false + } + const highIntegrityRID = 0x3000 + return binary.LittleEndian.Uint32(sid[8:]) >= highIntegrityRID +} diff --git a/core/control/peer_auth_windows_test.go b/core/control/peer_auth_windows_test.go index d4ea84c0..8aca7ab0 100644 --- a/core/control/peer_auth_windows_test.go +++ b/core/control/peer_auth_windows_test.go @@ -8,7 +8,6 @@ import ( "testing" "time" - "github.com/Microsoft/go-winio" "golang.org/x/sys/windows" ) @@ -56,7 +55,7 @@ func TestAuthorizePeerAllowsSelfOverPipe(t *testing.T) { accepted <- c }() timeout := 3 * time.Second - client, err := winio.DialPipe(name, &timeout) + client, err := dialTestPipe(name, &timeout) if err != nil { t.Fatalf("DialPipe(%s): %v", name, err) } diff --git a/core/control/ping_dial_test.go b/core/control/ping_dial_test.go index 201ccd12..9e5bfce4 100644 --- a/core/control/ping_dial_test.go +++ b/core/control/ping_dial_test.go @@ -210,7 +210,7 @@ func TestNewDaemonUsesPingDialer(t *testing.T) { if err != nil { t.Fatalf("open store: %v", err) } - d := NewDaemon(store, newFakeRunner()) + d := newUnitTestDaemon(store, newFakeRunner()) if d.dial == nil { t.Fatal("daemon has no dial function") } diff --git a/core/control/ping_fanout_test.go b/core/control/ping_fanout_test.go index d3cdbed0..f310bbed 100644 --- a/core/control/ping_fanout_test.go +++ b/core/control/ping_fanout_test.go @@ -26,7 +26,7 @@ func TestPingServersBoundsConcurrency(t *testing.T) { if err != nil { t.Fatalf("open store: %v", err) } - d := NewDaemon(store, newFakeRunner()) + d := newUnitTestDaemon(store, newFakeRunner()) var inFlight int64 var maxInFlight int64 @@ -93,7 +93,7 @@ func TestPingServersEmpty(t *testing.T) { if err != nil { t.Fatalf("open store: %v", err) } - d := NewDaemon(store, newFakeRunner()) + d := newUnitTestDaemon(store, newFakeRunner()) var dials int64 d.dial = func(ctx context.Context, network, address string) (net.Conn, error) { diff --git a/core/control/pipe_acl_windows_test.go b/core/control/pipe_acl_windows_test.go new file mode 100644 index 00000000..ae7e2c11 --- /dev/null +++ b/core/control/pipe_acl_windows_test.go @@ -0,0 +1,55 @@ +//go:build windows + +package control + +import ( + "golang.org/x/sys/windows" + "testing" + "unsafe" +) + +// Parse the actual server descriptor with Windows' security descriptor parser. +// No pipe, registry key or service is opened by this test. +func TestInteractivePipeACEExcludesServerCreation(t *testing.T) { + sd, err := windows.SecurityDescriptorFromString(pipeSecurityDescriptor) + if err != nil { + t.Fatal(err) + } + acl, _, err := sd.DACL() + if err != nil { + t.Fatal(err) + } + if acl == nil { + t.Fatal("pipe has unrestricted DACL") + } + iu, err := windows.StringToSid("S-1-5-4") + if err != nil { + t.Fatal(err) + } + found := false + for i := uint32(0); i < uint32(acl.AceCount); i++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(acl, i, &ace); err != nil { + t.Fatal(err) + } + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + continue + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + if !windows.EqualSid(sid, iu) { + continue + } + found = true + const needed = windows.FILE_READ_DATA | windows.FILE_WRITE_DATA | windows.FILE_READ_ATTRIBUTES | windows.READ_CONTROL | windows.SYNCHRONIZE + if ace.Mask != needed { + t.Fatalf("interactive access = %#x, want exact client-only %#x", ace.Mask, needed) + } + const fileCreatePipeInstance = 0x4 // same bit as FILE_APPEND_DATA + if ace.Mask&(fileCreatePipeInstance|windows.GENERIC_WRITE|windows.GENERIC_ALL|windows.WRITE_DAC|windows.WRITE_OWNER) != 0 { + t.Fatalf("interactive user can create a server or rewrite pipe security: %#x", ace.Mask) + } + } + if !found { + t.Fatal("interactive client ACE missing") + } +} diff --git a/core/control/pipe_windows.go b/core/control/pipe_windows.go index 40a27e66..3422d557 100644 --- a/core/control/pipe_windows.go +++ b/core/control/pipe_windows.go @@ -22,12 +22,14 @@ const PipeName = `\\.\pipe\tenebra` // IU (INTERACTIVE) - any locally logged-in user, which is what lets the // unprivileged GUI drive the privileged service. // -// GRGW (generic read/write) is what winio and every stock pipe client request -// when dialling, so narrowing the client rights further would lock the GUI -// out. Network logons never carry the INTERACTIVE SID, so a remote caller -// needs administrator credentials to reach the pipe at all. See -// docs/control-protocol.md for the security model and its honest limits. -const pipeSecurityDescriptor = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)" +// The interactive ACE grants only read/write data, read attributes, read control +// and synchronize (0x120083). GENERIC_WRITE also includes the 0x4 server-instance +// creation bit, so it would let an interactive client create a competing server. +// Clients must request this exact access mask rather than GENERIC_READ/WRITE. +// Network logons never carry INTERACTIVE; authenticated peer checks still run +// after accept. See docs/control-protocol.md for the complete trust model. +const pipeClientAccess uint32 = 0x120083 +const pipeSecurityDescriptor = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x120083;;;IU)" // ListenPipe opens the named pipe listener the control protocol is served on. // name is PipeName in production; tests pass unique names so parallel runs diff --git a/core/control/pipe_windows_test.go b/core/control/pipe_windows_test.go index d41e2bf0..58c0deaa 100644 --- a/core/control/pipe_windows_test.go +++ b/core/control/pipe_windows_test.go @@ -6,6 +6,7 @@ import ( "context" "errors" "fmt" + "net" "os" "sync" "testing" @@ -21,6 +22,12 @@ func testPipeName() string { return fmt.Sprintf(`\\.\pipe\tenebra-test-%d-%d`, os.Getpid(), time.Now().UnixNano()) } +func dialTestPipe(name string, timeout *time.Duration) (net.Conn, error) { + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + return winio.DialPipeAccessImpLevel(ctx, name, pipeClientAccess, winio.PipeImpLevelIdentification) +} + // requirePipeAccess skips the test when the current token holds none of the // identities the pipe DACL admits (INTERACTIVE, Administrators, SYSTEM). // Normal dev shells and CI runners are interactive; a bare network-logon @@ -91,7 +98,7 @@ func (h *pipeHarness) awaitDone() error { func (h *pipeHarness) dial() *lineClient { h.t.Helper() timeout := 3 * time.Second - conn, err := winio.DialPipe(h.name, &timeout) + conn, err := dialTestPipe(h.name, &timeout) if err != nil { h.t.Fatalf("DialPipe(%s): %v", h.name, err) } @@ -242,7 +249,7 @@ func TestPipeStalledClientDoesNotBlockTheListener(t *testing.T) { h.daemon.clientWriteTimeout = 200 * time.Millisecond timeout := 3 * time.Second - a, err := winio.DialPipe(h.name, &timeout) + a, err := dialTestPipe(h.name, &timeout) if err != nil { t.Fatalf("DialPipe: %v", err) } diff --git a/core/control/protection.go b/core/control/protection.go new file mode 100644 index 00000000..f9a00153 --- /dev/null +++ b/core/control/protection.go @@ -0,0 +1,115 @@ +package control + +import ( + "errors" + "fmt" + + "github.com/Divaaaan/tenebra/core/protection" + "github.com/Divaaaan/tenebra/core/routing" + "github.com/Divaaaan/tenebra/core/singbox" +) + +// EngineExecutablePath exposes only the runner's exact resolved executable. +// A fake or unsupported runner cannot accidentally authorize a host binary. +func (d *Daemon) EngineExecutablePath() (string, error) { + if r, ok := d.runner.(interface{ ExecutablePath() (string, error) }); ok { + return r.ExecutablePath() + } + return "", errors.New("runner has no trusted executable identity") +} + +// SetProtection is the explicit composition seam. NewDaemon never attaches a +// native adapter; fake runners and ordinary unit fixtures cannot touch WFP. +func (d *Daemon) SetProtection(g *protection.Guard) { + if g == nil { + g = protection.New(nil) + } + d.protection = g + g.SetNotify(d.emitProtection) +} + +// UseLegacyEngineProtection is selected only by the non-Windows production +// composition root. Default/missing-backend constructors remain fail closed. +func (d *Daemon) UseLegacyEngineProtection() { + d.SetProtection(protection.NewLegacyEngineOnly()) +} + +func (d *Daemon) emitProtection() { + s := d.snapshotState() + d.mu.Lock() + emit := d.emit + d.mu.Unlock() + if emit != nil { + emit(EventState, stateEventBody(s)) + } +} + +// RecoverProtectionAtStartup preserves/repairs existing owned policy regardless +// of settings; failure remains visible while the control plane stays available. +func (d *Daemon) RecoverProtectionAtStartup() error { + if err := d.protection.Recover(); err != nil { + return fmt.Errorf("host protection recovery: %w", err) + } + return nil +} + +func (d *Daemon) prepareProtection(ro routing.Options) error { + d.protectionOp.Lock() + defer d.protectionOp.Unlock() + if d.protection.LegacyEngineOnly() { + return nil + } + d.mu.Lock() + wanted := d.routing.KillSwitch + d.mu.Unlock() + s := d.protection.Snapshot() + if !wanted { + if s.Status == "error" { + return fmt.Errorf("host protection unresolved: %s; retry OFF or Disconnect", s.Error) + } + if !s.Enforced { + return nil + } + } + if err := protection.ValidateDNS(ro.Normalize().DNSDirect); err != nil { + return d.protection.Reject(err) + } + return d.protection.Prepare() +} + +// activateProtectionLocked runs inside recordSuccess's acceptance critical +// section. Keep protectionOp held through every local gate and the connected +// publication, so a setting command cannot replace the verified policy. +func (d *Daemon) activateProtectionLocked(ro routing.Options, tun singbox.TunOptions) error { + if d.protection.LegacyEngineOnly() { + return nil + } + d.mu.Lock() + wanted := d.routing.KillSwitch + d.mu.Unlock() + s := d.protection.Snapshot() + if !wanted && !s.Enforced { + if s.Status == "error" { + return fmt.Errorf("host protection unresolved: %s", s.Error) + } + return nil + } + if err := protection.ValidateDNS(ro.Normalize().DNSDirect); err != nil { + return d.protection.Reject(err) + } + name := tun.InterfaceName + if name == "" { + name = singbox.DefaultTUNName() + } + return d.protection.VerifyTunnel(name, tun.Address, tun.IsSystemProxy()) +} + +// ProtectionDNS returns the requested encrypted endpoint and whether plaintext +// fallback is forbidden. The production resolver reads this on each lookup. +func (d *Daemon) ProtectionDNS() (string, bool) { + d.mu.Lock() + ro := d.routing + d.mu.Unlock() + s := d.protection.Snapshot() + return ro.Normalize().DNSDirect, !d.protection.LegacyEngineOnly() && (ro.KillSwitch || s.Enforced || s.Status == "error") +} diff --git a/core/control/protection_dns_concurrency_test.go b/core/control/protection_dns_concurrency_test.go new file mode 100644 index 00000000..b0385e11 --- /dev/null +++ b/core/control/protection_dns_concurrency_test.go @@ -0,0 +1,50 @@ +package control + +import ( + "sync" + "testing" + "time" + + "github.com/Divaaaan/tenebra/core/protection" +) + +func TestHostProtectionDNSValidationSerializesWithOn(t *testing.T) { + d, _, _ := coreAuditDaemon(t) + d.SetProtection(protection.New(&fakeHostProtection{})) + before := d.snapshotRouting().DNSDirect + + // ON owns protectionOp while it validates/applies policy. A DNS command + // arriving then must validate against the eventual ON value, not an earlier + // unprotected snapshot. This models the setting boundary without native I/O. + d.protectionOp.Lock() + var unlockOnce sync.Once + unlock := func() { unlockOnce.Do(d.protectionOp.Unlock) } + defer unlock() + started := make(chan struct{}) + done := make(chan Response, 1) + go func() { + close(started) + done <- d.handleSetDNS(Request{ID: 1, DNSDirect: "udp://192.0.2.53"}) + }() + <-started + select { + case resp := <-done: + t.Fatalf("DNS setting crossed the ON critical section: %+v", resp) + case <-time.After(20 * time.Millisecond): + } + d.mu.Lock() + d.routing.KillSwitch = true + d.mu.Unlock() + unlock() + select { + case resp := <-done: + if resp.Ok { + t.Fatal("ON accepted a racing plaintext bootstrap") + } + case <-time.After(time.Second): + t.Fatal("DNS command did not leave its critical section") + } + if got := d.snapshotRouting().DNSDirect; got != before { + t.Fatalf("rejected DNS overwritten: %q, want %q", got, before) + } +} diff --git a/core/control/protection_legacy_test.go b/core/control/protection_legacy_test.go new file mode 100644 index 00000000..1645c939 --- /dev/null +++ b/core/control/protection_legacy_test.go @@ -0,0 +1,69 @@ +package control + +import ( + "context" + "testing" + "time" +) + +func TestHostProtectionLegacyEngineCompatibility(t *testing.T) { + d, r, p := coreAuditDaemon(t) + d.UseLegacyEngineProtection() + d.routing.KillSwitch = true // existing saved preference, never migrated OFF + d.routing.DNSDirect = "udp://192.0.2.53" + state := coreAuditConnect(t, d, p, "") + checkWarning := func() { + t.Helper() + state = d.snapshotState() + if state.Protection.Status != "unavailable" || state.Protection.Enforced || state.Protection.Persistent || state.Protection.Error == "" { + t.Fatalf("legacy routing claimed independent protection: %+v", state.Protection) + } + if stateEventBody(state).Protection != state.Protection { + t.Fatal("state event lost unavailable explanation") + } + } + checkWarning() + if !state.KillSwitch { + t.Fatal("saved preference was silently migrated OFF") + } + if endpoint, strict := d.ProtectionDNS(); strict || endpoint != "udp://192.0.2.53" { + t.Fatalf("legacy DNS choice changed: endpoint=%q strict=%t", endpoint, strict) + } + if strict, _ := tunFromConfig(t, r.startCfgs()[0]); !strict { + t.Fatal("legacy strict_route disappeared") + } + for i, on := range []bool{false, true} { + if resp := d.handleSetKillSwitch(Request{ID: int64(i + 1), On: on}); !resp.Ok { + t.Fatal(resp) + } + deadline := time.Now().Add(time.Second) + for d.snapshotState().State != StateConnected && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + checkWarning() + if state.State != StateConnected || state.KillSwitch != on { + t.Fatal(state) + } + cfgs := r.startCfgs() + if strict, _ := tunFromConfig(t, cfgs[len(cfgs)-1]); strict != on { + t.Fatalf("legacy strict_route=%t, want %t", strict, on) + } + } + if resp := d.handleSetDNS(Request{ID: 3, DNSDirect: "udp://192.0.2.54"}); !resp.Ok { + t.Fatal("legacy plaintext setting unexpectedly rejected", resp) + } + if endpoint, strict := d.ProtectionDNS(); strict || endpoint != "udp://192.0.2.54" { + t.Fatal(endpoint, strict) + } +} + +func TestHostProtectionMissingBackendDoesNotInferLegacy(t *testing.T) { + d, r, p := coreAuditDaemon(t) + d.routing.KillSwitch = true + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, "", false, false, "") + d.connMu.Unlock() + if err == nil || r.starts() != 0 || d.snapshotState().Protection.Status != "unavailable" { + t.Fatal("ordinary missing backend silently downgraded", err, r.starts()) + } +} diff --git a/core/control/protection_proxy_test.go b/core/control/protection_proxy_test.go new file mode 100644 index 00000000..561380f3 --- /dev/null +++ b/core/control/protection_proxy_test.go @@ -0,0 +1,189 @@ +package control + +import ( + "context" + "errors" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Divaaaan/tenebra/core/fallback" + "github.com/Divaaaan/tenebra/core/protection" + "github.com/Divaaaan/tenebra/core/singbox" +) + +func TestHostProtectionDisconnectCannotPublishCancelledAcceptance(t *testing.T) { + d, _, p := coreAuditDaemon(t) + d.SetProtection(protection.New(&fakeHostProtection{})) + d.routing.KillSwitch = true + d.tun.Mode = singbox.ModeSystemProxy + accepting, release := make(chan struct{}), make(chan struct{}) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + defer unblock() + d.logSink = func(_, msg string) { + if strings.HasPrefix(msg, "connect: up on ") { + close(accepting) + <-release // all gates passed, but Active/Connected has not been published + } + } + var cancelled, publishedAfterCancel atomic.Bool + d.SetEmitter(func(name string, body any) { + if state, ok := body.(stateEvent); name == EventState && ok && cancelled.Load() { + // Interrupted can report blocked with the previous connection phase + // while teardown drains. Only an active publication claims acceptance. + if state.Protection.Status == "active" { + publishedAfterCancel.Store(true) + } + } + }) + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, "", false, false, "") + d.connMu.Unlock() + if err != nil { + t.Fatal(err) + } + select { + case <-accepting: + case <-time.After(time.Second): + t.Fatal("acceptance barrier not reached") + } + // Observe real teardown cancellation. In the broken ordering it happens + // while the acceptance goroutine is still parked in the log callback. + cancelledEvent := make(chan struct{}) + d.mu.Lock() + cancel := d.cancel + d.cancel = func() { + cancel() + cancelled.Store(true) + close(cancelledEvent) + } + d.mu.Unlock() + done := make(chan Response, 1) + go func() { done <- d.handleDisconnect(Request{ID: 1}) }() + select { + case <-cancelledEvent: + case <-time.After(50 * time.Millisecond): + // A fenced teardown waits until acceptance has finished publishing. + } + unblock() + select { + case resp := <-done: + if !resp.Ok { + t.Fatal(resp) + } + case <-time.After(time.Second): + t.Fatal("disconnect did not drain acceptance") + } + if publishedAfterCancel.Load() { + t.Fatal("cancelled connection published Active/Connected during disconnect") + } + if s := d.snapshotState(); s.State != StateIdle || s.Protection.Status != "off" { + t.Fatalf("disconnect did not finish cleanup: %+v", s) + } +} + +func TestHostProtectionSupersededAcceptanceSkipsLocalGates(t *testing.T) { + for _, cause := range []string{"cancelled", "generation"} { + t.Run(cause, func(t *testing.T) { + d, _, _ := coreAuditDaemon(t) + native := &fakeHostProtection{} + d.SetProtection(protection.New(native)) + d.routing.KillSwitch = true + d.tun.Mode = singbox.ModeSystemProxy + proxy := &fakeProxyController{} + d.proxy = proxy + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + loop := fallbackLoop{gen: d.generation, ro: d.routing, tun: d.tun} + if cause == "cancelled" { + cancel() + } else { + loop.gen++ + } + err := d.recordSuccess(ctx, loop, fallback.Attempt{}, nil, fallback.Strategy{}, selectorShape{}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("superseded acceptance returned %v", err) + } + if s := d.snapshotState(); s.Protection.Status != "off" || proxy.enables() != 0 { + t.Fatalf("superseded acceptance applied local gates: proxy=%d state=%+v", proxy.enables(), s) + } + native.mu.Lock() + defer native.mu.Unlock() + if len(native.applied) != 0 { + t.Fatalf("superseded acceptance replaced native policy: %v", native.applied) + } + }) + } +} + +func TestHostProtectionProxyFailureCannotPublishActive(t *testing.T) { + d, r, p := coreAuditDaemon(t) + d.SetProtection(protection.New(&fakeHostProtection{})) + d.routing.KillSwitch = true + d.tun.Mode = singbox.ModeSystemProxy + d.proxy = &fakeProxyController{enableErr: errors.New("interactive proxy denied")} + var accepted atomic.Bool + d.SetEmitter(func(name string, body any) { + if state, ok := body.(stateEvent); name == EventState && ok { + if state.State == StateConnected || state.Protection.Status == "active" { + accepted.Store(true) + } + } + }) + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, "", false, false, "") + d.connMu.Unlock() + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(time.Second) + for d.snapshotState().State != StateError && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + s := d.snapshotState() + if accepted.Load() || s.State != StateError || s.Protection.Status != "blocked" || !s.Protection.Enforced || r.starts() != 1 || !strings.Contains(s.Error, "interactive proxy denied") { + t.Fatalf("accepted=%v starts=%d state=%+v", accepted.Load(), r.starts(), s) + } + if _, ok := d.lastGood.Get(p.ID); ok { + t.Fatal("failed local proxy gate recorded last-good") + } +} + +func TestHostProtectionDisconnectReportsBothCleanupFailuresAndRetries(t *testing.T) { + d, _, p := coreAuditDaemon(t) + native := &fakeHostProtection{} + d.SetProtection(protection.New(native)) + d.routing.KillSwitch = true + d.tun.Mode = singbox.ModeSystemProxy + proxy := &fakeProxyController{} + d.proxy = proxy + coreAuditConnect(t, d, p, "") + proxy.mu.Lock() + proxy.disableErr = errors.New("proxy rollback denied") + proxy.mu.Unlock() + native.mu.Lock() + native.removeErr = errors.New("WFP removal denied") + native.mu.Unlock() + resp := d.handleDisconnect(Request{ID: 1}) + if resp.Ok || !strings.Contains(resp.Error, "proxy rollback denied") || !strings.Contains(resp.Error, "WFP removal denied") { + t.Fatalf("cleanup failures lost: %+v", resp) + } + if s := d.snapshotState(); !s.Protection.Enforced || s.Protection.Status != "error" { + t.Fatal(s) + } + proxy.mu.Lock() + proxy.disableErr = nil + proxy.mu.Unlock() + native.mu.Lock() + native.removeErr = nil + native.mu.Unlock() + if resp = d.handleDisconnect(Request{ID: 2}); !resp.Ok { + t.Fatal(resp) + } + if s := d.snapshotState(); s.Protection.Enforced || s.Protection.Status != "off" || s.State != StateIdle { + t.Fatal(s) + } +} diff --git a/core/control/protection_test.go b/core/control/protection_test.go new file mode 100644 index 00000000..314bdd08 --- /dev/null +++ b/core/control/protection_test.go @@ -0,0 +1,379 @@ +package control + +import ( + "context" + "errors" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Divaaaan/tenebra/core/protection" +) + +type fakeHostProtection struct { + mu sync.Mutex + applyErr, resolveErr, removeErr error + installed bool + applied []uint64 + removed int + resolvedLUID uint64 + resolveCalls int +} + +func (f *fakeHostProtection) Inspect() (bool, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.installed, f.installed, nil +} +func (f *fakeHostProtection) Replace(luid uint64) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.applyErr != nil { + return f.applyErr + } + f.installed = true + f.applied = append(f.applied, luid) + return nil +} +func (f *fakeHostProtection) ResolveTunnel(string, string) (uint64, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.resolveCalls++ + if f.resolvedLUID != 0 { + return f.resolvedLUID, f.resolveErr + } + return 42, f.resolveErr +} +func (f *fakeHostProtection) Remove() error { + f.mu.Lock() + defer f.mu.Unlock() + f.removed++ + if f.removeErr != nil { + return f.removeErr + } + f.installed = false + return nil +} + +func TestHostProtectionApplyFailureStartsNoEngine(t *testing.T) { + d, r, p := coreAuditDaemon(t) + d.SetProtection(protection.New(&fakeHostProtection{applyErr: errors.New("denied")})) + d.routing.KillSwitch = true + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, "", false, false, "") + d.connMu.Unlock() + if err == nil || r.starts() != 0 || d.snapshotState().Protection.Enforced { + t.Fatalf("err=%v starts=%d state=%+v", err, r.starts(), d.snapshotState()) + } +} + +func TestHostProtectionTunFailureDoesNotAcceptNodeOrFallback(t *testing.T) { + d, r, p := coreAuditDaemon(t) + d.SetProtection(protection.New(&fakeHostProtection{resolveErr: errors.New("wrong TUN")})) + d.routing.KillSwitch = true + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, "", false, false, "") + d.connMu.Unlock() + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(time.Second) + for d.snapshotState().State != StateError && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + s := d.snapshotState() + if s.State != StateError || s.Protection.Status != "error" || !s.Protection.Enforced || r.starts() != 1 { + t.Fatalf("starts=%d state=%+v", r.starts(), s) + } + if _, ok := d.lastGood.Get(p.ID); ok { + t.Fatal("local protection failure recorded last-good") + } +} + +func TestHostProtectionTeardownRetainsAndExplicitDisconnectReleases(t *testing.T) { + d, _, p := coreAuditDaemon(t) + f := &fakeHostProtection{} + d.SetProtection(protection.New(f)) + d.routing.KillSwitch = true + s := coreAuditConnect(t, d, p, "") + if s.Protection.Status != "active" { + t.Fatal(s.Protection) + } + d.connMu.Lock() + d.teardown(StateIdle, "", "") + d.connMu.Unlock() + if s = d.snapshotState(); s.Protection.Status != "blocked" || !s.Protection.Enforced { + t.Fatal(s.Protection) + } + if resp := d.handleDisconnect(Request{ID: 1}); !resp.Ok { + t.Fatal(resp) + } + if s = d.snapshotState(); s.Protection.Status != "off" || s.Protection.Enforced { + t.Fatal(s.Protection) + } +} + +func TestHostProtectionOffFailureRemainsVisibleAndRetryWorks(t *testing.T) { + d, _, p := coreAuditDaemon(t) + f := &fakeHostProtection{} + d.SetProtection(protection.New(f)) + d.routing.KillSwitch = true + coreAuditConnect(t, d, p, "") + f.mu.Lock() + f.removeErr = errors.New("locked") + f.mu.Unlock() + if resp := d.handleSetKillSwitch(Request{ID: 1, On: false}); resp.Ok { + t.Fatal("failed cleanup acknowledged") + } + if s := d.snapshotState(); s.Protection.Status != "error" || !s.Protection.Enforced { + t.Fatal(s.Protection) + } + f.mu.Lock() + f.removeErr = nil + f.mu.Unlock() + if resp := d.handleSetKillSwitch(Request{ID: 2, On: false}); !resp.Ok { + t.Fatal(resp) + } + if s := d.snapshotState(); s.Protection.Status != "off" || s.Protection.Enforced { + t.Fatal(s.Protection) + } +} + +func TestHostProtectionDefaultConstructorCannotApplyNative(t *testing.T) { + d, r, p := coreAuditDaemon(t) + if s := d.snapshotState().Protection; s.Status != "unavailable" { + t.Fatal(s) + } + d.routing.KillSwitch = true + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, "", false, false, "") + d.connMu.Unlock() + if err == nil || r.starts() != 0 { + t.Fatal("missing injected backend accepted", err) + } +} + +func TestHostProtectionRepeatedOnKeepsVerifiedTunnel(t *testing.T) { + d, r, p := coreAuditDaemon(t) + f := &fakeHostProtection{} + d.SetProtection(protection.New(f)) + d.routing.KillSwitch = true + coreAuditConnect(t, d, p, "") + if resp := d.handleSetKillSwitch(Request{ID: 1, On: true}); !resp.Ok { + t.Fatal(resp) + } + if s := d.snapshotState(); s.Protection.Status != "active" || r.starts() != 1 { + t.Fatalf("repeated ON revoked live TUN: %+v", s) + } + f.mu.Lock() + defer f.mu.Unlock() + if len(f.applied) != 2 || f.applied[1] != 42 { + t.Fatal(f.applied) + } +} + +func TestHostProtectionAcceptanceSerializesToggle(t *testing.T) { + for _, offFirst := range []bool{false, true} { + t.Run(map[bool]string{false: "repeated-on", true: "off-on"}[offFirst], func(t *testing.T) { + d, _, p := coreAuditDaemon(t) + f := &fakeHostProtection{} + d.SetProtection(protection.New(f)) + d.routing.KillSwitch = true + verified, release := make(chan struct{}), make(chan struct{}) + var once sync.Once + d.logSink = func(_, msg string) { + if strings.HasPrefix(msg, "connect: up on ") { + once.Do(func() { close(verified); <-release }) + } + } + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + defer unblock() + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, "", false, false, "") + d.connMu.Unlock() + if err != nil { + t.Fatal(err) + } + select { + case <-verified: + case <-time.After(time.Second): + t.Fatal("verification barrier not reached") + } + done := make(chan Response, 1) + go func() { + if offFirst { + if resp := d.handleSetKillSwitch(Request{ID: 1, On: false}); !resp.Ok { + done <- resp + return + } + } + done <- d.handleSetKillSwitch(Request{ID: 2, On: true}) + }() + select { + case <-done: + t.Fatal("toggle replaced verified policy before acceptance") + case <-time.After(20 * time.Millisecond): + } + unblock() + select { + case resp := <-done: + if !resp.Ok { + t.Fatal(resp) + } + case <-time.After(time.Second): + t.Fatal("toggle stayed blocked") + } + deadline := time.Now().Add(time.Second) + for d.snapshotState().Protection.Status != "active" && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if s := d.snapshotState(); s.State != StateConnected || s.Protection.Status != "active" { + t.Fatal(s) + } + f.mu.Lock() + defer f.mu.Unlock() + if len(f.applied) == 0 || f.applied[len(f.applied)-1] != 42 { + t.Fatalf("active without verified TUN: %v", f.applied) + } + }) + } +} + +func TestHostProtectionRetryOnAfterInitialApplyFailure(t *testing.T) { + d, r, p := coreAuditDaemon(t) + f := &fakeHostProtection{applyErr: errors.New("injected")} + d.SetProtection(protection.New(f)) + d.routing.KillSwitch = true + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, "", false, false, "") + d.connMu.Unlock() + if err == nil { + t.Fatal("initial apply should fail") + } + f.mu.Lock() + f.applyErr = nil + f.mu.Unlock() + if resp := d.handleSetKillSwitch(Request{ID: 1, On: true}); !resp.Ok { + t.Fatal(resp) + } + if s := d.snapshotState(); s.Protection.Status != "blocked" || !s.Protection.Enforced || r.starts() != 0 { + t.Fatalf("retry did not apply idle lockdown: %+v", s) + } +} + +type refusingStopRunner struct { + *fakeRunner + refuse atomic.Bool +} + +func (r *refusingStopRunner) Stop() error { + if r.refuse.Load() { + return errors.New("injected stop refusal") + } + return r.fakeRunner.Stop() +} + +func TestHostProtectionLostVerifiedTunDemotesEvenWhenStopFails(t *testing.T) { + d, r, p := coreAuditDaemon(t) + wrapped := &refusingStopRunner{fakeRunner: r} + d.runner = wrapped + f := &fakeHostProtection{} + d.SetProtection(protection.New(f)) + d.routing.KillSwitch = true + d.tunWatchInterval = time.Millisecond + // This fake Windows guard verifies a named interface on every test host; + // macOS production intentionally leaves its kernel-selected utun name empty. + d.tun.InterfaceName = "tenebra-test" + d.ifacePresent = func(string) bool { return true } // an identically named replacement is present + coreAuditConnect(t, d, p, "") + deadline := time.Now().Add(time.Second) + for { + f.mu.Lock() + checked := f.resolveCalls > 1 + f.mu.Unlock() + if checked { + break + } + if time.Now().After(deadline) { + t.Fatal("watcher did not verify original TUN identity") + } + time.Sleep(time.Millisecond) + } + wrapped.refuse.Store(true) + defer wrapped.refuse.Store(false) + f.mu.Lock() + f.resolvedLUID = 99 + f.mu.Unlock() + deadline = time.Now().Add(time.Second) + for d.snapshotState().State != StateError && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if s := d.snapshotState(); s.State != StateError || s.Protection.Status != "blocked" || !s.Protection.Enforced || r.starts() != 1 { + t.Fatalf("lost TUN still accepted: %+v", s) + } +} + +func TestHostProtectionCrashBudgetStillBlocks(t *testing.T) { + d, r, p := coreAuditDaemon(t) + f := &fakeHostProtection{} + d.SetProtection(protection.New(f)) + d.routing.KillSwitch = true + coreAuditConnect(t, d, p, "") + d.mu.Lock() + d.relaunches = maxRelaunches + d.mu.Unlock() + r.exit(errors.New("engine crash")) + deadline := time.Now().Add(time.Second) + for d.snapshotState().State != StateError && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if s := d.snapshotState(); s.State != StateError || s.Protection.Status != "blocked" || !s.Protection.Persistent || r.starts() != 1 { + t.Fatal(s) + } + f.mu.Lock() + defer f.mu.Unlock() + if f.removed != 0 { + t.Fatal("crash cap removed persistent guard") + } +} + +func TestHostProtectionStateEventCarriesConfirmedResult(t *testing.T) { + s := State{State: StateError, Protection: protection.State{Status: "error", Enforced: true, Persistent: true, Error: "injected"}} + if event := stateEventBody(s); event.Protection != s.Protection { + t.Fatal(event) + } +} + +func TestHostProtectionPlainBootstrapRefusalPreservesSettingAndNoNetwork(t *testing.T) { + d, r, p := coreAuditDaemon(t) + d.SetProtection(protection.New(&fakeHostProtection{})) + d.routing.KillSwitch = true + d.routing.DNSDirect = "udp://192.0.2.53" + for _, lookup := range d.nodeLookups() { + if _, err := lookup(context.Background(), "example.test"); err == nil { + t.Fatal("bootstrap fell back to plain DNS") + } + } + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, "", false, false, "") + d.connMu.Unlock() + if err == nil || r.starts() != 0 || d.snapshotState().Protection.Status != "error" { + t.Fatal("plaintext bootstrap did not fail closed", err) + } + if d.snapshotRouting().DNSDirect != "udp://192.0.2.53" { + t.Fatal("saved resolver was silently overwritten") + } + if resp := d.handleSetDNS(Request{ID: 1, DNSDirect: "https://77.88.8.8/dns-query"}); !resp.Ok { + t.Fatal(resp) + } + before := d.snapshotRouting().DNSDirect + if resp := d.handleSetDNS(Request{ID: 2, DNSDirect: "udp://192.0.2.53"}); resp.Ok { + t.Fatal("protected settings accepted plaintext") + } + if d.snapshotRouting().DNSDirect != before { + t.Fatal("rejected setting overwrote current resolver") + } +} diff --git a/core/control/protocol.go b/core/control/protocol.go index 89aae2a4..3aa21353 100644 --- a/core/control/protocol.go +++ b/core/control/protocol.go @@ -21,6 +21,7 @@ import ( "io" "github.com/Divaaaan/tenebra/core/model" + "github.com/Divaaaan/tenebra/core/protection" ) // Command names. These are the cmd values a Request may carry. @@ -307,10 +308,10 @@ type State struct { // connect will use; an empty/off split omits them. Split string `json:"split,omitempty"` SplitApps []string `json:"split_apps,omitempty"` - // KillSwitch reports whether the kill switch is armed (strict_route on the - // tun, plus an automatic relaunch if the tunnel process dies). Omitted when - // off, like the split fields. - KillSwitch bool `json:"kill_switch,omitempty"` + // KillSwitch is the desired preference. Protection separately reports + // confirmed host enforcement; a true setting is never evidence of applied rules. + KillSwitch bool `json:"kill_switch,omitempty"` + Protection protection.State `json:"protection"` // TLSFragment reports whether forced TLS ClientHello fragmentation is armed — // every TLS-bearing outbound carries tls.fragment. Omitted when off, like the // kill switch. The adaptive walk still reaches fragmentation per-node on a diff --git a/core/control/protocol_test.go b/core/control/protocol_test.go index 2528c2ad..499cea7a 100644 --- a/core/control/protocol_test.go +++ b/core/control/protocol_test.go @@ -6,6 +6,8 @@ import ( "reflect" "strings" "testing" + + "github.com/Divaaaan/tenebra/core/protection" ) func TestRequestRoundTrip(t *testing.T) { @@ -61,7 +63,7 @@ func TestDecodeRequestBadJSON(t *testing.T) { func TestResponseMarshalShape(t *testing.T) { // A success response with data echoes id and ok and carries the data object. - resp, err := newResult(7, State{State: StateConnecting, Node: "n3"}) + resp, err := newResult(7, State{State: StateConnecting, Node: "n3", Protection: protection.State{Status: "off"}}) if err != nil { t.Fatalf("newResult: %v", err) } @@ -70,11 +72,13 @@ func TestResponseMarshalShape(t *testing.T) { t.Fatalf("marshal: %v", err) } got := string(b) - // Every empty field drops out except zapret_auto_update, which rides the wire + // Protection always carries explicit enforcement and persistence, including + // false; a client must never infer protection from a requested setting. Other + // empty fields drop out except zapret_auto_update, which rides the wire // even as false: it is the one flag here whose default is on, so a client // meeting an absence has to guess, and guessing "on" turns a user's "stop // updating the bundle" back into "keep updating it" (see State). - want := `{"id":7,"ok":true,"data":{"state":"connecting","node":"n3","zapret_auto_update":false}}` + want := `{"id":7,"ok":true,"data":{"state":"connecting","node":"n3","protection":{"status":"off","enforced":false,"persistent":false},"zapret_auto_update":false}}` if got != want { t.Errorf("response =\n %s\nwant %s", got, want) } @@ -102,8 +106,8 @@ func TestMarshalEventMergesFields(t *testing.T) { { name: "state", ev: EventState, - body: stateEvent{State: StateConnected, Node: "n3"}, - want: `{"event":"state","state":"connected","node":"n3"}`, + body: stateEvent{State: StateConnected, Node: "n3", Protection: protection.State{Status: "active", Enforced: true, Persistent: true}}, + want: `{"event":"state","state":"connected","node":"n3","protection":{"status":"active","enforced":true,"persistent":true}}`, }, { name: "traffic", diff --git a/core/control/proxy.go b/core/control/proxy.go index 4b11a65c..23e21065 100644 --- a/core/control/proxy.go +++ b/core/control/proxy.go @@ -1,6 +1,7 @@ package control import ( + "errors" "fmt" "net" "strings" @@ -24,53 +25,52 @@ type proxyState struct { // sequencing is unit-testable with a fake — the real registry/networksetup calls // run only in a live session, never a unit test. // -// The guard deliberately toggles the proxy on and off rather than saving and -// restoring a user's pre-existing proxy: a machine that already routes through a -// corporate proxy is not a machine that also needs this mode, and capture/restore -// adds a second failure surface. See the report's "left for live acceptance" note. +// Enable must retain enough ownership information to restore any partially +// applied change. Disable restores that snapshot and is harmless when Enable +// failed before changing anything. A failed Disable must retain its snapshot. type systemProxyController interface { // Enable points the OS at hostport (the loopback mixed inbound). Enable(hostport string) error - // Disable removes the proxy pointer, restoring direct connectivity. + // Disable restores the configuration owned by this controller. Disable() error // Get reads the current OS proxy configuration. It backs the startup reconcile // that clears a proxy a previous run left pointing at our mixed inbound. Get() (proxyState, error) } -// realSystemProxy is the production controller. Its methods defer to the -// build-tagged platform functions (proxy_windows.go / proxy_darwin.go / -// proxy_other.go), mirroring how newPingDialer defers to bindSocketToInterface. -type realSystemProxy struct{} - -func (realSystemProxy) Enable(hostport string) error { return enableSystemProxy(hostport) } -func (realSystemProxy) Disable() error { return disableSystemProxy() } -func (realSystemProxy) Get() (proxyState, error) { return readSystemProxy() } - -var _ systemProxyController = realSystemProxy{} - -// armSystemProxy points the OS at hostport and records that WE now own the proxy -// pointer, so disarmSystemProxy later knows to clear it. It is idempotent: a -// second call while already armed is a no-op, so a hot-swap that re-promotes the -// same connection doesn't rewrite the registry. A failure to enable is logged and -// leaves the guard disarmed — the tunnel is up but the OS still routes direct, -// which the user sees as "connected but not protected", a visible, safe failure -// rather than a half-set proxy. -func (d *Daemon) armSystemProxy(hostport string) { +// armSystemProxy confirms apply before the connection can be promoted. Ownership +// starts before Enable because an error can follow a partial OS mutation. +func (d *Daemon) armSystemProxy(hostport string) error { + d.proxyMu.Lock() + defer d.proxyMu.Unlock() d.mu.Lock() - already := d.proxyArmed + already := d.proxyApplied && d.proxyTarget == hostport + pending := d.proxyArmed d.mu.Unlock() if already { - return + return nil } - if err := d.proxy.Enable(hostport); err != nil { - d.emitLog(LogError, fmt.Sprintf("system proxy: could not point the OS at %s: %v", hostport, err)) - return + if pending { + if err := d.disarmSystemProxyLocked(); err != nil { + return fmt.Errorf("restore previous system proxy before applying: %w", err) + } } d.mu.Lock() d.proxyArmed = true d.mu.Unlock() + if err := d.proxy.Enable(hostport); err != nil { + rollback := d.disarmSystemProxyLocked() + if rollback != nil { + rollback = fmt.Errorf("rollback system proxy: %w", rollback) + } + return errors.Join(err, rollback) + } + d.mu.Lock() + d.proxyApplied = true + d.proxyTarget = hostport + d.mu.Unlock() d.emitLog(LogInfo, "system proxy: OS now routing through "+hostport) + return nil } // disarmSystemProxy clears the OS proxy pointer if (and only if) we armed it, @@ -78,22 +78,31 @@ func (d *Daemon) armSystemProxy(hostport string) { // leaves a system-proxy connection — an explicit disconnect, a tunnel-process // death, connect supersession, and daemon shutdown — funnels through it, so the // OS is never left pointing at a mixed inbound that is no longer listening. It is -// idempotent (a no-op when not armed) and clears the armed flag up front, so a -// Disable error can't wedge the guard into retrying forever; a persistent failure -// is logged loudly and the next startup's reconcile is the backstop. -func (d *Daemon) disarmSystemProxy() { +// idempotent. A failure retains ownership so a later disconnect/startup can retry. +func (d *Daemon) disarmSystemProxy() error { + d.proxyMu.Lock() + defer d.proxyMu.Unlock() + return d.disarmSystemProxyLocked() +} + +func (d *Daemon) disarmSystemProxyLocked() error { d.mu.Lock() armed := d.proxyArmed - d.proxyArmed = false + d.proxyApplied = false d.mu.Unlock() if !armed { - return + return nil } if err := d.proxy.Disable(); err != nil { - d.emitLog(LogError, fmt.Sprintf("system proxy: could not restore direct connectivity: %v; turn the proxy off in OS network settings", err)) - return + d.emitLog(LogError, fmt.Sprintf("system proxy: could not restore previous settings: %v; cleanup remains pending", err)) + return err } - d.emitLog(LogInfo, "system proxy: cleared; OS back to direct") + d.mu.Lock() + d.proxyArmed = false + d.proxyTarget = "" + d.mu.Unlock() + d.emitLog(LogInfo, "system proxy: previous settings restored") + return nil } // ReconcileSystemProxyAtStartup clears a system proxy a previous run left pointing @@ -110,6 +119,19 @@ func (d *Daemon) disarmSystemProxy() { // touches a proxy tenebra did not set. main calls it once at startup, before // serving, while the daemon is idle. It never arms anything. func (d *Daemon) ReconcileSystemProxyAtStartup() (cleared bool, err error) { + d.proxyMu.Lock() + defer d.proxyMu.Unlock() + if owned, ok := d.proxy.(interface{ Reconcile() (bool, error) }); ok { + found, restoreErr := owned.Reconcile() + d.mu.Lock() + if found { + d.proxyArmed = restoreErr != nil + d.proxyApplied = false + d.proxyTarget = "" + } + d.mu.Unlock() + return found && restoreErr == nil, restoreErr + } st, err := d.proxy.Get() if err != nil { return false, fmt.Errorf("read OS proxy state: %w", err) @@ -124,6 +146,32 @@ func (d *Daemon) ReconcileSystemProxyAtStartup() (cleared bool, err error) { return true, nil } +// ReconcileSystemProxyWhenIdle handles a console logon after service startup. +// Session notifications must not block the SCM handler or race a new connect. +func (d *Daemon) ReconcileSystemProxyWhenIdle() { + if !d.connMu.TryLock() { + return + } + defer d.connMu.Unlock() + st := d.snapshotState() + if st.State != StateIdle && st.State != StateError { + return + } + if cleared, err := d.ReconcileSystemProxyAtStartup(); err != nil { + d.emitLog(LogWarn, fmt.Sprintf("system proxy session restore: %v", err)) + d.mu.Lock() + pending := d.proxyArmed + d.mu.Unlock() + if pending { + st.State = StateError + st.Error = "system proxy restore remains pending: " + err.Error() + d.setState(st) + } + } else if cleared { + d.emitLog(LogInfo, "system proxy: recovered previous user settings after logon") + } +} + // sameProxyTarget reports whether two proxy server strings name the same // host:port, comparing case-insensitively on host and ignoring surrounding // whitespace. An unparseable or portless value on either side is treated as "not diff --git a/core/control/proxy_controller_other.go b/core/control/proxy_controller_other.go new file mode 100644 index 00000000..1e1678de --- /dev/null +++ b/core/control/proxy_controller_other.go @@ -0,0 +1,11 @@ +//go:build !windows + +package control + +type realSystemProxy struct{} + +func (realSystemProxy) Enable(target string) error { return enableSystemProxy(target) } +func (realSystemProxy) Disable() error { return disableSystemProxy() } +func (realSystemProxy) Get() (proxyState, error) { return readSystemProxy() } +func newSystemProxyController() systemProxyController { return realSystemProxy{} } +func RunUserProxyHelper([]string) (bool, error) { return false, nil } diff --git a/core/control/proxy_lock_policy.go b/core/control/proxy_lock_policy.go new file mode 100644 index 00000000..13be61e6 --- /dev/null +++ b/core/control/proxy_lock_policy.go @@ -0,0 +1,61 @@ +package control + +import ( + "errors" + "fmt" + "strings" +) + +// Only a local, unambiguous KnownFolder path is accepted. Do not derive this +// location from LOCALAPPDATA, TEMP, a working directory or a caller argument. +func proxyLockNTPath(path string) (string, error) { + if len(path) < 4 || !((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z')) || path[1:3] != `:\` { + return "", errors.New("user proxy lock requires an absolute local known folder") + } + for _, part := range strings.Split(path[3:], `\`) { + if part == "" || part == "." || part == ".." || strings.TrimRight(part, ". ") != part || strings.ContainsAny(part, ":/\x00") { + return "", errors.New("ambiguous user proxy lock path") + } + device := strings.ToUpper(strings.SplitN(part, ".", 2)[0]) + if device == "CON" || device == "PRN" || device == "AUX" || device == "NUL" || device == "CONIN$" || device == "CONOUT$" || (len(device) == 4 && (strings.HasPrefix(device, "COM") || strings.HasPrefix(device, "LPT")) && device[3] >= '1' && device[3] <= '9') { + return "", errors.New("device name in user proxy lock path") + } + } + return `\??\` + path, nil +} + +type proxyLockGrant struct { + SID string + Mask uint32 +} + +type proxyLockNode struct { + Directory, Reparse, MultipleLinks bool + Owner string + DACLPresent, Protected bool + Grants []proxyLockGrant +} + +func trustedProxyLockSID(sid, user string) bool { + return sid != "" && (sid == user || sid == "S-1-5-18" || sid == "S-1-5-32-544") +} + +// Same-user and administrator writes are already authorized to change that +// user's proxy. Everyone else must be unable to replace the directory or lock. +func validateProxyLockNode(n proxyLockNode, user string, directory, private bool) error { + if user == "" || n.Directory != directory || n.Reparse || (!directory && n.MultipleLinks) { + return errors.New("user proxy lock path has an unexpected file type or link") + } + if !trustedProxyLockSID(n.Owner, user) || !n.DACLPresent || (private && !n.Protected) { + return errors.New("user proxy lock ownership or private DACL is unsafe") + } + // FILE_WRITE_DATA/APPEND_DATA/WRITE_EA/DELETE_CHILD/WRITE_ATTRIBUTES, + // DELETE/WRITE_DAC/WRITE_OWNER, GENERIC_WRITE/ALL and MAXIMUM_ALLOWED. + const mutation = 0x2 | 0x4 | 0x10 | 0x40 | 0x100 | 0x10000 | 0x40000 | 0x80000 | 0x40000000 | 0x10000000 | 0x02000000 + for _, grant := range n.Grants { + if !trustedProxyLockSID(grant.SID, user) && ((private && grant.Mask != 0) || grant.Mask&mutation != 0) { + return fmt.Errorf("user proxy lock permits access by another identity: %s", grant.SID) + } + } + return nil +} diff --git a/core/control/proxy_lock_policy_test.go b/core/control/proxy_lock_policy_test.go new file mode 100644 index 00000000..7610557f --- /dev/null +++ b/core/control/proxy_lock_policy_test.go @@ -0,0 +1,73 @@ +package control + +import ( + "fmt" + "testing" +) + +func TestProxyLockPathRejectsAmbiguousAndRemoteLocations(t *testing.T) { + for _, path := range []string{"", `C:relative`, `\\server\share\Local`, `\\?\C:\Local`, `C:\Users\u\..\other`, `C:\Users\u\Local.`, `C:\Users\u\Local `, `C:\Users\u\Local:stream`, `C:\Users\NUL\Local`, `C:\Users\u\\Local`, "C:\\Users\\u\\Local\x00"} { + if _, err := proxyLockNTPath(path); err == nil { + t.Errorf("accepted unsafe known folder %q", path) + } + } + got, err := proxyLockNTPath(`C:\Users\Даня\AppData\Local`) + if err != nil || got != `\??\C:\Users\Даня\AppData\Local` { + t.Fatalf("valid Unicode known folder failed: %q %v", got, err) + } +} + +func TestProxyLockPolicyRejectsOtherUsersAndReparsePoints(t *testing.T) { + const user = "S-1-5-21-1000" + good := proxyLockNode{Directory: true, Owner: user, DACLPresent: true, Protected: true, + Grants: []proxyLockGrant{{SID: user, Mask: 0x1f01ff}, {SID: "S-1-5-18", Mask: 0x1f01ff}, {SID: "S-1-5-32-544", Mask: 0x1f01ff}}} + if err := validateProxyLockNode(good, user, true, true); err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + change func(*proxyLockNode) + }{ + {"foreign owner", func(n *proxyLockNode) { n.Owner = "S-1-5-21-2000" }}, + {"null DACL", func(n *proxyLockNode) { n.DACLPresent = false }}, + {"inheritable private DACL", func(n *proxyLockNode) { n.Protected = false }}, + {"junction", func(n *proxyLockNode) { n.Reparse = true }}, + {"file instead of directory", func(n *proxyLockNode) { n.Directory = false }}, + {"foreign read grant", func(n *proxyLockNode) { n.Grants = append(n.Grants, proxyLockGrant{SID: "S-1-1-0", Mask: 0x80000000}) }}, + } { + t.Run(test.name, func(t *testing.T) { + n := good + test.change(&n) + if validateProxyLockNode(n, user, true, true) == nil { + t.Fatal("unsafe private lock location accepted") + } + }) + } + file := good + file.Directory = false + if err := validateProxyLockNode(file, user, false, true); err != nil { + t.Fatal(err) + } + file.MultipleLinks = true + if validateProxyLockNode(file, user, false, true) == nil { + t.Fatal("hard-linked lock file accepted") + } +} + +func TestProxyLockKnownFolderPermitsReadOnlyButNotForeignMutation(t *testing.T) { + const user = "S-1-5-21-1000" + n := proxyLockNode{Directory: true, Owner: user, DACLPresent: true, + Grants: []proxyLockGrant{{SID: user, Mask: 0x1f01ff}, {SID: "S-1-5-32-545", Mask: 0x1200a9}}} + if err := validateProxyLockNode(n, user, true, false); err != nil { + t.Fatal(err) + } + for _, mask := range []uint32{0x2, 0x4, 0x10, 0x40, 0x100, 0x10000, 0x40000, 0x80000, 0x40000000, 0x10000000, 0x02000000} { + t.Run(fmt.Sprintf("%#x", mask), func(t *testing.T) { + bad := n + bad.Grants = append(bad.Grants, proxyLockGrant{SID: "S-1-5-21-2000", Mask: mask}) + if validateProxyLockNode(bad, user, true, false) == nil { + t.Errorf("foreign mutation mask %#x accepted", mask) + } + }) + } +} diff --git a/core/control/proxy_lock_windows.go b/core/control/proxy_lock_windows.go new file mode 100644 index 00000000..5593b319 --- /dev/null +++ b/core/control/proxy_lock_windows.go @@ -0,0 +1,153 @@ +//go:build windows + +package control + +import ( + "errors" + "fmt" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +const proxyLockDirectory = "Tenebra-private-proxy" + +// The parent and child handles remain open throughout the operation. Children +// are opened relative to verified handles, so renaming any path ancestor cannot +// redirect a subsequent create. OBJ_DONT_REPARSE rejects junctions/symlinks. +// No global kernel object is exposed to precreation by another logged-in user. +func acquireUserProxyLock(sid string) (func(), error) { + basePath, err := windows.KnownFolderPath(windows.FOLDERID_LocalAppData, 0) + if err != nil { + return nil, fmt.Errorf("resolve user proxy lock known folder: %w", err) + } + ntPath, err := proxyLockNTPath(basePath) + if err != nil { + return nil, err + } + var handles []windows.Handle + release := func() { + for i := len(handles) - 1; i >= 0; i-- { + windows.CloseHandle(handles[i]) + } + handles = nil + } + ok := false + defer func() { + if !ok { + release() + } + }() + base, err := openProxyLockNode(0, ntPath, true, false, nil) + if err != nil { + return nil, fmt.Errorf("open user proxy lock known folder without reparse: %w", err) + } + handles = append(handles, base) + if err := checkProxyLockHandle(base, sid, true, false); err != nil { + return nil, err + } + sd, err := windows.SecurityDescriptorFromString("O:" + sid + "D:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;" + sid + ")") + if err != nil { + return nil, err + } + dir, err := openProxyLockNode(base, proxyLockDirectory, true, true, sd) + if err != nil { + return nil, fmt.Errorf("open private user proxy lock directory: %w", err) + } + handles = append(handles, dir) + if err := checkProxyLockHandle(dir, sid, true, true); err != nil { + return nil, err + } + deadline := time.Now().Add(5 * time.Second) + for { + lock, err := openProxyLockNode(dir, "operation.lock", false, true, sd) + if err == nil { + handles = append(handles, lock) + if err := checkProxyLockHandle(lock, sid, false, true); err != nil { + return nil, err + } + ok = true + return release, nil + } + if !errors.Is(err, windows.STATUS_SHARING_VIOLATION) || !time.Now().Before(deadline) { + return nil, fmt.Errorf("acquire private user proxy lock: %w", err) + } + time.Sleep(20 * time.Millisecond) + } +} + +func openProxyLockNode(parent windows.Handle, name string, directory, create bool, sd *windows.SECURITY_DESCRIPTOR) (windows.Handle, error) { + objectName, err := windows.NewNTUnicodeString(name) + if err != nil { + return 0, err + } + oa := windows.OBJECT_ATTRIBUTES{ + RootDirectory: parent, ObjectName: objectName, + Attributes: windows.OBJ_CASE_INSENSITIVE | windows.OBJ_DONT_REPARSE, + SecurityDescriptor: sd, + } + oa.Length = uint32(unsafe.Sizeof(oa)) + access := uint32(windows.READ_CONTROL | windows.FILE_READ_ATTRIBUTES | windows.SYNCHRONIZE) + options := uint32(windows.FILE_SYNCHRONOUS_IO_NONALERT | windows.FILE_NON_DIRECTORY_FILE) + share := uint32(0) // exclusive file handle; released automatically after a crash + if directory { + access |= windows.FILE_TRAVERSE + options = windows.FILE_SYNCHRONOUS_IO_NONALERT | windows.FILE_DIRECTORY_FILE + share = windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE // never allow delete/rename + } else { + access |= windows.FILE_READ_DATA | windows.FILE_WRITE_DATA + } + disposition := uint32(windows.FILE_OPEN) + if create { + disposition = windows.FILE_OPEN_IF // never truncate an existing object + } + var handle windows.Handle + var status windows.IO_STATUS_BLOCK + err = windows.NtCreateFile(&handle, access, &oa, &status, nil, windows.FILE_ATTRIBUTE_NORMAL, share, disposition, options, 0, 0) + return handle, err +} + +func checkProxyLockHandle(handle windows.Handle, user string, directory, private bool) error { + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return err + } + sd, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION) + if err != nil { + return err + } + owner, _, err := sd.Owner() + if err != nil || owner == nil { + return errors.New("user proxy lock owner unavailable") + } + acl, _, err := sd.DACL() + if err != nil || acl == nil { + return errors.New("user proxy lock DACL unavailable") + } + control, _, err := sd.Control() + if err != nil { + return err + } + node := proxyLockNode{ + Directory: info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0, + Reparse: info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0, + MultipleLinks: info.NumberOfLinks != 1, Owner: owner.String(), + DACLPresent: true, Protected: control&windows.SE_DACL_PROTECTED != 0, + } + for i := uint32(0); i < uint32(acl.AceCount); i++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(acl, i, &ace); err != nil { + return err + } + if ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 || ace.Header.AceType == windows.ACCESS_DENIED_ACE_TYPE { + continue // neither grants access to this object + } + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + return errors.New("unsupported user proxy lock ACL entry") + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + node.Grants = append(node.Grants, proxyLockGrant{SID: sid.String(), Mask: uint32(ace.Mask)}) + } + return validateProxyLockNode(node, user, directory, private) +} diff --git a/core/control/proxy_mode_control_test.go b/core/control/proxy_mode_control_test.go index d49a5396..e4792206 100644 --- a/core/control/proxy_mode_control_test.go +++ b/core/control/proxy_mode_control_test.go @@ -153,23 +153,33 @@ func TestSetProxyModeLiveHotSwapArmsAndDisarms(t *testing.T) { // mixed inbound and arms the OS proxy once the swapped tunnel comes up. h.send(Request{ID: 2, Cmd: CmdSetProxyMode, ProxyMode: "system-proxy"}) h.await() - h.waitStarts(2) - h.awaitLogContains("system proxy: OS now routing") - if f.enables() != 1 { - t.Errorf("enables = %d after swap to system-proxy, want 1", f.enables()) + h.awaitRestartConnected(2) + if f.enables() != 1 || f.disables() != 0 || f.lastHostPort() != "127.0.0.1:2080" { + t.Errorf("proxy after swap: enables=%d restores=%d target=%q, want 1/0/127.0.0.1:2080", f.enables(), f.disables(), f.lastHostPort()) + } + h.daemon.mu.Lock() + applied := h.daemon.proxyArmed && h.daemon.proxyApplied + h.daemon.mu.Unlock() + if !applied { + t.Error("connected system-proxy mode has no confirmed proxy ownership") } if got := firstInboundType(t, lastCfg(t, h)); got != "mixed" { t.Errorf("hot-swapped inbound type = %q, want mixed", got) } - // Switch back to tun while connected: the teardown clears the OS proxy before - // the tun tunnel comes up. + // Switch back to tun while connected: the teardown restores the previous + // proxy settings exactly once before the tun tunnel comes up. h.send(Request{ID: 3, Cmd: CmdSetProxyMode, ProxyMode: "tun"}) h.await() - h.waitStarts(3) - h.awaitLogContains("system proxy: cleared") - if f.disables() < 1 { - t.Errorf("switching back to tun did not clear the proxy (disables=%d)", f.disables()) + h.awaitRestartConnected(3) + if f.enables() != 1 || f.disables() != 1 { + t.Errorf("proxy after restoring tun: enables=%d restores=%d, want 1/1", f.enables(), f.disables()) + } + h.daemon.mu.Lock() + pending := h.daemon.proxyArmed || h.daemon.proxyApplied || h.daemon.proxyTarget != "" + h.daemon.mu.Unlock() + if pending { + t.Error("successful proxy restore retained ownership") } if got := firstInboundType(t, lastCfg(t, h)); got != "tun" { t.Errorf("swapped-back inbound type = %q, want tun", got) diff --git a/core/control/proxy_other.go b/core/control/proxy_other.go index c9c1625b..5a3ac19b 100644 --- a/core/control/proxy_other.go +++ b/core/control/proxy_other.go @@ -9,14 +9,14 @@ import "errors" // proxy there means writing per-desktop settings (GNOME's gsettings, KDE's // kioslaverc, and a session's own environment) as the logged-in user, which a // root daemon has no session bus to reach — a separate piece of work from -// bringing the tun path up. The daemon degrades gracefully: arming logs this and -// stays disarmed, so system-proxy mode simply doesn't take effect rather than -// crashing the core, and tun mode — the default — is unaffected. +// bringing the tun path up. The daemon reports a local setup failure rather +// than promoting a connection whose OS proxy could not be applied. TUN remains +// the default supported mode. var errSystemProxyUnsupported = errors.New("control: system proxy is not supported on this platform") func enableSystemProxy(string) error { return errSystemProxyUnsupported } -func disableSystemProxy() error { return errSystemProxyUnsupported } +func disableSystemProxy() error { return nil } // unsupported apply cannot mutate the OS // readSystemProxy reports "no proxy set" with no error so the startup reconcile // finds nothing to clear rather than logging a spurious failure on every launch. diff --git a/core/control/proxy_safety_test.go b/core/control/proxy_safety_test.go new file mode 100644 index 00000000..994eeca8 --- /dev/null +++ b/core/control/proxy_safety_test.go @@ -0,0 +1,125 @@ +package control + +import ( + "context" + "errors" + "net" + "strings" + "testing" + "time" + + "github.com/Divaaaan/tenebra/core/model" + "github.com/Divaaaan/tenebra/core/profile" + "github.com/Divaaaan/tenebra/core/singbox" +) + +// The external boundaries are fake: these tests never mutate the host proxy or +// start/stop the real engine or bypass. In particular, cleanup does not use Close. +func proxySafetyDaemon(t *testing.T) (*Daemon, *fakeRunner, profile.Profile) { + t.Helper() + s, err := profile.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + p, err := profile.NewProfile("proxy safety", profile.SourceManual, "", []model.Node{{ + Protocol: model.VLESS, Name: "fixture", Server: "192.0.2.1", Port: 443, + UUID: "123e4567-e89b-12d3-a456-426614174000", + }}) + if err != nil { + t.Fatal(err) + } + if err := s.Add(p); err != nil { + t.Fatal(err) + } + r := newFakeRunner() + d := newUnitTestDaemon(s, r) + d.localAddrs = func() []net.Addr { return nil } + d.tunWatchInterval, d.healthInterval, d.bypassVerifyDelay = 0, 0, 0 + d.proxy = &fakeProxyController{} + d.probeWarmup, d.probeRetry = time.Millisecond, time.Millisecond + d.probeTimeout, d.probeBudget = time.Second, time.Second + t.Cleanup(func() { + d.connMu.Lock() + d.teardown(StateIdle, "", "") + d.connMu.Unlock() + d.relaunchWG.Wait() + d.entCancel() + }) + return d, r, p +} + +func TestProxyApplyFailureDoesNotPublishConnected(t *testing.T) { + d, r, p := proxySafetyDaemon(t) + f := &fakeProxyController{enableErr: errors.New("user proxy apply denied")} + d.proxy = f + d.tun.Mode = singbox.ModeSystemProxy + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, p.Servers[0].ID, false, false, "") + d.connMu.Unlock() + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + st := d.snapshotState() + if st.State == StateConnected { + t.Fatal("published connected despite failed OS proxy apply") + } + if st.State == StateError { + if !strings.Contains(st.Error, "system proxy") { + t.Fatalf("unhelpful error: %q", st.Error) + } + if r.stops() == 0 { + t.Fatal("unused engine left running after proxy failure") + } + if f.disables() != 1 { + t.Fatalf("rollback calls = %d, want 1", f.disables()) + } + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("proxy failure never reached error state") +} + +func TestProxyCleanupFailureRetainsOwnershipUntilRetrySucceeds(t *testing.T) { + d, _, _ := proxySafetyDaemon(t) + f := &fakeProxyController{disableErr: errors.New("temporary cleanup failure")} + d.proxy = f + d.armSystemProxy("127.0.0.1:2080") + d.disarmSystemProxy() + d.mu.Lock() + pending := d.proxyArmed + d.mu.Unlock() + if !pending { + t.Fatal("cleanup failure discarded ownership") + } + f.mu.Lock() + f.disableErr = nil + f.mu.Unlock() + d.disarmSystemProxy() + d.disarmSystemProxy() + if f.disables() != 2 { + t.Fatalf("cleanup attempts=%d, want failed + successful", f.disables()) + } + d.mu.Lock() + pending = d.proxyArmed + d.mu.Unlock() + if pending { + t.Fatal("successful cleanup did not release ownership") + } +} + +func TestPartialProxyApplyRollsBackBeforeReportingFailure(t *testing.T) { + d, _, _ := proxySafetyDaemon(t) + f := &fakeProxyController{enableErr: errors.New("refresh failed after registry write")} + d.proxy = f + d.armSystemProxy("127.0.0.1:2080") + if f.disables() != 1 { + t.Fatal("potentially partial application was not rolled back") + } + d.disarmSystemProxy() + if f.disables() != 1 { + t.Fatal("successful rollback was repeated") + } +} diff --git a/core/control/proxy_test.go b/core/control/proxy_test.go index 504407c7..c4c1348b 100644 --- a/core/control/proxy_test.go +++ b/core/control/proxy_test.go @@ -84,7 +84,7 @@ func bareDaemonWithProxy(t *testing.T) (*Daemon, *fakeProxyController) { if err != nil { t.Fatalf("open store: %v", err) } - d := NewDaemon(store, newFakeRunner()) + d := newUnitTestDaemon(store, newFakeRunner()) f := &fakeProxyController{} d.proxy = f return d, f @@ -124,11 +124,8 @@ func TestSystemProxyDisarmWithoutArmIsNoop(t *testing.T) { } } -// TestSystemProxyArmFailureLeavesDisarmed: a failed Enable must leave the guard -// disarmed, so a later teardown does not wrongly believe it owns (and then clear) -// a proxy that was never set. The tunnel is up but unprotected — a visible, safe -// failure, not a corrupt half-state. -func TestSystemProxyArmFailureLeavesDisarmed(t *testing.T) { +// A failed Enable can have partially applied state, so it must be rolled back. +func TestSystemProxyArmFailureRollsBack(t *testing.T) { d, f := bareDaemonWithProxy(t) f.enableErr = errors.New("registry write denied") @@ -137,8 +134,8 @@ func TestSystemProxyArmFailureLeavesDisarmed(t *testing.T) { t.Errorf("enables = %d, want 1 attempt", f.enables()) } d.disarmSystemProxy() - if f.disables() != 0 { - t.Errorf("disarm after a failed arm called Disable %d times, want 0 (nothing was set)", f.disables()) + if f.disables() != 1 { + t.Errorf("disarm after a failed arm called Disable %d times, want 1 rollback", f.disables()) } } diff --git a/core/control/proxy_windows.go b/core/control/proxy_windows.go index 7d649862..38903255 100644 --- a/core/control/proxy_windows.go +++ b/core/control/proxy_windows.go @@ -3,98 +3,382 @@ package control import ( + "encoding/json" + "errors" "fmt" + "os" + "path/filepath" + "runtime" "strings" + "unsafe" "golang.org/x/sys/windows" "golang.org/x/sys/windows/registry" ) -// inetSettingsKey is the per-user WinINet configuration key. Writing ProxyEnable -// and ProxyServer here is exactly what the Internet Options dialog does, so it -// applies without admin rights — the whole point of system-proxy mode on a -// locked-down machine. -const inetSettingsKey = `Software\Microsoft\Windows\CurrentVersion\Internet Settings` - -// WinINet InternetSetOption codes, from Wininet.h. SETTINGS_CHANGED tells running -// processes the proxy config changed; REFRESH makes them reload it, so an open -// browser honours the new setting without a restart. const ( + inetSettingsKey = `Software\Microsoft\Windows\CurrentVersion\Internet Settings` + proxyLeaseKey = `Software\Tenebra` + proxyLeaseValue = "SystemProxyLease" + proxyHelperFlag = "--user-proxy-helper" + proxyLeaseMaxBytes = 64 << 10 internetOptionSettingsChanged = 39 internetOptionRefresh = 37 + internetOptionPerConnection = 75 ) var ( - modWininet = windows.NewLazySystemDLL("wininet.dll") - procInternetSetOption = modWininet.NewProc("InternetSetOptionW") + modWininet = windows.NewLazySystemDLL("wininet.dll") + procInternetSetOption = modWininet.NewProc("InternetSetOptionW") + procInternetQueryOption = modWininet.NewProc("InternetQueryOptionW") + procProxyGlobalFree = windows.NewLazySystemDLL("kernel32.dll").NewProc("GlobalFree") + procProxyRegFlushKey = windows.NewLazySystemDLL("advapi32.dll").NewProc("RegFlushKey") ) -// enableSystemProxy points the current user's WinINet proxy at hostport for all -// protocols and refreshes live so open apps pick it up without a restart. -func enableSystemProxy(hostport string) error { - k, err := registry.OpenKey(registry.CURRENT_USER, inetSettingsKey, registry.SET_VALUE) +func newSystemProxyController() systemProxyController { + return &sessionSystemProxy{ops: windowsProxySessions{}} +} + +type windowsProxySessions struct{} + +func (windowsProxySessions) Current() (proxyUser, error) { + self, err := currentUserSID() + if err != nil { + return proxyUser{}, err + } + if self != "S-1-5-18" { + var session uint32 + if err := windows.ProcessIdToSessionId(windows.GetCurrentProcessId(), &session); err != nil { + return proxyUser{}, err + } + return proxyUser{SID: self, Session: session}, nil + } + session := windows.WTSGetActiveConsoleSessionId() + if session == 0xffffffff { + return proxyUser{}, errors.New("no active console user for system proxy") + } + tok, err := proxySessionToken(proxyUser{Session: session}) + if err != nil { + return proxyUser{}, err + } + defer tok.Close() + u, err := tok.GetTokenUser() if err != nil { - return fmt.Errorf("open Internet Settings: %w", err) + return proxyUser{}, err + } + return proxyUser{SID: u.User.Sid.String(), Session: session}, nil +} + +// A reused session ID must never restore a lease into another user's account. +func proxySessionToken(u proxyUser) (windows.Token, error) { + var tok windows.Token + if err := windows.WTSQueryUserToken(u.Session, &tok); err != nil { + return 0, fmt.Errorf("open interactive user token: %w", err) + } + tu, err := tok.GetTokenUser() + if err != nil || (u.SID != "" && tu.User.Sid.String() != u.SID) { + tok.Close() + return 0, errors.New("system proxy owner session is unavailable or changed") + } + return tok, nil +} + +func (windowsProxySessions) Run(u proxyUser, action, target string) error { + self, err := currentUserSID() + if err != nil { + return err + } + if self != "S-1-5-18" { + if self != u.SID { + return errors.New("system proxy owner differs from the current user") + } + return runUserProxyAction(action, target) + } + tok, err := proxySessionToken(u) + if err != nil { + return err + } + defer tok.Close() + return launchUserProxyHelper(tok, action, target) +} + +func openProxyUserKey(u proxyUser, path string) (registry.Key, error) { + if u.SID == "" { + return 0, errors.New("missing system proxy owner") + } + return registry.OpenKey(registry.USERS, u.SID+`\`+path, registry.QUERY_VALUE) +} + +func (windowsProxySessions) Read(u proxyUser) (proxyState, error) { + k, err := openProxyUserKey(u, inetSettingsKey) + if err != nil { + return proxyState{}, err } defer k.Close() - // ProxyServer as a bare host:port applies to every protocol (HTTP/HTTPS), which - // is what the mixed inbound serves. - if err := k.SetStringValue("ProxyServer", hostport); err != nil { - return fmt.Errorf("set ProxyServer: %w", err) + on, _, err := k.GetIntegerValue("ProxyEnable") + if err != nil && !errors.Is(err, registry.ErrNotExist) { + return proxyState{}, err } - if err := k.SetDWordValue("ProxyEnable", 1); err != nil { - return fmt.Errorf("set ProxyEnable: %w", err) + server, _, err := k.GetStringValue("ProxyServer") + if err != nil && !errors.Is(err, registry.ErrNotExist) { + return proxyState{}, err } - return refreshWinINet() + return proxyState{Enabled: on == 1, Server: firstProxyTarget(server)}, nil } -// disableSystemProxy turns the current user's WinINet proxy off and refreshes. It -// leaves ProxyServer in place — harmless once ProxyEnable is 0 — so this touches -// only the flag, minimising what the guard rewrites. -func disableSystemProxy() error { - k, err := registry.OpenKey(registry.CURRENT_USER, inetSettingsKey, registry.SET_VALUE) +func (windowsProxySessions) HasLease(u proxyUser) (bool, error) { + k, err := openProxyUserKey(u, proxyLeaseKey) + if errors.Is(err, registry.ErrNotExist) { + return false, nil + } if err != nil { - return fmt.Errorf("open Internet Settings: %w", err) + return false, err } defer k.Close() - if err := k.SetDWordValue("ProxyEnable", 0); err != nil { - return fmt.Errorf("clear ProxyEnable: %w", err) + _, _, err = k.GetValue(proxyLeaseValue, nil) + if errors.Is(err, registry.ErrNotExist) { + return false, nil } - return refreshWinINet() + return err == nil, err } -// readSystemProxy reads ProxyEnable/ProxyServer for the startup reconcile. A -// missing value reads as off/empty rather than an error, so a machine that never -// had a proxy set is simply "not enabled". -func readSystemProxy() (proxyState, error) { - k, err := registry.OpenKey(registry.CURRENT_USER, inetSettingsKey, registry.QUERY_VALUE) +// WinINet is unsupported in services. Run the installed, administrator-protected +// core as the interactive user, before any normal daemon initialization. +// No inherited handles cross the session boundary. +// https://learn.microsoft.com/en-us/windows/win32/wininet/enabling-internet-functionality +func launchUserProxyHelper(tok windows.Token, action, target string) error { + exe, err := os.Executable() if err != nil { - return proxyState{}, fmt.Errorf("open Internet Settings: %w", err) + return err + } + args := []string{exe, proxyHelperFlag, action} + if action == "apply" { + args = append(args, target) + } + app, err := windows.UTF16PtrFromString(exe) + if err != nil { + return err + } + cmd, err := windows.UTF16PtrFromString(windows.ComposeCommandLine(args)) + if err != nil { + return err + } + dir, err := windows.UTF16PtrFromString(filepath.Dir(exe)) + if err != nil { + return err + } + desktop, _ := windows.UTF16PtrFromString(`winsta0\default`) + var env *uint16 + if err := windows.CreateEnvironmentBlock(&env, tok, false); err != nil { + return fmt.Errorf("create user environment: %w", err) + } + defer windows.DestroyEnvironmentBlock(env) + si := windows.StartupInfo{Cb: uint32(unsafe.Sizeof(windows.StartupInfo{})), Desktop: desktop, Flags: windows.STARTF_USESHOWWINDOW, ShowWindow: windows.SW_HIDE} + var pi windows.ProcessInformation + if err := windows.CreateProcessAsUser(tok, app, cmd, nil, nil, false, windows.CREATE_UNICODE_ENVIRONMENT|windows.CREATE_NO_WINDOW, env, dir, &si, &pi); err != nil { + return fmt.Errorf("start interactive user proxy helper: %w", err) + } + defer windows.CloseHandle(pi.Process) + windows.CloseHandle(pi.Thread) + wait, err := windows.WaitForSingleObject(pi.Process, 12_000) + if err != nil || wait != windows.WAIT_OBJECT_0 { + // Only this helper is terminated. The durable snapshot survives timeout; + // the daemon retains cleanup ownership and retries restore. + _ = windows.TerminateProcess(pi.Process, 1) + _, _ = windows.WaitForSingleObject(pi.Process, 1_000) + return errors.New("interactive user proxy helper timed out or could not be waited for; restore remains pending") + } + var code uint32 + if err := windows.GetExitCodeProcess(pi.Process, &code); err != nil { + return err + } + if code != 0 { + return fmt.Errorf("interactive user proxy %s failed (exit %d)", action, code) + } + return nil +} + +// RunUserProxyHelper recognizes a tiny protocol before flag parsing/service +// detection. It can never launch an engine or background jobs. +func RunUserProxyHelper(args []string) (bool, error) { + if len(args) == 0 || args[0] != proxyHelperFlag { + return false, nil + } + if len(args) == 2 && args[1] == "restore" { + return true, runUserProxyAction("restore", "") + } + if len(args) == 3 && args[1] == "apply" && validUserProxyTarget(args[2]) { + return true, runUserProxyAction("apply", args[2]) + } + return true, errors.New("invalid user proxy helper arguments") +} + +func runUserProxyAction(action, target string) error { + if action != "restore" && (action != "apply" || !validUserProxyTarget(target)) { + return errors.New("invalid user proxy operation") + } + sid, err := currentUserSID() + if err != nil { + return err + } + if sid == "S-1-5-18" || sid == "S-1-5-19" || sid == "S-1-5-20" { + return errors.New("WinINet proxy helper requires an interactive user") + } + release, err := acquireUserProxyLock(sid) + if err != nil { + return err + } + defer release() + ops := wininetProxyOperations{} + if action == "apply" { + return applyUserProxy(ops, target) + } + return restoreUserProxy(ops) +} + +type wininetProxyOperations struct{} + +func (wininetProxyOperations) Load() (*userProxyLease, error) { + k, err := registry.OpenKey(registry.CURRENT_USER, proxyLeaseKey, registry.QUERY_VALUE) + if errors.Is(err, registry.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err } defer k.Close() + buf := make([]byte, proxyLeaseMaxBytes) + n, kind, err := k.GetValue(proxyLeaseValue, buf) + if errors.Is(err, registry.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + if kind != registry.BINARY || n > len(buf) { + return nil, errors.New("invalid user proxy snapshot format") + } + var lease userProxyLease + if err := json.Unmarshal(buf[:n], &lease); err != nil { + return nil, errors.New("invalid user proxy snapshot JSON") + } + return &lease, nil +} - enable, _, err := k.GetIntegerValue("ProxyEnable") - if err != nil && err != registry.ErrNotExist { - return proxyState{}, fmt.Errorf("read ProxyEnable: %w", err) +func (wininetProxyOperations) Save(lease userProxyLease) error { + buf, err := json.Marshal(lease) + if err != nil { + return err } - server, _, err := k.GetStringValue("ProxyServer") - if err != nil && err != registry.ErrNotExist { - return proxyState{}, fmt.Errorf("read ProxyServer: %w", err) + if len(buf) > proxyLeaseMaxBytes { + return errors.New("user proxy snapshot is too large") } - return proxyState{Enabled: enable == 1, Server: firstProxyTarget(server)}, nil + k, _, err := registry.CreateKey(registry.CURRENT_USER, proxyLeaseKey, registry.SET_VALUE) + if err != nil { + return err + } + defer k.Close() + if err := k.SetBinaryValue(proxyLeaseValue, buf); err != nil { + return err + } + return flushProxyJournal(k) } -// firstProxyTarget extracts a bare host:port from a WinINet ProxyServer value. -// The value is either a single "host:port" (what enableSystemProxy writes) or a -// per-protocol list like "http=127.0.0.1:2080;https=127.0.0.1:2080"; the reconcile -// only needs one target to compare, so strip any "scheme=" prefix and take the -// first entry. A plain value passes through unchanged. -func firstProxyTarget(v string) string { - v = strings.TrimSpace(v) - if v == "" { - return "" +func (wininetProxyOperations) Delete() error { + k, err := registry.OpenKey(registry.CURRENT_USER, proxyLeaseKey, registry.SET_VALUE) + if errors.Is(err, registry.ErrNotExist) { + return nil + } + if err != nil { + return err + } + defer k.Close() + if err := k.DeleteValue(proxyLeaseValue); err != nil && !errors.Is(err, registry.ErrNotExist) { + return err + } + return flushProxyJournal(k) +} + +func flushProxyJournal(k registry.Key) error { + code, _, _ := procProxyRegFlushKey.Call(uintptr(k)) + if code != 0 { + return fmt.Errorf("flush user proxy snapshot: %w", windows.Errno(code)) + } + return nil +} + +// INTERNET_PER_CONN_OPTION's union is eight bytes (FILETIME), aligned as a +// pointer on 64-bit Windows and as a DWORD on 32-bit Windows. +type internetPerConnOption struct { + Option uint32 + Value uint64 +} +type internetPerConnList struct { + Size uint32 + Connection *uint16 + Count uint32 + Error uint32 + Options *internetPerConnOption +} + +func proxyOptionString(o *internetPerConnOption) *uint16 { + return *(**uint16)(unsafe.Pointer(&o.Value)) +} + +func freeProxyOptionStrings(opts []internetPerConnOption) { + for i := 1; i < len(opts); i++ { + if p := proxyOptionString(&opts[i]); p != nil { + procProxyGlobalFree.Call(uintptr(unsafe.Pointer(p))) + opts[i].Value = 0 + } } - first := v +} + +func (wininetProxyOperations) Read() (userProxySettings, error) { + // Query FLAGS_UI (10) with FLAGS (1) fallback; write with FLAGS (1). + for _, flagOption := range []uint32{10, 1} { + opts := []internetPerConnOption{{Option: flagOption}, {Option: 2}, {Option: 3}, {Option: 4}} + list := internetPerConnList{Count: uint32(len(opts)), Options: &opts[0]} + list.Size = uint32(unsafe.Sizeof(list)) + size := list.Size + r, _, err := procInternetQueryOption.Call(0, internetOptionPerConnection, uintptr(unsafe.Pointer(&list)), uintptr(unsafe.Pointer(&size))) + if r == 0 { + freeProxyOptionStrings(opts) + if flagOption == 10 { + continue + } + return userProxySettings{}, fmt.Errorf("query user WinINet proxy: %w", err) + } + st := userProxySettings{Flags: uint32(opts[0].Value), Server: windows.UTF16PtrToString(proxyOptionString(&opts[1])), Bypass: windows.UTF16PtrToString(proxyOptionString(&opts[2])), PAC: windows.UTF16PtrToString(proxyOptionString(&opts[3]))} + freeProxyOptionStrings(opts) + return st, nil + } + return userProxySettings{}, errors.New("user proxy query unavailable") +} + +func (wininetProxyOperations) Write(st userProxySettings) error { + opts := []internetPerConnOption{{Option: 1, Value: uint64(st.Flags)}, {Option: 2}, {Option: 3}, {Option: 4}} + keep := make([]*uint16, 0, 3) + for i, s := range []string{st.Server, st.Bypass, st.PAC} { + ptr, err := windows.UTF16PtrFromString(s) + if err != nil { + return err + } + keep = append(keep, ptr) + *(*unsafe.Pointer)(unsafe.Pointer(&opts[i+1].Value)) = unsafe.Pointer(ptr) + } + list := internetPerConnList{Count: uint32(len(opts)), Options: &opts[0]} + list.Size = uint32(unsafe.Sizeof(list)) + r, _, err := procInternetSetOption.Call(0, internetOptionPerConnection, uintptr(unsafe.Pointer(&list)), uintptr(list.Size)) + runtime.KeepAlive(keep) + if r == 0 { + return fmt.Errorf("set user WinINet proxy: %w", err) + } + return refreshWinINet() +} + +func firstProxyTarget(v string) string { + first := strings.TrimSpace(v) if i := strings.IndexByte(first, ';'); i >= 0 { first = first[:i] } @@ -104,10 +388,6 @@ func firstProxyTarget(v string) string { return strings.TrimSpace(first) } -// refreshWinINet broadcasts the settings-changed and refresh options so running -// processes reload the proxy configuration immediately. A zero return from -// InternetSetOption signals failure; the accompanying error is the last-call -// error only then. func refreshWinINet() error { if r, _, err := procInternetSetOption.Call(0, internetOptionSettingsChanged, 0, 0); r == 0 { return fmt.Errorf("InternetSetOption(SETTINGS_CHANGED): %w", err) diff --git a/core/control/proxy_windows_test.go b/core/control/proxy_windows_test.go index 6dd92957..16e9542b 100644 --- a/core/control/proxy_windows_test.go +++ b/core/control/proxy_windows_test.go @@ -2,7 +2,33 @@ package control -import "testing" +import ( + "testing" + "unsafe" +) + +func TestUserProxyWinINetABILayout(t *testing.T) { + var option internetPerConnOption + var list internetPerConnList + if unsafe.Sizeof(uintptr(0)) == 8 { + if unsafe.Sizeof(option) != 16 || unsafe.Offsetof(option.Value) != 8 || unsafe.Sizeof(list) != 32 || unsafe.Offsetof(list.Options) != 24 { + t.Fatal("WinINet 64-bit ABI mismatch") + } + } else if unsafe.Sizeof(option) != 12 || unsafe.Offsetof(option.Value) != 4 || unsafe.Sizeof(list) != 20 || unsafe.Offsetof(list.Options) != 16 { + t.Fatal("WinINet 32-bit ABI mismatch") + } +} + +func TestUserProxyHelperRejectsInvalidArgumentsBeforeNativeWork(t *testing.T) { + for _, args := range [][]string{{proxyHelperFlag}, {proxyHelperFlag, "restore", "extra"}, {proxyHelperFlag, "apply", "192.0.2.1:80"}, {proxyHelperFlag, "anything"}} { + if handled, err := RunUserProxyHelper(args); !handled || err == nil { + t.Fatalf("accepted malformed helper arguments: %v", args) + } + } + if handled, err := RunUserProxyHelper([]string{"--pipe"}); handled || err != nil { + t.Fatal("ordinary core flags treated as proxy helper") + } +} // TestFirstProxyTarget pins how a WinINet ProxyServer value is reduced to a bare // host:port for the startup reconcile's comparison: a plain value passes through, diff --git a/core/control/reapply_test.go b/core/control/reapply_test.go index bf28113e..7f78da18 100644 --- a/core/control/reapply_test.go +++ b/core/control/reapply_test.go @@ -66,7 +66,7 @@ func TestReapplyMovesToAFreeTunAddress(t *testing.T) { h.send(Request{ID: 2, Cmd: CmdSetKillSwitch, On: true}) h.await() - h.awaitState(StateConnected) + h.awaitRestartConnected(2) cfgs := h.runner.startCfgs() second := tunAddressOf(t, cfgs[len(cfgs)-1]) @@ -108,7 +108,7 @@ func TestSuccessfulReapplyStaysConnected(t *testing.T) { h.send(Request{ID: 2, Cmd: CmdSetKillSwitch, On: true}) h.await() - again := h.awaitState(StateConnected) + again := h.awaitRestartConnected(2) if again["node"] != connected["node"] { t.Errorf("re-apply moved the session to %v, want the same node %v", again["node"], connected["node"]) } diff --git a/core/control/refresh_test.go b/core/control/refresh_test.go index 78bd101d..16164e49 100644 --- a/core/control/refresh_test.go +++ b/core/control/refresh_test.go @@ -55,7 +55,7 @@ func daemonWithFetch(t *testing.T, f *fakeFetch) (*Daemon, *profile.Store) { if err != nil { t.Fatalf("open store: %v", err) } - d := NewDaemon(store, newFakeRunner()) + d := newUnitTestDaemon(store, newFakeRunner()) d.fetch = f.fetch return d, store } diff --git a/core/control/server_test.go b/core/control/server_test.go index e1178f68..7a9e29e2 100644 --- a/core/control/server_test.go +++ b/core/control/server_test.go @@ -14,6 +14,7 @@ import ( "github.com/Divaaaan/tenebra/core/fallback" "github.com/Divaaaan/tenebra/core/model" "github.com/Divaaaan/tenebra/core/profile" + "github.com/Divaaaan/tenebra/core/protection" ) // harness drives a Server over two pipes with a fake runner, demultiplexing the @@ -39,7 +40,8 @@ func newHarness(t *testing.T) *harness { t.Fatalf("open store: %v", err) } runner := newFakeRunner() - d := NewDaemon(store, runner) + d := newUnitTestDaemon(store, runner) + d.SetProtection(protection.New(&fakeHostProtection{})) // Shrink the fallback-loop timings so tests don't wait out real warmups/budgets. // The fake runner's Probe answers instantly, so a blocked candidate must burn // its whole (tiny) budget before the loop gives up on it — keep the budget @@ -877,7 +879,7 @@ func TestServeReturnsOnEOF(t *testing.T) { if err != nil { t.Fatal(err) } - d := NewDaemon(store, newFakeRunner()) + d := newUnitTestDaemon(store, newFakeRunner()) inR, inW := io.Pipe() var out discardWriter srv := NewServer(d, inR, &out) diff --git a/core/control/session_proxy.go b/core/control/session_proxy.go new file mode 100644 index 00000000..057335ad --- /dev/null +++ b/core/control/session_proxy.go @@ -0,0 +1,79 @@ +package control + +import "errors" + +// proxyUser identifies the owner of a per-user proxy lease. Session ID alone +// can be reused after logout; SID must also match before any cleanup is run. +type proxyUser struct { + SID string + Session uint32 +} + +type userProxySessionOps interface { + Current() (proxyUser, error) + Run(proxyUser, string, string) error + Read(proxyUser) (proxyState, error) + HasLease(proxyUser) (bool, error) +} + +type sessionSystemProxy struct { + ops userProxySessionOps + owner *proxyUser +} + +func (p *sessionSystemProxy) Enable(target string) error { + if p.owner == nil { + u, err := p.ops.Current() + if err != nil { + return err + } + p.owner = &u // retain the user even if apply fails after a partial write + } + return p.ops.Run(*p.owner, "apply", target) +} + +func (p *sessionSystemProxy) Disable() error { + if p.owner == nil { + return nil + } + if err := p.ops.Run(*p.owner, "restore", ""); err != nil { + // A logout destroys the old WTS session. Its HKCU lease still belongs + // to the same SID when that user logs on again with a new session ID. + current, currentErr := p.ops.Current() + if currentErr != nil || current.SID == "" || current.SID != p.owner.SID || current.Session == p.owner.Session { + return errors.Join(err, currentErr) + } + p.owner = ¤t + if retryErr := p.ops.Run(current, "restore", ""); retryErr != nil { + return errors.Join(err, retryErr) + } + } + p.owner = nil + return nil +} + +func (p *sessionSystemProxy) Get() (proxyState, error) { + u, err := p.ops.Current() + if err != nil { + return proxyState{}, err + } + return p.ops.Read(u) +} + +// Reconcile restores only a durable Tenebra lease, including a partially +// applied or already-disabled proxy. Merely sharing our port is not ownership. +func (p *sessionSystemProxy) Reconcile() (bool, error) { + if p.owner != nil { + return true, p.Disable() + } + u, err := p.ops.Current() + if err != nil { + return false, err + } + has, err := p.ops.HasLease(u) + if err != nil || !has { + return false, err + } + p.owner = &u + return true, p.Disable() +} diff --git a/core/control/session_proxy_rebind_test.go b/core/control/session_proxy_rebind_test.go new file mode 100644 index 00000000..e6971066 --- /dev/null +++ b/core/control/session_proxy_rebind_test.go @@ -0,0 +1,54 @@ +package control + +import ( + "errors" + "testing" +) + +type logonProxySessions struct { + current proxyUser + hasLease bool + restoreUsers []proxyUser +} + +func (m *logonProxySessions) Current() (proxyUser, error) { return m.current, nil } +func (m *logonProxySessions) Read(proxyUser) (proxyState, error) { return proxyState{}, nil } +func (m *logonProxySessions) HasLease(proxyUser) (bool, error) { return m.hasLease, nil } +func (m *logonProxySessions) Run(u proxyUser, action, _ string) error { + if u != m.current { + return errors.New("WTSQueryUserToken: prior session is gone") + } + if action == "restore" { + m.restoreUsers = append(m.restoreUsers, u) + } + m.hasLease = action == "apply" + return nil +} + +func TestUserProxyReconcileRebindsOnlySameSIDNewSession(t *testing.T) { + for _, sameUser := range []bool{true, false} { + t.Run(map[bool]string{true: "same SID", false: "different SID"}[sameUser], func(t *testing.T) { + original := proxyUser{SID: "S-1-5-21-1000", Session: 1} + m := &logonProxySessions{current: original} + p := &sessionSystemProxy{ops: m} + if err := p.Enable("127.0.0.1:2080"); err != nil { + t.Fatal(err) + } + m.current.Session = 2 + if !sameUser { + m.current.SID = "S-1-5-21-2000" + } + found, err := p.Reconcile() + if !found { + t.Fatal("retained cleanup was lost") + } + if sameUser { + if err != nil || p.owner != nil || m.hasLease || len(m.restoreUsers) != 1 || m.restoreUsers[0] != m.current { + t.Fatalf("same user could not recover at new logon: owner=%+v error=%v", p.owner, err) + } + } else if err == nil || p.owner == nil || *p.owner != original || !m.hasLease || len(m.restoreUsers) != 0 { + t.Fatal("cleanup was transferred to another SID") + } + }) + } +} diff --git a/core/control/session_proxy_test.go b/core/control/session_proxy_test.go new file mode 100644 index 00000000..61f8dea5 --- /dev/null +++ b/core/control/session_proxy_test.go @@ -0,0 +1,115 @@ +package control + +import ( + "errors" + "testing" +) + +type fakeProxySessions struct { + current proxyUser + fail bool + has bool + users []proxyUser + actions []string +} + +func (f *fakeProxySessions) Current() (proxyUser, error) { return f.current, nil } +func (f *fakeProxySessions) Run(u proxyUser, a, _ string) error { + f.users = append(f.users, u) + f.actions = append(f.actions, a) + if f.fail { + return errors.New("session temporarily unavailable") + } + return nil +} +func (f *fakeProxySessions) Read(proxyUser) (proxyState, error) { + return proxyState{Enabled: true, Server: "127.0.0.1:2080"}, nil +} +func (f *fakeProxySessions) HasLease(proxyUser) (bool, error) { return f.has, nil } + +func TestUserProxyCleanupStaysWithOriginalSession(t *testing.T) { + a := proxyUser{SID: "S-1-5-21-100", Session: 1} + b := proxyUser{SID: "S-1-5-21-200", Session: 2} + f := &fakeProxySessions{current: a} + p := &sessionSystemProxy{ops: f} + if err := p.Enable("127.0.0.1:2080"); err != nil { + t.Fatal(err) + } + f.current = b + f.fail = true + if err := p.Disable(); err == nil { + t.Fatal("cleanup failure lost") + } + f.fail = false + if err := p.Disable(); err != nil { + t.Fatal(err) + } + for _, u := range f.users { + if u != a { + t.Fatalf("cleanup retargeted to another user: %+v", u) + } + } + if err := p.Enable("127.0.0.1:2081"); err != nil { + t.Fatal(err) + } + if f.users[len(f.users)-1] != b { + t.Fatal("new apply did not select the new user") + } +} + +func TestUserProxyFailedApplyKeepsOriginalOwner(t *testing.T) { + a := proxyUser{SID: "first", Session: 1} + f := &fakeProxySessions{current: a, fail: true} + p := &sessionSystemProxy{ops: f} + if p.Enable("127.0.0.1:2080") == nil { + t.Fatal("apply should fail") + } + f.current = proxyUser{SID: "second", Session: 1} + f.fail = false + if err := p.Disable(); err != nil { + t.Fatal(err) + } + if f.users[1] != a { + t.Fatal("reused session id replaced cleanup owner") + } +} + +func TestUserProxyReconcileRequiresLeaseAndRetriesFailure(t *testing.T) { + f := &fakeProxySessions{current: proxyUser{SID: "user", Session: 1}} + p := &sessionSystemProxy{ops: f} + if changed, err := p.Reconcile(); changed || err != nil || len(f.users) != 0 { + t.Fatal("matching port without ownership was changed") + } + f.has = true + f.fail = true + if changed, err := p.Reconcile(); !changed || err == nil { + t.Fatal("failed stale lease cleanup not surfaced") + } + f.current = proxyUser{SID: "other", Session: 2} + f.fail = false + if changed, err := p.Reconcile(); !changed || err != nil { + t.Fatal("cleanup did not retry") + } + if f.users[0] != f.users[1] { + t.Fatal("reconcile retry changed target user") + } +} + +func TestUserProxyStartupFailureRetainsDaemonCleanup(t *testing.T) { + d, _ := bareDaemonWithProxy(t) + f := &fakeProxySessions{current: proxyUser{SID: "user", Session: 1}, has: true, fail: true} + d.proxy = &sessionSystemProxy{ops: f} + if cleared, err := d.ReconcileSystemProxyAtStartup(); cleared || err == nil { + t.Fatal("failed startup restore claimed success") + } + if !d.proxyArmed { + t.Fatal("daemon forgot startup cleanup obligation") + } + f.fail = false + if err := d.disarmSystemProxy(); err != nil { + t.Fatal(err) + } + if d.proxyArmed { + t.Fatal("cleanup obligation survived confirmed restore") + } +} diff --git a/core/control/tunconflict_test.go b/core/control/tunconflict_test.go index dec4ff44..959beac6 100644 --- a/core/control/tunconflict_test.go +++ b/core/control/tunconflict_test.go @@ -5,6 +5,7 @@ import ( "errors" "strings" "testing" + "time" "github.com/Divaaaan/tenebra/core/model" "github.com/Divaaaan/tenebra/core/profile" @@ -34,7 +35,20 @@ func daemonForConflictTest(t *testing.T) (*Daemon, string) { if err := store.Add(p); err != nil { t.Fatalf("add profile: %v", err) } - return NewDaemon(store, newFakeRunner()), p.ID + d := newUnitTestDaemon(store, newFakeRunner()) + // These tests start asynchronous connects. Drain them before the fixture's + // store disappears, without Close(), which also stops the host DPI bypass. + t.Cleanup(func() { + d.connMu.Lock() + err := d.teardown(StateIdle, "", "") + d.connMu.Unlock() + d.relaunchWG.Wait() + d.entCancel() + if err != nil { + t.Errorf("conflict fixture teardown: %v", err) + } + }) + return d, p.ID } // foreignTunnel is another VPN holding the default route at a metric that beats @@ -78,6 +92,10 @@ func TestConnectProceedsWithExplicitOverride(t *testing.T) { // with anything; blocking it would be pure obstruction. func TestConnectNotGuardedInSystemProxyMode(t *testing.T) { d, pid := daemonForConflictTest(t) + proxy, ok := d.proxy.(*fakeProxyController) + if !ok { + t.Fatalf("conflict fixture must not use host proxy controller %T", d.proxy) + } d.SetInterfaceProbe(func() ([]tunguard.Iface, error) { return foreignTunnel(), nil }) d.mu.Lock() d.tun.Mode = singbox.ModeSystemProxy @@ -87,6 +105,18 @@ func TestConnectNotGuardedInSystemProxyMode(t *testing.T) { if !resp.Ok { t.Fatalf("system-proxy connect was blocked by the tun guard: %q", resp.Error) } + // An accepted asynchronous command is not proof that the system-proxy path + // completed. Reach recordSuccess and require the injected adapter to own it. + deadline := time.Now().Add(3 * time.Second) + for d.snapshotState().State != StateConnected { + if time.Now().After(deadline) { + t.Fatalf("system-proxy connect did not complete: %+v", d.snapshotState()) + } + time.Sleep(time.Millisecond) + } + if proxy.enables() != 1 || proxy.lastHostPort() != "127.0.0.1:2080" { + t.Fatalf("proxy enables=%d target=%q, want one fake apply at 127.0.0.1:2080", proxy.enables(), proxy.lastHostPort()) + } } // A probe that cannot read the route table knows nothing. Turning "unknown" into diff --git a/core/control/tunwatch.go b/core/control/tunwatch.go index f40b3c4f..8806fc1a 100644 --- a/core/control/tunwatch.go +++ b/core/control/tunwatch.go @@ -51,16 +51,23 @@ func (d *Daemon) watchTunInterface(ctx context.Context, gen uint64) { if name == "" { return } + present := func() bool { + if checked, exists := d.protection.TunnelPresent(); checked { + return exists + } + return d.ifacePresent(name) + } - // Wait for it to come up first: reporting "gone" for an interface that has not - // appeared yet would turn a slow start into a failure. - appeared := false + // A protected interface was already observed by VerifyTunnel before Active. + // If it vanished before this watcher starts, do not wait and silently give up. + appeared, _ := d.protection.TunnelPresent() + // Unprotected platforms still wait for their first name-based observation. deadline := time.Now().Add(tunAppearBudget) - for time.Now().Before(deadline) { + for !appeared && time.Now().Before(deadline) { if !d.isCurrent(gen) || ctx.Err() != nil { return } - if d.ifacePresent(name) { + if present() { appeared = true break } @@ -83,7 +90,7 @@ func (d *Daemon) watchTunInterface(ctx context.Context, gen uint64) { if !d.isCurrent(gen) { return // superseded; a newer connection owns the state } - if d.ifacePresent(name) { + if present() { continue } // Give it one grace beat: an adapter can flicker while the stack @@ -98,7 +105,7 @@ func (d *Daemon) watchTunInterface(ctx context.Context, gen uint64) { return case <-time.After(grace): } - if !d.isCurrent(gen) || d.ifacePresent(name) { + if !d.isCurrent(gen) || present() { continue } @@ -106,6 +113,9 @@ func (d *Daemon) watchTunInterface(ctx context.Context, gen uint64) { // Whatever sing-box said before losing its adapter is the only explanation // available, and it is exactly what was missing while this was diagnosed. d.emitSingboxTail() + // Confirmed loss invalidates active protection even when Stop fails or + // the process never sends a Done event. The persistent block stays owned. + d.protection.Interrupted() // Stop the orphan rather than inventing a state here. A process with no // interface carries nothing, and stopping it lands on watchProcess — the // one path that already disarms the system proxy, spends the kill-switch @@ -113,6 +123,8 @@ func (d *Daemon) watchTunInterface(ctx context.Context, gen uint64) { // how the state ends up disagreeing with reality, which is this bug. if err := d.runner.Stop(); err != nil { d.emitLog(LogError, "could not stop the tunnel process: "+err.Error()) + cur := d.snapshotState() + d.setState(State{State: StateError, Profile: cur.Profile, Node: cur.Node, Error: "tunnel interface disappeared; could not stop engine: " + err.Error()}) } return } diff --git a/core/control/tunwatch_test.go b/core/control/tunwatch_test.go index 228a68ec..7d4ca7a1 100644 --- a/core/control/tunwatch_test.go +++ b/core/control/tunwatch_test.go @@ -127,7 +127,9 @@ func TestTunWatchLeavesAHealthyTunnelAlone(t *testing.T) { // absent one is normal and must not be read as a dead tunnel. func TestTunWatchInertInSystemProxyMode(t *testing.T) { h := newHarness(t) - h.daemon.ifacePresent = func(string) bool { return false } + f := h.useFakeProxy() + var looks atomic.Int32 + h.daemon.ifacePresent = func(string) bool { looks.Add(1); return false } h.daemon.tunWatchInterval = 20 * time.Millisecond h.daemon.mu.Lock() h.daemon.tun.Mode = singbox.ModeSystemProxy @@ -137,11 +139,18 @@ func TestTunWatchInertInSystemProxyMode(t *testing.T) { h.send(Request{ID: 1, Cmd: CmdConnect, Profile: p.ID}) h.await() h.awaitState(StateConnected) + beforeStops := h.runner.stops() + if f.enables() != 1 || f.lastHostPort() != "127.0.0.1:2080" { + t.Fatalf("system proxy was not applied before connected: enables=%d target=%q", f.enables(), f.lastHostPort()) + } time.Sleep(300 * time.Millisecond) if got := h.daemon.snapshotState().State; got != StateConnected { t.Errorf("state = %q in system-proxy mode, want connected", got) } + if looks.Load() != 0 || h.runner.stops() != beforeStops || f.disables() != 0 { + t.Errorf("proxy-mode watch was not inert: lookups=%d stops=%d (before=%d) proxy restores=%d", looks.Load(), h.runner.stops(), beforeStops, f.disables()) + } } // TestTunWatchDisabledByZeroInterval keeps the escape hatch honest: platforms diff --git a/core/control/user_proxy_lease.go b/core/control/user_proxy_lease.go new file mode 100644 index 00000000..1d739088 --- /dev/null +++ b/core/control/user_proxy_lease.go @@ -0,0 +1,153 @@ +package control + +import ( + "errors" + "fmt" + "net" + "strconv" +) + +// userProxySettings captures the per-connection WinINet settings, including PAC +// and autodetection flags. It belongs to the interactive user, never LocalSystem. +type userProxySettings struct { + Flags uint32 `json:"flags"` + Server string `json:"server"` + Bypass string `json:"bypass"` + PAC string `json:"pac"` +} + +type userProxyLease struct { + Version int `json:"version"` + Before userProxySettings `json:"before"` + Applied userProxySettings `json:"applied"` + Confirmed bool `json:"confirmed,omitempty"` +} + +type userProxyOperations interface { + Read() (userProxySettings, error) + Write(userProxySettings) error + Load() (*userProxyLease, error) + Save(userProxyLease) error + Delete() error +} + +func validUserProxyTarget(target string) bool { + host, port, err := net.SplitHostPort(target) + if err != nil { + return false + } + ip := net.ParseIP(host) + p, err := strconv.Atoi(port) + return ip != nil && ip.IsLoopback() && err == nil && p > 0 && p <= 65535 +} + +func applyUserProxy(o userProxyOperations, target string) error { + if !validUserProxyTarget(target) { + return errors.New("system proxy target must be a loopback IP and valid port") + } + lease, err := o.Load() + if err != nil { + return fmt.Errorf("load system proxy snapshot: %w", err) + } + if lease != nil { + current, err := o.Read() + if err != nil { + return err + } + if lease.Version == 1 && lease.Applied.Server == target && current == lease.Applied { + if !lease.Confirmed { + confirmed := *lease + confirmed.Confirmed = true + if err := o.Save(confirmed); err != nil { + return fmt.Errorf("confirm existing user proxy snapshot: %w", err) + } + } + return nil + } + if err := restoreUserProxy(o); err != nil { + return err + } + } + before, err := o.Read() + if err != nil { + return fmt.Errorf("read user proxy: %w", err) + } + want := userProxySettings{Flags: 3, Server: target, Bypass: "localhost;127.0.0.1;[::1]"} + newLease := userProxyLease{Version: 1, Before: before, Applied: want} + if err := o.Save(newLease); err != nil { + return fmt.Errorf("save user proxy rollback snapshot: %w", err) + } + if err := o.Write(want); err != nil { + return errors.Join(fmt.Errorf("apply user proxy: %w", err), restoreUserProxy(o)) + } + got, err := o.Read() + if err != nil || got != want { + if err == nil { + err = errors.New("user proxy settings did not take effect") + } + return errors.Join(err, restoreUserProxy(o)) + } + // Persist successful readback before reporting success. Recovery can then + // distinguish a later switch back to Before.Server from a partial apply. + newLease.Confirmed = true + if err := o.Save(newLease); err != nil { + return errors.Join(fmt.Errorf("confirm user proxy snapshot: %w", err), restoreUserProxy(o)) + } + return nil +} + +func restoreUserProxy(o userProxyOperations) error { + lease, err := o.Load() + if err != nil { + return fmt.Errorf("load user proxy rollback snapshot: %w", err) + } + if lease == nil { + return nil + } + if lease.Version != 1 || !validUserProxyTarget(lease.Applied.Server) { + return errors.New("invalid user proxy ownership record; automatic restore refused") + } + current, err := o.Read() + if err != nil { + return err + } + // A different server is an explicit subsequent user/tool change. Do not + // restore old flags/PAC over that newer configuration. + if current.Server != lease.Applied.Server { + if lease.Confirmed || current == lease.Before || current.Server != lease.Before.Server { + return o.Delete() + } + // With an unconfirmed/crashed apply, a mix of old and new settings + // at the old server could be a partial option write or a later user + // edit. There is no evidence to safely undo it automatically. + return errors.New("ambiguous user proxy snapshot; previous server has changed settings, automatic restore refused") + } + want := current + // Restore only fields still equal to our write. This also rolls back a + // partially completed option list without clobbering independent edits. + if current.Flags == lease.Applied.Flags { + want.Flags = lease.Before.Flags + } + if current.Server == lease.Applied.Server { + want.Server = lease.Before.Server + } + if current.Bypass == lease.Applied.Bypass { + want.Bypass = lease.Before.Bypass + } + if current.PAC == lease.Applied.PAC { + want.PAC = lease.Before.PAC + } + if want != current { + if err := o.Write(want); err != nil { + return fmt.Errorf("restore user proxy: %w", err) + } + got, err := o.Read() + if err != nil { + return err + } + if got != want { + return errors.New("user proxy restore did not take effect") + } + } + return o.Delete() +} diff --git a/core/control/user_proxy_lease_test.go b/core/control/user_proxy_lease_test.go new file mode 100644 index 00000000..e261189f --- /dev/null +++ b/core/control/user_proxy_lease_test.go @@ -0,0 +1,180 @@ +package control + +import ( + "errors" + "testing" +) + +type memoryUserProxy struct { + settings userProxySettings + lease *userProxyLease + writes int + failWrite int + failSave bool + failDelete bool + saves int + failSaveAt int +} + +func (m *memoryUserProxy) Read() (userProxySettings, error) { return m.settings, nil } +func (m *memoryUserProxy) Write(s userProxySettings) error { + m.writes++ + if m.writes == m.failWrite { + m.settings.Server = s.Server + return errors.New("partial write") + } + m.settings = s + return nil +} +func (m *memoryUserProxy) Load() (*userProxyLease, error) { return m.lease, nil } +func (m *memoryUserProxy) Save(l userProxyLease) error { + m.saves++ + if m.failSave || m.saves == m.failSaveAt { + return errors.New("snapshot unavailable") + } + m.lease = &l + return nil +} + +func TestUserProxyPreservesExternalReturnToOriginalServer(t *testing.T) { + before := userProxySettings{Flags: 9, Server: "corp.example:8080", PAC: "https://config.example/old.pac"} + m := &memoryUserProxy{settings: before} + if err := applyUserProxy(m, "127.0.0.1:2080"); err != nil { + t.Fatal(err) + } + changed := userProxySettings{Flags: 3, Server: before.Server, Bypass: "intranet"} + m.settings = changed + if err := restoreUserProxy(m); err != nil { + t.Fatal(err) + } + if m.settings != changed || m.lease != nil { + t.Fatalf("cleanup overwrote later corporate configuration: got %+v, want %+v", m.settings, changed) + } +} + +func TestUserProxyConfirmationFailureRollsBack(t *testing.T) { + before := userProxySettings{Flags: 9, PAC: "https://config.example/proxy.pac"} + m := &memoryUserProxy{settings: before, failSaveAt: 2} + if err := applyUserProxy(m, "127.0.0.1:2080"); err == nil { + t.Fatal("accepted an apply without durable confirmation") + } + if m.settings != before || m.lease != nil { + t.Fatal("failed confirmation did not restore the original configuration") + } +} + +func TestUserProxyUnconfirmedAmbiguousOriginalServerIsPreserved(t *testing.T) { + before := userProxySettings{Flags: 9, Server: "corp.example:8080", PAC: "https://config.example/old.pac"} + applied := userProxySettings{Flags: 3, Server: "127.0.0.1:2080", Bypass: "localhost;127.0.0.1;[::1]"} + changed := applied + changed.Server = before.Server + m := &memoryUserProxy{settings: changed, lease: &userProxyLease{Version: 1, Before: before, Applied: applied}} + if err := restoreUserProxy(m); err == nil { + t.Fatal("ambiguous partial write/external switch should retain cleanup for repair") + } + if m.settings != changed || m.lease == nil || m.writes != 0 { + t.Fatal("ambiguous state was changed or ownership discarded") + } +} +func (m *memoryUserProxy) Delete() error { + if m.failDelete { + return errors.New("delete failed") + } + m.lease = nil + return nil +} + +func TestUserProxyRestoresCorporateSettingsAndPAC(t *testing.T) { + before := userProxySettings{Flags: 15, Server: "corp.example:8080", Bypass: "intranet;*.internal", PAC: "https://config.example/proxy.pac"} + m := &memoryUserProxy{settings: before} + if err := applyUserProxy(m, "127.0.0.1:2080"); err != nil { + t.Fatal(err) + } + if m.settings.Flags != 3 || m.settings.Server != "127.0.0.1:2080" || m.settings.PAC != "" { + t.Fatalf("proxy not applied: %+v", m.settings) + } + if m.lease == nil || m.lease.Before != before { + t.Fatal("original settings not retained") + } + if err := restoreUserProxy(m); err != nil { + t.Fatal(err) + } + if m.settings != before || m.lease != nil { + t.Fatal("original proxy/PAC not fully restored") + } +} + +func TestUserProxySnapshotFailureNeverMutatesSettings(t *testing.T) { + m := &memoryUserProxy{failSave: true, settings: userProxySettings{Flags: 9}} + if err := applyUserProxy(m, "127.0.0.1:2080"); err == nil { + t.Fatal("snapshot failure accepted") + } + if m.writes != 0 { + t.Fatal("changed settings before durable rollback snapshot") + } +} + +func TestUserProxyPartialApplyAndCleanupRetry(t *testing.T) { + before := userProxySettings{Flags: 9, Server: "old:80", PAC: "https://config.example/pac"} + m := &memoryUserProxy{settings: before, failWrite: 1, failDelete: true} + if err := applyUserProxy(m, "127.0.0.1:2080"); err == nil { + t.Fatal("partial apply accepted") + } + if m.settings != before || m.lease == nil { + t.Fatal("partial change not restored or retry ownership lost") + } + m.failDelete = false + if err := restoreUserProxy(m); err != nil { + t.Fatal(err) + } + if m.lease != nil || m.settings != before { + t.Fatal("retry did not finish restore") + } +} + +func TestUserProxyRepeatedApplyDoesNotOverwriteOriginalSnapshot(t *testing.T) { + before := userProxySettings{Flags: 1} + m := &memoryUserProxy{settings: before} + if err := applyUserProxy(m, "127.0.0.1:2080"); err != nil { + t.Fatal(err) + } + if err := applyUserProxy(m, "127.0.0.1:2080"); err != nil { + t.Fatal(err) + } + if m.lease.Before != before || m.writes != 1 { + t.Fatal("idempotent apply lost original state") + } + if err := applyUserProxy(m, "127.0.0.1:2081"); err != nil { + t.Fatal(err) + } + if m.lease.Before != before || m.settings.Server != "127.0.0.1:2081" { + t.Fatal("port change lost original state") + } +} + +func TestUserProxyRestorePreservesLaterUserChange(t *testing.T) { + m := &memoryUserProxy{settings: userProxySettings{Flags: 1}} + if err := applyUserProxy(m, "127.0.0.1:2080"); err != nil { + t.Fatal(err) + } + changed := userProxySettings{Flags: 7, Server: "new-corporate:8888", Bypass: "work", PAC: "https://work/pac"} + m.settings = changed + if err := restoreUserProxy(m); err != nil { + t.Fatal(err) + } + if m.settings != changed || m.lease != nil { + t.Fatal("cleanup overwrote newer external settings") + } +} + +func TestUserProxyRejectsNonLoopbackAndInvalidTargets(t *testing.T) { + for _, target := range []string{"192.0.2.1:2080", "127.0.0.1:0", "127.0.0.1:65536", "localhost:2080", "127.0.0.1:2080;https=evil:80"} { + m := &memoryUserProxy{} + if err := applyUserProxy(m, target); err == nil { + t.Errorf("accepted %q", target) + } + if m.writes != 0 || m.lease != nil { + t.Fatal("invalid target changed settings") + } + } +} diff --git a/core/control/zapret.go b/core/control/zapret.go index 534e691b..f097081a 100644 --- a/core/control/zapret.go +++ b/core/control/zapret.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "errors" "fmt" + "net" "os" "path/filepath" "strings" @@ -12,6 +13,7 @@ import ( "time" "github.com/Divaaaan/tenebra/core/dnswire" + "github.com/Divaaaan/tenebra/core/protection" "github.com/Divaaaan/tenebra/core/zapret" ) @@ -278,6 +280,11 @@ func (d *Daemon) excludeNodesFromZapret(dir string) { // function was written to replace, and silence about it leaves a user whose // nodes are still being desynced with nothing to go on. func (d *Daemon) nodeLookups() []zapret.Lookup { + if endpoint, required := d.ProtectionDNS(); required { + if err := protection.ValidateDNS(endpoint); err != nil { + return []zapret.Lookup{func(context.Context, string) ([]net.IP, error) { return nil, err }} + } + } d.mu.Lock() direct := strings.TrimSpace(d.routing.DNSDirect) d.mu.Unlock() @@ -1033,9 +1040,8 @@ func (d *Daemon) autoStartZapret(ctx context.Context, tunnelUp bool) bool { // Asked here rather than in each caller: both automatic raises funnel through // this function, so a switch obeyed at this point cannot be forgotten by the // next path that wants a bypass up. Read before the bypass lock, because this - // is a decision NOT to run and nothing the lock protects can change it — - // queueing it behind a probe run of several minutes would only delay the - // answer the connect is waiting on. + // is a fast refusal; re-check after taking the lock because OFF may win while + // this automatic raise waits behind another operation. if d.zapretSwitchedOff() { // Debug, for the same reason the missing-bundle branch below is: the caller // states at info where the censored services ended up, and this only adds @@ -1055,6 +1061,10 @@ func (d *Daemon) autoStartZapret(ctx context.Context, tunnelUp bool) bool { return false } defer d.zapretOpMu.Unlock() + if d.zapretSwitchedOff() { + d.emitDebug("zapret: the switch was turned off while waiting — leaving the bypass down") + return false + } dir := filepath.Join(d.store.Dir(), zapretDirName) entries, err := os.ReadDir(dir) diff --git a/core/control/zapret_dial_test.go b/core/control/zapret_dial_test.go index 3c1a2f90..908f34f2 100644 --- a/core/control/zapret_dial_test.go +++ b/core/control/zapret_dial_test.go @@ -223,7 +223,7 @@ func TestNewDaemonPinsTheBypassPick(t *testing.T) { if err != nil { t.Fatalf("open store: %v", err) } - d := NewDaemon(store, newFakeRunner()) + d := newUnitTestDaemon(store, newFakeRunner()) if d.probeIfaces == nil { t.Fatal("daemon cannot enumerate adapters for the bypass pick; every probe would follow the tun") } diff --git a/core/control/zapret_persist_test.go b/core/control/zapret_persist_test.go index 65e245f1..ac7286ba 100644 --- a/core/control/zapret_persist_test.go +++ b/core/control/zapret_persist_test.go @@ -77,7 +77,7 @@ func launchBypassDaemon(t *testing.T, storeDir, settingsDir string) (*Daemon, *s if err != nil { t.Fatalf("open store: %v", err) } - d := NewDaemon(store, newFakeRunner()) + d := newUnitTestDaemon(store, newFakeRunner()) d.SetSettings(settingsAt(t, settingsDir)) nets, err := OpenFileNetStrategies(storeDir) if err != nil { diff --git a/core/protection/dns.go b/core/protection/dns.go new file mode 100644 index 00000000..62535fb8 --- /dev/null +++ b/core/protection/dns.go @@ -0,0 +1,24 @@ +package protection + +import ( + "fmt" + "net/netip" + "net/url" +) + +// ValidateDNS refuses an implicit plaintext/system bootstrap. The caller keeps +// the user's saved value; changing this requirement must be an explicit choice. +func ValidateDNS(endpoint string) error { + u, err := url.Parse(endpoint) + if err != nil || u.User != nil || u.Fragment != "" || (u.Scheme != "https" && u.Scheme != "tls") { + return fmt.Errorf("host protection requires an encrypted https:// or tls:// bootstrap resolver with a literal IP") + } + ip, err := netip.ParseAddr(u.Hostname()) + if err != nil || ip.Zone() != "" || ip.IsUnspecified() || ip.IsMulticast() { + return fmt.Errorf("host protection requires a literal-IP encrypted bootstrap resolver; hostname/system DNS is unavailable while blocked") + } + if u.Scheme == "tls" && (u.Path != "" && u.Path != "/" || u.RawQuery != "") { + return fmt.Errorf("TLS DNS bootstrap does not accept a path or query") + } + return nil +} diff --git a/core/protection/guard.go b/core/protection/guard.go new file mode 100644 index 00000000..707d01d6 --- /dev/null +++ b/core/protection/guard.go @@ -0,0 +1,223 @@ +package protection + +import ( + "errors" + "sync" +) + +// State reports confirmed policy independently of the desired setting. +type State struct { + Status string `json:"status"` + Enforced bool `json:"enforced"` + Persistent bool `json:"persistent"` + Error string `json:"error,omitempty"` +} + +// Backend changes only owned policy. Replace and Remove must be atomic; a +// failed operation leaves the previous policy unchanged. Inspect verifies the +// complete four-layer default block, not just the existence of a provider. +type Backend interface { + Inspect() (present, complete bool, err error) + Replace(tunLUID uint64) error + ResolveTunnel(name, address string) (uint64, error) + Remove() error +} + +type Guard struct { + op sync.Mutex + mu sync.Mutex + backend Backend + state State + notify func() + tunnel *verifiedTunnel + legacyEngineOnly bool // immutable, explicitly selected by non-Windows composition +} + +type verifiedTunnel struct { + name, address string + luid uint64 +} + +func New(b Backend) *Guard { + g := &Guard{backend: b, state: State{Status: "off"}} + if b == nil { + g.state.Status = "unavailable" + } + return g +} + +// NewLegacyEngineOnly preserves preexisting non-Windows engine routing without +// claiming independent host protection. A missing Backend never selects this. +func NewLegacyEngineOnly() *Guard { + g := New(nil) + g.legacyEngineOnly = true + g.state.Error = "Persistent host protection is unavailable on this platform; legacy engine routing only, while the engine is running." + return g +} + +func (g *Guard) LegacyEngineOnly() bool { return g.legacyEngineOnly } + +// SetNotify is configured once before commands begin. The callback runs without +// Guard.mu and may safely read Snapshot; it must not perform another operation. +func (g *Guard) SetNotify(f func()) { g.notify = f } +func (g *Guard) Snapshot() State { g.mu.Lock(); defer g.mu.Unlock(); return g.state } +func (g *Guard) set(s State) { + g.mu.Lock() + g.state = s + g.mu.Unlock() + if g.notify != nil { + g.notify() + } +} +func (g *Guard) confirm(s State, tunnel *verifiedTunnel) { + g.mu.Lock() + g.state, g.tunnel = s, tunnel + g.mu.Unlock() + if g.notify != nil { + g.notify() + } +} +func (g *Guard) pending() { s := g.Snapshot(); s.Status = "applying"; s.Error = ""; g.set(s) } +func (g *Guard) fail(err error) error { + s := g.Snapshot() + s.Status = "error" + s.Error = err.Error() + g.set(s) + return err +} + +// Reject reports a configuration refusal before native policy is touched. +// It preserves the last confirmed enforcement just like an apply failure. +func (g *Guard) Reject(err error) error { return g.fail(err) } +func (g *Guard) available() error { + if g.backend == nil { + return errors.New("persistent host protection is unavailable on this platform") + } + return nil +} + +// Recover never clears an existing guard on the strength of a preferences file. +// A crash may have happened between saving OFF and removing the policy. +func (g *Guard) Recover() error { + g.op.Lock() + defer g.op.Unlock() + if err := g.available(); err != nil { + return err + } + present, complete, err := g.backend.Inspect() + if err != nil { + return g.fail(err) + } + if !present { + g.confirm(State{Status: "off"}, nil) + return nil + } + g.set(State{Status: "blocked", Enforced: complete, Persistent: complete}) + return g.prepare() +} + +// Prepare closes TUN allowances before a process replacement, retaining the +// trusted engine/core bootstrap path. It is deliberately separate from Stop. +func (g *Guard) Prepare() error { + g.op.Lock() + defer g.op.Unlock() + return g.prepare() +} +func (g *Guard) prepare() error { + if err := g.available(); err != nil { + return err + } + g.pending() + if err := g.backend.Replace(0); err != nil { + return g.fail(err) + } + g.confirm(State{Status: "blocked", Enforced: true, Persistent: true}, nil) + return nil +} + +// VerifyTunnel is called only after the engine's successful probe. Resolving a TUN +// failure leaves lockdown in place; there is no fallback to a name or address. +func (g *Guard) VerifyTunnel(name, address string, systemProxy bool) error { + g.op.Lock() + defer g.op.Unlock() + if err := g.available(); err != nil { + return err + } + var luid uint64 + if !systemProxy { + var err error + luid, err = g.backend.ResolveTunnel(name, address) + if err != nil { + return g.fail(err) + } + if luid == 0 { + return g.fail(errors.New("TUN has no verified interface identity")) + } + } + g.pending() + if err := g.backend.Replace(luid); err != nil { + return g.fail(err) + } + g.confirm(State{Status: "blocked", Enforced: true, Persistent: true}, &verifiedTunnel{name, address, luid}) + return nil +} + +// TunnelPresent checks the same LUID, name and address that were permitted. +// A newly-created same-name interface cannot stand in for the verified one. +// checked=false leaves non-TUN/unprotected platforms to their existing watcher. +func (g *Guard) TunnelPresent() (checked, present bool) { + g.mu.Lock() + tunnel := g.tunnel + g.mu.Unlock() + if tunnel == nil || tunnel.luid == 0 { + return false, false + } + luid, err := g.backend.ResolveTunnel(tunnel.name, tunnel.address) + g.mu.Lock() + unchanged := tunnel == g.tunnel + g.mu.Unlock() + if !unchanged { + return true, true + } // the next tick checks the new policy + return true, err == nil && luid == tunnel.luid +} + +// Accepted marks the already-verified engine only after ALL local gates (including +// system proxy) pass. The caller publishes its connected state immediately after +// this; no intermediate active event is emitted over an unaccepted connection. +func (g *Guard) Accepted() { + g.mu.Lock() + defer g.mu.Unlock() + if g.state.Status == "blocked" && g.state.Enforced && g.tunnel != nil { + g.state.Status = "active" + } +} + +// Interrupted is metadata only. Persistent kernel policy requires no userspace +// cleanup to survive a crash, a stopped service or the relaunch limit. +func (g *Guard) Interrupted() { + g.mu.Lock() + changed := g.state.Status == "active" + if changed { + g.state.Status = "blocked" + } + g.mu.Unlock() + if changed && g.notify != nil { + g.notify() + } +} + +// Release is reserved for explicit OFF/Disconnect/uninstall, never Close. +func (g *Guard) Release() error { + g.op.Lock() + defer g.op.Unlock() + if g.backend == nil { + return nil + } + g.pending() + if err := g.backend.Remove(); err != nil { + return g.fail(err) + } + g.confirm(State{Status: "off"}, nil) + return nil +} diff --git a/core/protection/policy.go b/core/protection/policy.go new file mode 100644 index 00000000..75bdd224 --- /dev/null +++ b/core/protection/policy.go @@ -0,0 +1,89 @@ +// Package protection describes the persistent host guard without performing any +// OS operations. Only the production composition root installs a native Backend. +package protection + +import "fmt" + +type Layer uint8 + +const ( + Connect4 Layer = iota + Connect6 + Accept4 + Accept6 +) + +var Layers = [...]Layer{Connect4, Connect6, Accept4, Accept6} + +func (l Layer) Outbound() bool { return l == Connect4 || l == Connect6 } +func (l Layer) IPv6() bool { return l == Connect6 || l == Accept6 } + +type Field uint8 + +const ( + Loopback Field = iota + Interface + Application + Protocol + LocalPort + RemotePort + RemoteAddress +) +const ( + Core = "core" + Engine = "engine" + DHCP = "dhcp" +) + +type Condition struct { + Field Field + Number uint64 + Text string +} +type Rule struct { + Key string + Layer Layer + Weight uint64 + Permit bool + Conditions []Condition +} + +// Policy returns filters in decreasing priority. OR is expressed as separate +// filters; all conditions within a filter are AND. No caller application, LAN +// range, resolver, or DIRECT split exception is an input to this policy. +func Policy(tunLUID uint64) []Rule { + var rules []Rule + for _, l := range Layers { + add := func(key string, weight uint64, permit bool, c ...Condition) { + rules = append(rules, Rule{fmt.Sprintf("%d/%s", l, key), l, weight, permit, c}) + } + add("loopback", 100, true, Condition{Field: Loopback}) + if tunLUID != 0 { + add("tun", 90, true, Condition{Field: Interface, Number: tunLUID}) + } + portField := RemotePort + if !l.Outbound() { + portField = LocalPort + } + for _, proto := range []uint64{6, 17} { + add(fmt.Sprintf("dns-%d", proto), 80, false, Condition{Field: Protocol, Number: proto}, Condition{Field: portField, Number: 53}) + } + for _, app := range []string{Core, Engine} { + add(app, 70, true, Condition{Field: Application, Text: app}) + } + local, remote := uint64(68), uint64(67) + if l.IPv6() { + local, remote = 546, 547 + } + add("dhcp", 60, true, Condition{Field: Application, Text: DHCP}, Condition{Field: Protocol, Number: 17}, Condition{Field: LocalPort, Number: local}, Condition{Field: RemotePort, Number: remote}) + if l.IPv6() { + for typ := uint64(133); typ <= 136; typ++ { + for i, prefix := range []string{"fe80::/10", "ff02::/16"} { + add(fmt.Sprintf("ndp-%d-%d", typ, i), 50, true, Condition{Field: Protocol, Number: 58}, Condition{Field: LocalPort, Number: typ}, Condition{Field: RemotePort, Number: 0}, Condition{Field: RemoteAddress, Text: prefix}) + } + } + } + add("block", 1, false) + } + return rules +} diff --git a/core/protection/policy_test.go b/core/protection/policy_test.go new file mode 100644 index 00000000..f7a61984 --- /dev/null +++ b/core/protection/policy_test.go @@ -0,0 +1,217 @@ +package protection + +import ( + "errors" + "net/netip" + "testing" +) + +type packet struct { + layer Layer + app string + loop bool + luid uint64 + proto, local, remote uint64 + addr string +} + +func allowed(rules []Rule, p packet) bool { + for _, r := range rules { + if r.Layer != p.layer { + continue + } + match := true + for _, c := range r.Conditions { + switch c.Field { + case Loopback: + match = match && p.loop + case Interface: + match = match && p.luid == c.Number + case Application: + match = match && p.app == c.Text + case Protocol: + match = match && p.proto == c.Number + case LocalPort: + match = match && p.local == c.Number + case RemotePort: + match = match && p.remote == c.Number + case RemoteAddress: + a, err := netip.ParseAddr(p.addr) + match = match && err == nil && netip.MustParsePrefix(c.Text).Contains(a) + } + } + if match { + return r.Permit + } + } + return false +} + +func TestPolicyBlocksOrdinaryPhysicalAndTrustedPlainDNS(t *testing.T) { + rules := Policy(42) + for _, layer := range Layers { + p := packet{layer: layer, proto: 6, local: 50000, remote: 443} + if allowed(rules, p) { + t.Fatal("ordinary physical traffic permitted", layer) + } + p.app = Engine + if !allowed(rules, p) { + t.Fatal("engine transport blocked", layer) + } + if layer.Outbound() { + p.remote = 53 + } else { + p.local = 53 + } + if allowed(rules, p) { + t.Fatal("engine plaintext DNS escaped", layer) + } + p.luid = 42 + if !allowed(rules, p) { + t.Fatal("DNS inside TUN blocked", layer) + } + p.luid = 43 + if allowed(rules, p) { + t.Fatal("replacement uplink inherited TUN permission", layer) + } + p.loop = true + if !allowed(rules, p) { + t.Fatal("loopback blocked", layer) + } + } +} + +func TestPolicyLockdownDHCPNDPAndDefaultCoverage(t *testing.T) { + rules := Policy(0) + seen := map[Layer]int{} + for _, r := range rules { + if len(r.Conditions) == 0 && !r.Permit { + seen[r.Layer]++ + } + for _, c := range r.Conditions { + if c.Field == Interface { + t.Fatal("lockdown has TUN permit") + } + } + } + for _, layer := range Layers { + if seen[layer] != 1 { + t.Fatal("missing unique default block", layer) + } + p := packet{layer: layer, app: DHCP, proto: 17, local: 68, remote: 67} + if layer.IPv6() { + p.local, p.remote = 546, 547 + } + if !allowed(rules, p) { + t.Fatal("DHCP unavailable", layer) + } + p.app = "browser" + if allowed(rules, p) { + t.Fatal("untrusted DHCP-port bypass", layer) + } + } + for _, addr := range []string{"fe80::1", "ff02::1"} { + if !allowed(rules, packet{layer: Connect6, proto: 58, local: 135, remote: 0, addr: addr}) { + t.Fatal("NDP blocked") + } + } + if allowed(rules, packet{layer: Connect6, proto: 58, local: 128, remote: 0, addr: "fe80::1"}) { + t.Fatal("arbitrary ICMP allowed") + } + if allowed(rules, packet{layer: Connect6, proto: 58, local: 135, remote: 0, addr: "2001:db8::1"}) { + t.Fatal("offlink NDP allowed") + } +} + +type memoryBackend struct { + present, complete bool + applyErr, removeErr error + luid uint64 + applications []uint64 + removes int +} + +func (m *memoryBackend) Inspect() (bool, bool, error) { return m.present, m.complete, nil } +func (m *memoryBackend) Replace(luid uint64) error { + m.applications = append(m.applications, luid) + if m.applyErr != nil { + return m.applyErr + } + m.present, m.complete = true, true + return nil +} +func (m *memoryBackend) ResolveTunnel(string, string) (uint64, error) { + if m.luid == 0 { + return 0, errors.New("missing TUN") + } + return m.luid, nil +} +func (m *memoryBackend) Remove() error { + m.removes++ + if m.removeErr != nil { + return m.removeErr + } + m.present, m.complete = false, false + return nil +} + +func TestGuardFailureAndReleasePreserveTruth(t *testing.T) { + b := &memoryBackend{luid: 42} + g := New(b) + if err := g.Prepare(); err != nil { + t.Fatal(err) + } + if s := g.Snapshot(); s.Status != "blocked" || !s.Enforced || !s.Persistent { + t.Fatal(s) + } + g.Accepted() + if g.Snapshot().Status != "blocked" { + t.Fatal("lockdown alone was promoted without TUN/system-proxy verification") + } + if err := g.VerifyTunnel("tenebra", "172.19.0.1/30", false); err != nil { + t.Fatal(err) + } + if g.Snapshot().Status != "blocked" { + t.Fatal("unaccepted engine reported active") + } + g.Accepted() + if g.Snapshot().Status != "active" { + t.Fatal(g.Snapshot()) + } + g.Interrupted() + if g.Snapshot().Status != "blocked" || b.removes != 0 { + t.Fatal("process exit released policy") + } + b.applyErr = errors.New("transaction failed") + if g.Prepare() == nil || !g.Snapshot().Enforced || g.Snapshot().Status != "error" { + t.Fatal(g.Snapshot()) + } + b.removeErr = errors.New("cleanup failed") + if g.Release() == nil || !g.Snapshot().Enforced { + t.Fatal("failed release claimed off") + } + b.removeErr = nil + if g.Release() != nil || g.Snapshot().Enforced || g.Snapshot().Status != "off" { + t.Fatal(g.Snapshot()) + } +} + +func TestGuardRecoveryDoesNotOpenTrafficOrInventProtection(t *testing.T) { + b := &memoryBackend{} + g := New(b) + if err := g.Recover(); err != nil || len(b.applications) != 0 { + t.Fatal("startup with no policy wrote native state") + } + b.present, b.complete = true, true + if err := g.Recover(); err != nil || len(b.applications) != 1 || b.applications[0] != 0 { + t.Fatal("recovery did not lock down", err) + } + b2 := &memoryBackend{applyErr: errors.New("denied")} + g2 := New(b2) + if g2.Prepare() == nil || g2.Snapshot().Enforced { + t.Fatal("initial failed apply claims enforced") + } + if New(nil).Prepare() == nil || New(nil).Snapshot().Status != "unavailable" { + t.Fatal("nil native adapter accepted") + } +} diff --git a/core/protection/resolver.go b/core/protection/resolver.go new file mode 100644 index 00000000..9e131f4e --- /dev/null +++ b/core/protection/resolver.go @@ -0,0 +1,167 @@ +package protection + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// NewResolver creates no connections and changes no globals. The production +// entry point may install it before starting goroutines. While protected, even +// the resolver's retries use the same explicit encrypted endpoint: no OS DNS, +// proxy environment, redirected endpoint or plaintext fallback is consulted. +func NewResolver(source func() (endpoint string, required bool)) *net.Resolver { + client := &http.Client{ + Timeout: 4 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("DNS bootstrap redirects are forbidden") }, + Transport: &http.Transport{Proxy: nil, TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, TLSHandshakeTimeout: 3 * time.Second, ResponseHeaderTimeout: 3 * time.Second, MaxIdleConns: 2, MaxIdleConnsPerHost: 2, MaxConnsPerHost: 2, IdleConnTimeout: 30 * time.Second, ForceAttemptHTTP2: true}, + } + return &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + endpoint, required := source() + if !required { + return (&net.Dialer{Timeout: 4 * time.Second}).DialContext(ctx, network, address) + } + if err := ValidateDNS(endpoint); err != nil { + return nil, err + } + u, _ := url.Parse(endpoint) + if u.Scheme == "tls" { + port := u.Port() + if port == "" { + port = "853" + } + d := tls.Dialer{NetDialer: &net.Dialer{Timeout: 4 * time.Second}, Config: &tls.Config{ServerName: u.Hostname(), MinVersion: tls.VersionTLS12}} + return d.DialContext(ctx, "tcp", net.JoinHostPort(u.Hostname(), port)) + } + if u.Path == "" { + u.Path = "/dns-query" + } + return newDNSConn(ctx, u.String(), client), nil + }} +} + +// dnsConn adapts the Go resolver's TCP DNS framing to a single bounded RFC8484 +// POST. It is intentionally not a PacketConn, even when Resolver.Dial asks for +// "udp": net.Resolver then uses the stream framing specified by its contract. +type dnsConn struct { + mu sync.Mutex + ctx context.Context + cancel context.CancelFunc + endpoint string + client *http.Client + deadline time.Time + closed bool + pending []byte + response *bytes.Reader +} + +func newDNSConn(ctx context.Context, endpoint string, client *http.Client) *dnsConn { + ctx, cancel := context.WithCancel(ctx) + return &dnsConn{ctx: ctx, cancel: cancel, endpoint: endpoint, client: client} +} +func (c *dnsConn) Write(p []byte) (int, error) { + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return 0, net.ErrClosed + } + if len(c.pending)+len(p) > 65537 { + c.mu.Unlock() + return 0, errors.New("DNS query too large") + } + c.pending = append(c.pending, p...) + if len(c.pending) < 2 { + c.mu.Unlock() + return len(p), nil + } + n := int(binary.BigEndian.Uint16(c.pending)) + if n < 12 || len(c.pending) > n+2 { + c.mu.Unlock() + return 0, errors.New("invalid DNS stream frame") + } + if len(c.pending) < n+2 { + c.mu.Unlock() + return len(p), nil + } + query := append([]byte(nil), c.pending[2:]...) + c.pending = nil + deadline := c.deadline + c.mu.Unlock() + ctx, cancel := context.WithTimeout(c.ctx, 4*time.Second) + defer cancel() + if !deadline.IsZero() { + var stop context.CancelFunc + ctx, stop = context.WithDeadline(ctx, deadline) + defer stop() + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(query)) + if err != nil { + return 0, err + } + req.Header.Set("Content-Type", "application/dns-message") + req.Header.Set("Accept", "application/dns-message") + resp, err := c.client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("DNS bootstrap HTTP status %d", resp.StatusCode) + } + if strings.Split(resp.Header.Get("Content-Type"), ";")[0] != "application/dns-message" { + return 0, errors.New("DNS bootstrap returned an invalid media type") + } + answer, err := io.ReadAll(io.LimitReader(resp.Body, 65536)) + if err != nil { + return 0, err + } + if len(answer) < 12 || len(answer) > 65535 { + return 0, errors.New("DNS bootstrap returned an invalid length") + } + framed := binary.BigEndian.AppendUint16(nil, uint16(len(answer))) + framed = append(framed, answer...) + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return 0, net.ErrClosed + } + c.response = bytes.NewReader(framed) + return len(p), nil +} +func (c *dnsConn) Read(p []byte) (int, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return 0, net.ErrClosed + } + if c.response == nil { + return 0, errors.New("DNS query has not completed") + } + return c.response.Read(p) +} +func (c *dnsConn) Close() error { c.mu.Lock(); c.closed = true; c.mu.Unlock(); c.cancel(); return nil } +func (c *dnsConn) SetDeadline(t time.Time) error { + c.mu.Lock() + c.deadline = t + c.mu.Unlock() + return nil +} +func (c *dnsConn) SetReadDeadline(t time.Time) error { return c.SetDeadline(t) } +func (c *dnsConn) SetWriteDeadline(t time.Time) error { return c.SetDeadline(t) } +func (c *dnsConn) LocalAddr() net.Addr { return dnsAddr("core") } +func (c *dnsConn) RemoteAddr() net.Addr { return dnsAddr("encrypted-resolver") } + +type dnsAddr string + +func (a dnsAddr) Network() string { return "tcp" } +func (a dnsAddr) String() string { return string(a) } diff --git a/core/protection/resolver_test.go b/core/protection/resolver_test.go new file mode 100644 index 00000000..02c95513 --- /dev/null +++ b/core/protection/resolver_test.go @@ -0,0 +1,60 @@ +package protection + +import ( + "bytes" + "context" + "encoding/binary" + "io" + "net/http" + "testing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestEncryptedBootstrapRefusesPlaintextAndHostnameWithoutNetwork(t *testing.T) { + for _, value := range []string{"8.8.8.8", "udp://8.8.8.8", "https://dns.example.test/dns-query", "tls://dns.example.test", "http://1.1.1.1/dns-query", "quic://1.1.1.1"} { + if err := ValidateDNS(value); err == nil { + t.Fatal("invalid resolver accepted", value) + } + r := NewResolver(func() (string, bool) { return value, true }) + if _, err := r.Dial(context.Background(), "udp", "192.0.2.53:53"); err == nil { + t.Fatal("invalid resolver attempted fallback", value) + } + } + for _, value := range []string{"https://77.88.8.8/dns-query", "tls://1.1.1.1", "tls://[2606:4700:4700::1111]:853"} { + if err := ValidateDNS(value); err != nil { + t.Fatal(err) + } + } +} + +func TestDNSConnFramesBoundedHTTPSReplyWithoutSocket(t *testing.T) { + query := []byte{0x12, 0x34, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0} + answer := append([]byte(nil), query...) + answer[2] = 0x81 + called := 0 + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + called++ + body, _ := io.ReadAll(r.Body) + if r.Method != "POST" || r.URL.String() != "https://192.0.2.53/dns-query" || !bytes.Equal(body, query) { + t.Fatal("wrong encrypted query") + } + return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/dns-message"}}, Body: io.NopCloser(bytes.NewReader(answer))}, nil + })} + c := newDNSConn(context.Background(), "https://192.0.2.53/dns-query", client) + defer c.Close() + frame := binary.BigEndian.AppendUint16(nil, uint16(len(query))) + frame = append(frame, query...) + if _, err := c.Write(frame[:1]); err != nil { + t.Fatal(err) + } + if _, err := c.Write(frame[1:]); err != nil { + t.Fatal(err) + } + got, err := io.ReadAll(c) + if err != nil || called != 1 || !bytes.Equal(got[2:], answer) || int(binary.BigEndian.Uint16(got)) != len(answer) { + t.Fatalf("got=%x called=%d err=%v", got, called, err) + } +} diff --git a/core/protection/wfp_abi_windows.go b/core/protection/wfp_abi_windows.go new file mode 100644 index 00000000..5fa115e4 --- /dev/null +++ b/core/protection/wfp_abi_windows.go @@ -0,0 +1,201 @@ +//go:build windows && (amd64 || arm64) + +package protection + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "runtime" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +// These are the 64-bit SDK layouts, including FWPM_FILTER0's 16-byte UNION. +// https://learn.microsoft.com/windows/win32/api/fwpmtypes/ns-fwpmtypes-fwpm_filter0 +type displayData struct{ Name, Description *uint16 } +type byteBlob struct { + Size uint32 + Data *byte +} +type wfpValue struct { + Type uint32 + Value uintptr +} + +// pointer reads the pointer member of the SDK's value union without rebuilding +// a pointer from an integer. Call only for pointer-valued FWP data types; inline +// UINT8/16/32 remain numbers so Go's GC never scans them as pointers. +func (v *wfpValue) pointer() unsafe.Pointer { + return *(*unsafe.Pointer)(unsafe.Pointer(&v.Value)) +} + +type wfpSession struct { + Key windows.GUID + Display displayData + Flags, Timeout, PID uint32 + SID *windows.SID + Username *uint16 + KernelMode uint8 +} +type wfpProvider struct { + Key windows.GUID + Display displayData + Flags uint32 + Data byteBlob + ServiceName *uint16 +} +type wfpSublayer struct { + Key windows.GUID + Display displayData + Flags uint32 + Provider *windows.GUID + Data byteBlob + Weight uint16 +} +type wfpAction struct { + Type uint32 + Key windows.GUID +} +type wfpCondition struct { + Field windows.GUID + Match uint32 + Value wfpValue +} +type wfpFilter struct { + Key windows.GUID + Display displayData + Flags uint32 + Provider *windows.GUID + Data byteBlob + Layer, Sublayer windows.GUID + Weight wfpValue + Count uint32 + Conditions *wfpCondition + Action wfpAction + Context [2]uint64 // rawContext OR providerContextKey, never two sequential fields + Reserved *windows.GUID + ID uint64 + EffectiveWeight wfpValue +} +type filterTemplate struct { + Provider *windows.GUID + Layer windows.GUID + EnumType, Flags uint32 + ProviderContext unsafe.Pointer + Count uint32 + Conditions *wfpCondition + ActionMask uint32 + Callout *windows.GUID +} +type v6Mask struct { + Address [16]byte + Prefix uint8 +} + +const ( + persistentFlag = uint32(1) + blockAction = uint32(0x1001) + permitAction = uint32(0x1002) + marker = "tenebra/persistent-host-guard/v1" +) + +// Stable ownership keys. A collision is an error unless the metadata and the +// provider relationship match; no operation deletes by display name. +var providerKey = guid("fcb43b44-9358-4cd7-a998-9e7f822d5248") +var sublayerKey = guid("fcb43b45-9358-4cd7-a998-9e7f822d5248") +var layerKeys = [4]windows.GUID{ + guid("c38d57d1-05a7-4c33-904f-7fbceee60e82"), guid("4a72393b-319f-44bc-84c3-ba54dcb3b6b4"), + guid("e1cd9fe7-f4b5-4273-96c0-592e487b8650"), guid("a3b42c97-9f04-4672-b87e-cee9c483257f"), +} +var fieldFlags = guid("632ce23b-5167-435c-86d7-e903684aa80c") +var fieldNextHop = guid("93ae8f5b-7f6f-4719-98c8-14e97429ef04") +var fieldLocalInterface = guid("4cd62a49-59c3-4969-b7f3-bda5d32890a4") +var fieldApp = guid("d78e1e87-8644-4ea5-9437-d809ecefc971") +var fieldUser = guid("af043a0a-b34d-4f86-979c-c90371af6e66") +var fieldProtocol = guid("3971ef2b-623e-4f9a-8cb1-6e79b806b9a7") +var fieldLocalPort = guid("0c1ba1af-5765-453f-af22-a8f791ac775b") +var fieldRemotePort = guid("c35a604d-d22b-4e1a-91b4-68f674ee674b") +var fieldRemoteAddress = guid("b235ae9a-1d64-49b8-a44c-5ff3d9095045") + +// GUID parsing is pure Go; it does not load a DLL or contact BFE. +func guid(s string) windows.GUID { + b, err := hex.DecodeString(strings.ReplaceAll(s, "-", "")) + if err != nil || len(b) != 16 { + panic(err) + } + g := windows.GUID{Data1: binary.BigEndian.Uint32(b[:4]), Data2: binary.BigEndian.Uint16(b[4:6]), Data3: binary.BigEndian.Uint16(b[6:8])} + copy(g.Data4[:], b[8:]) + return g +} +func filterKey(key string) windows.GUID { + h := sha256.Sum256([]byte(marker + "/" + key)) + return windows.GUID{Data1: binary.BigEndian.Uint32(h[:4]), Data2: binary.BigEndian.Uint16(h[4:6]), Data3: (binary.BigEndian.Uint16(h[6:8]) & 0x0fff) | 0x5000, Data4: [8]byte{(h[8] & 0x3f) | 0x80, h[9], h[10], h[11], h[12], h[13], h[14], h[15]}} +} + +// Pointer arguments remain typed GC roots through the entire synchronous call. +// Do not convert stack pointers to uintptr before entering an ordinary Go +// wrapper: a stack growth could otherwise invalidate the native address. +type nativeArg struct { + p unsafe.Pointer + value uintptr +} + +func ptr[T any](p *T) nativeArg { return nativeArg{p: unsafe.Pointer(p)} } +func num(n uintptr) nativeArg { return nativeArg{value: n} } + +type nativeCall func(string, ...nativeArg) error + +// Lazy construction performs no native call. Reuse the loaded module instead +// of accumulating a LoadLibrary reference for each filter operation. +var wfpDLL = windows.NewLazySystemDLL("fwpuclnt.dll") + +func callWFP(name string, args ...nativeArg) error { + proc := wfpDLL.NewProc(name) + if err := proc.Find(); err != nil { + return err + } + values := make([]uintptr, len(args)) + for i, a := range args { + values[i] = a.value + if a.p != nil { + values[i] = uintptr(a.p) + } + } + r, _, _ := proc.Call(values...) + runtime.KeepAlive(args) + if name == "FwpmFreeMemory0" { + return nil + } // void API + if r != 0 { + return fmt.Errorf("%s: %w", name, syscall.Errno(r)) + } + return nil +} + +func transaction(call nativeCall, h uintptr, fn func() error) (err error) { + if err = call("FwpmTransactionBegin0", num(h), num(0)); err != nil { + return err + } + committed := false + defer func() { + if !committed { + abortErr := call("FwpmTransactionAbort0", num(h)) + if abortErr != nil { + err = fmt.Errorf("%w; abort: %v", err, abortErr) + } + } + }() + if err = fn(); err != nil { + return err + } + if err = call("FwpmTransactionCommit0", num(h)); err != nil { + return err + } + committed = true + return nil +} diff --git a/core/protection/wfp_abi_windows_test.go b/core/protection/wfp_abi_windows_test.go new file mode 100644 index 00000000..a3dff20f --- /dev/null +++ b/core/protection/wfp_abi_windows_test.go @@ -0,0 +1,181 @@ +//go:build windows && (amd64 || arm64) + +package protection + +import ( + "errors" + "reflect" + "syscall" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +func TestWFPABI64WithoutNativeCalls(t *testing.T) { + f := wfpFilter{} + checks := map[string][2]uintptr{ + "filter size": {unsafe.Sizeof(f), 200}, "context": {unsafe.Offsetof(f.Context), 152}, + "reserved": {unsafe.Offsetof(f.Reserved), 168}, "id": {unsafe.Offsetof(f.ID), 176}, + "effective": {unsafe.Offsetof(f.EffectiveWeight), 184}, "value": {unsafe.Sizeof(wfpValue{}), 16}, + "condition": {unsafe.Sizeof(wfpCondition{}), 40}, "session": {unsafe.Sizeof(wfpSession{}), 72}, + "provider": {unsafe.Sizeof(wfpProvider{}), 64}, "sublayer": {unsafe.Sizeof(wfpSublayer{}), 72}, + } + for name, c := range checks { + if c[0] != c[1] { + t.Errorf("%s=%d want%d", name, c[0], c[1]) + } + } +} + +func TestWFPMarshalsOnlyScopedPersistentSoftPermits(t *testing.T) { + apps := map[string]appIdentity{} + for _, name := range []string{Core, Engine, DHCP} { + apps[name] = appIdentity{app: []byte{1, 2}, sd: []byte{3, 4, 5}} + } + var got []filterInfo + b := &windowsBackend{call: func(name string, args ...nativeArg) error { + if name != "FwpmFilterAdd0" { + t.Fatal("unexpected native call", name) + } + f := (*wfpFilter)(args[1].p) + if f.Provider == nil || *f.Provider != providerKey || f.Sublayer != sublayerKey || f.Flags != 1 || f.Context != [2]uint64{} { + t.Fatal("wrong filter lifetime/ownership") + } + got = append(got, filterInfo{f.Key, f.Layer, f.Flags, f.Action.Type, f.Count, *(*uint64)(f.Weight.pointer())}) + if f.Count == 0 { + return nil + } + hasNextHop, hasLocal := false, false + for _, c := range unsafe.Slice(f.Conditions, f.Count) { + if c.Field == fieldUser { + blob := (*byteBlob)(c.Value.pointer()) + if c.Value.Type != 14 || blob.Size != 3 || blob.Data == nil || *blob.Data != 3 { + t.Fatal("security descriptor is not an FWP_BYTE_BLOB") + } + } + if c.Field == fieldNextHop || c.Field == fieldLocalInterface { + hasNextHop = hasNextHop || c.Field == fieldNextHop + hasLocal = hasLocal || c.Field == fieldLocalInterface + if c.Value.Type != 4 || *(*uint64)(c.Value.pointer()) != 42 { + t.Fatal("wrong TUN identity condition") + } + } + } + if hasNextHop || hasLocal { + inbound := f.Layer == layerKeys[Accept4] || f.Layer == layerKeys[Accept6] + if !hasNextHop || hasLocal != inbound { + t.Fatal("TUN permit does not constrain both directions of the flow") + } + } + return nil + }} + for _, r := range Policy(42) { + if err := b.addFilter(7, r, apps, nil); err != nil { + t.Fatal(err) + } + } + if !completeBlocks(got) { + t.Fatal("native form does not cover four default blocks") + } + got[0].flags = 0 // a non-block does not establish enforcement itself + for i := range got { + if got[i].count == 0 { + got[i].flags |= 32 + break + } + } + if completeBlocks(got) { + t.Fatal("disabled catch-all reported complete") + } +} + +func TestWFPOwnershipCollisionNeverDeletesForeignObjects(t *testing.T) { + data := []byte("not-tenebra") + p := &wfpProvider{Key: providerKey, Flags: 1, Data: makeBlob(data)} + var calls []string + b := &windowsBackend{call: func(name string, args ...nativeArg) error { + calls = append(calls, name) + switch name { + case "FwpmProviderGetByKey0": + *(**wfpProvider)(args[2].p) = p + return nil + case "FwpmFreeMemory0": + return nil + default: + return syscall.Errno(windows.FWP_E_SUBLAYER_NOT_FOUND) + } + }} + if _, _, err := b.owned(7); err == nil { + t.Fatal("foreign provider adopted") + } + if !reflect.DeepEqual(calls, []string{"FwpmProviderGetByKey0", "FwpmFreeMemory0"}) { + t.Fatal("foreign provider was touched", calls) + } +} + +func TestWFPDisabledOwnedPolicyCanBeRecoveredAndRemoved(t *testing.T) { + data := []byte(marker) + p := &wfpProvider{Key: providerKey, Flags: 1 | 0x10, Data: makeBlob(data)} + s := &wfpSublayer{Key: sublayerKey, Flags: 1, Provider: &providerKey, Data: makeBlob(data), Weight: 0xffff} + b := &windowsBackend{call: func(name string, args ...nativeArg) error { + switch name { + case "FwpmProviderGetByKey0": + *(**wfpProvider)(args[2].p) = p + case "FwpmSubLayerGetByKey0": + *(**wfpSublayer)(args[2].p) = s + case "FwpmFreeMemory0": + default: + t.Fatal("unexpected call", name) + } + return nil + }} + if _, _, err := b.owned(7); err != nil { + t.Fatal("disabled legitimate provider became unremovable", err) + } + stop := errors.New("fake enumeration boundary") + b.call = func(name string, args ...nativeArg) error { + if name != "FwpmFilterCreateEnumHandle0" { + t.Fatal(name) + } + template := (*filterTemplate)(args[1].p) + if template.Flags&0x18 != 0x18 { + t.Fatal("cleanup omits disabled/boot-time filters") + } + if template.Layer != layerKeys[Connect4] { + t.Fatal("enumeration must use a specific owned layer") + } + return stop + } + if _, err := b.filters(7); !errors.Is(err, stop) { + t.Fatal(err) + } +} + +func TestWFPTransactionCommitAndFailureAbortWithoutNativeCalls(t *testing.T) { + for _, fail := range []string{"", "operation", "FwpmTransactionBegin0", "FwpmTransactionCommit0"} { + t.Run(fail, func(t *testing.T) { + var calls []string + call := func(name string, _ ...nativeArg) error { + calls = append(calls, name) + if name == fail { + return errors.New("injected") + } + return nil + } + err := transaction(call, 7, func() error { return call("operation") }) + want := []string{"FwpmTransactionBegin0", "operation", "FwpmTransactionCommit0"} + switch fail { + case "operation": + want = []string{"FwpmTransactionBegin0", "operation", "FwpmTransactionAbort0"} + case "FwpmTransactionBegin0": + want = []string{"FwpmTransactionBegin0"} + case "FwpmTransactionCommit0": + want = append(want, "FwpmTransactionAbort0") + } + if !reflect.DeepEqual(calls, want) || (err == nil) != (fail == "") { + t.Fatalf("calls=%v err=%v", calls, err) + } + }) + } +} diff --git a/core/protection/wfp_identity_windows.go b/core/protection/wfp_identity_windows.go new file mode 100644 index 00000000..fe56efa0 --- /dev/null +++ b/core/protection/wfp_identity_windows.go @@ -0,0 +1,211 @@ +//go:build windows && (amd64 || arm64) + +package protection + +import ( + "errors" + "fmt" + "net/netip" + "os" + "path/filepath" + "runtime" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +func (b *windowsBackend) identities() (map[string]appIdentity, error) { + if b.enginePath == nil { + return nil, errors.New("engine executable identity unavailable") + } + engine, err := b.enginePath() + if err != nil { + return nil, err + } + core, err := os.Executable() + if err != nil { + return nil, err + } + systemDir, err := windows.GetSystemDirectory() + if err != nil { + return nil, err + } + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return nil, err + } + dhcpSID, _, _, err := windows.LookupSID("", "NT SERVICE\\Dhcp") + if err != nil { + return nil, err + } + paths := map[string]string{Core: core, Engine: engine, DHCP: filepath.Join(systemDir, "svchost.exe")} + result := make(map[string]appIdentity) + for name, path := range paths { + if err := trustedExecutable(path); err != nil { + return nil, fmt.Errorf("%s protection identity: %w", name, err) + } + sid := user.User.Sid.String() + if name == DHCP { + sid = dhcpSID.String() + } + // FWP_ACTRL_MATCH_FILTER=1. Match the account/service token as well as + // path; a same-name binary run by another account gets no exemption. + sd, err := windows.SecurityDescriptorFromString("D:(A;;CC;;;" + sid + ")") + if err != nil { + return nil, err + } + path16, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + var blob *byteBlob + if err := b.call("FwpmGetAppIdFromFileName0", ptr(path16), ptr(&blob)); err != nil { + return nil, err + } + if blob == nil || blob.Data == nil || blob.Size == 0 || blob.Size > 65536 { + if blob != nil { + b.free(unsafe.Pointer(blob)) + } + return nil, errors.New("invalid WFP executable identity") + } + app := append([]byte(nil), unsafe.Slice(blob.Data, blob.Size)...) + b.free(unsafe.Pointer(blob)) + sdBytes := append([]byte(nil), unsafe.Slice((*byte)(unsafe.Pointer(sd)), sd.Length())...) + runtime.KeepAlive(sd) + result[name] = appIdentity{app, sdBytes} + } + return result, nil +} + +// trustedExecutable is deliberately conservative. A persistent unrestricted +// application permit must never point at an ordinary user's replaceable file. +// ACL checks include owner-implied WRITE_DAC, parent DELETE_CHILD and reparse +// points. This rejects portable/user-writable installs with an actionable error. +func trustedExecutable(path string) error { + if !filepath.IsAbs(path) || strings.HasPrefix(path, `\\`) { + return errors.New("protection requires an absolute local executable path") + } + info, err := os.Stat(path) + if err != nil { + return err + } + if info.IsDir() { + return errors.New("executable path is a directory") + } + trustedInstaller, _, _, err := windows.LookupSID("", "NT SERVICE\\TrustedInstaller") + if err != nil { + return err + } + trusted := func(s *windows.SID) bool { + return s != nil && (s.String() == "S-1-5-18" || s.String() == "S-1-5-32-544" || s.String() == trustedInstaller.String()) + } + for depth, current := 0, filepath.Clean(path); ; depth, current = depth+1, filepath.Dir(current) { + name, err := windows.UTF16PtrFromString(current) + if err != nil { + return err + } + attrs, err := windows.GetFileAttributes(name) + if err != nil { + return err + } + if attrs&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return fmt.Errorf("protected executable path traverses a reparse point: %s", current) + } + sd, err := windows.GetNamedSecurityInfo(current, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION) + if err != nil { + return err + } + owner, _, err := sd.Owner() + if err != nil { + return err + } + if !trusted(owner) { + return fmt.Errorf("install Tenebra in an administrator-owned directory: %s", current) + } + acl, _, err := sd.DACL() + if err != nil { + return err + } + if acl == nil { + return fmt.Errorf("unrestricted executable ACL: %s", current) + } + // File/containing directory mutations and ancestor replacement rights. + var dangerous windows.ACCESS_MASK = 0x10000000 | 0x40000000 | 0x00010000 | 0x00040000 | 0x00080000 | 0x40 + if depth <= 1 { + dangerous |= 0x2 | 0x4 | 0x10 | 0x100 + } + for i := uint32(0); i < uint32(acl.AceCount); i++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(acl, i, &ace); err != nil { + return err + } + if ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 || ace.Header.AceType == windows.ACCESS_DENIED_ACE_TYPE { + continue + } + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + return fmt.Errorf("unsupported executable ACL entry: %s", current) + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + if ace.Mask&dangerous != 0 && !trusted(sid) { + return fmt.Errorf("executable can be replaced by an untrusted identity: %s", current) + } + } + runtime.KeepAlive(sd) + if parent := filepath.Dir(current); parent == current { + break + } + if depth >= 32 { + return errors.New("executable path exceeds trust-check depth") + } + } + return nil +} + +func (b *windowsBackend) ResolveTunnel(name, address string) (uint64, error) { + prefix, err := netip.ParsePrefix(address) + if err != nil || name == "" { + return 0, errors.New("TUN identity requires its configured name and address") + } + var size uint32 = 16384 + for attempt := 0; attempt < 4; attempt++ { + if size == 0 || size > 4<<20 { + return 0, errors.New("adapter enumeration exceeded bound") + } + buf := make([]byte, size) + first := (*windows.IpAdapterAddresses)(unsafe.Pointer(&buf[0])) + err := windows.GetAdaptersAddresses(windows.AF_UNSPEC, windows.GAA_FLAG_SKIP_ANYCAST|windows.GAA_FLAG_SKIP_MULTICAST|windows.GAA_FLAG_SKIP_DNS_SERVER, 0, first, &size) + if err == windows.ERROR_BUFFER_OVERFLOW { + continue + } + if err != nil { + return 0, err + } + var found uint64 + for a := first; a != nil; a = a.Next { + if windows.UTF16PtrToString(a.FriendlyName) != name { + continue + } + if found != 0 || a.Luid == 0 || (a.IfType != 53 && a.IfType != 131) || a.OperStatus != windows.IfOperStatusUp { + return 0, errors.New("configured TUN is not a unique active virtual interface") + } + matched := false + for u := a.FirstUnicastAddress; u != nil; u = u.Next { + ip, ok := netip.AddrFromSlice(u.Address.IP()) + if ok && ip.Unmap() == prefix.Addr().Unmap() { + matched = true + } + } + if !matched { + return 0, errors.New("configured TUN does not own its expected address") + } + found = a.Luid + } + runtime.KeepAlive(buf) + if found != 0 { + return found, nil + } + return 0, errors.New("configured TUN was not found") + } + return 0, errors.New("adapter list kept changing") +} diff --git a/core/protection/wfp_other.go b/core/protection/wfp_other.go new file mode 100644 index 00000000..c371df16 --- /dev/null +++ b/core/protection/wfp_other.go @@ -0,0 +1,6 @@ +//go:build !windows || (!amd64 && !arm64) + +package protection + +// Unsupported ABIs never use the 64-bit WFP structs and never claim protection. +func NewWindowsBackend(func() (string, error)) Backend { return nil } diff --git a/core/protection/wfp_windows.go b/core/protection/wfp_windows.go new file mode 100644 index 00000000..7e372f78 --- /dev/null +++ b/core/protection/wfp_windows.go @@ -0,0 +1,344 @@ +//go:build windows && (amd64 || arm64) + +package protection + +import ( + "errors" + "net/netip" + "runtime" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +type windowsBackend struct { + enginePath func() (string, error) + call nativeCall +} + +// NewWindowsBackend is inert: no DLL is loaded, no interface is enumerated and +// no WFP session is opened until an explicit Guard operation. Production alone +// passes this backend to the daemon. Unit fixtures use an in-memory Backend. +func NewWindowsBackend(enginePath func() (string, error)) Backend { + return &windowsBackend{enginePath: enginePath, call: callWFP} +} + +func (b *windowsBackend) session(fn func(uintptr) error) error { + s := wfpSession{Timeout: 3000} + var h uintptr + if err := b.call("FwpmEngineOpen0", num(0), num(10), num(0), ptr(&s), ptr(&h)); err != nil { + return err + } + defer b.call("FwpmEngineClose0", num(h)) + return fn(h) +} +func (b *windowsBackend) free(p unsafe.Pointer) { b.call("FwpmFreeMemory0", ptr(&p)) } +func blobEqual(v byteBlob, s string) bool { + return v.Size == uint32(len(s)) && v.Data != nil && string(unsafe.Slice(v.Data, v.Size)) == s +} +func makeBlob(s []byte) byteBlob { + if len(s) == 0 { + return byteBlob{} + } + return byteBlob{Size: uint32(len(s)), Data: &s[0]} +} +func display(name string) displayData { + return displayData{Name: windows.StringToUTF16Ptr(name), Description: windows.StringToUTF16Ptr(marker)} +} +func notFound(err error, code windows.Handle) bool { return errors.Is(err, syscall.Errno(code)) } + +// owned validates both stable keys before any deletion. A partially-created +// provider is recoverable; an occupied key with foreign metadata is not ours. +func (b *windowsBackend) owned(h uintptr) (provider, sublayer bool, err error) { + s, err := b.ownedState(h) + return s.provider, s.sublayer, err +} + +type ownership struct{ provider, sublayer, enforcing bool } + +// Ownership and current enforcement are separate. Disabled/static/misweighted +// owned objects must remain removable and atomically repairable. +func (b *windowsBackend) ownedState(h uintptr) (out ownership, err error) { + var p *wfpProvider + err = b.call("FwpmProviderGetByKey0", num(h), ptr(&providerKey), ptr(&p)) + if err != nil && !notFound(err, windows.FWP_E_PROVIDER_NOT_FOUND) { + return out, err + } + if err == nil { + defer b.free(unsafe.Pointer(p)) + if p == nil || p.Key != providerKey || !blobEqual(p.Data, marker) { + return out, errors.New("WFP provider ownership mismatch") + } + out.provider = true + out.enforcing = p.Flags&persistentFlag != 0 && p.Flags&0x10 == 0 && (p.ServiceName == nil || windows.UTF16PtrToString(p.ServiceName) == "") + } + var s *wfpSublayer + err = b.call("FwpmSubLayerGetByKey0", num(h), ptr(&sublayerKey), ptr(&s)) + if err != nil && !notFound(err, windows.FWP_E_SUBLAYER_NOT_FOUND) { + return out, err + } + if err == nil { + defer b.free(unsafe.Pointer(s)) + if s == nil || s.Key != sublayerKey || s.Provider == nil || *s.Provider != providerKey || !blobEqual(s.Data, marker) { + return out, errors.New("WFP sublayer ownership mismatch") + } + out.sublayer = true + out.enforcing = out.enforcing && s.Flags&persistentFlag != 0 && s.Weight == 0xffff + } + if out.sublayer && !out.provider { + return out, errors.New("WFP sublayer has no owned provider") + } + return out, nil +} + +type filterInfo struct { + key, layer windows.GUID + flags, action, count uint32 + weight uint64 +} + +func (b *windowsBackend) filters(h uintptr) ([]filterInfo, error) { + var out []filterInfo + for _, layer := range layerKeys { + filters, err := b.filtersAtLayer(h, layer) + if err != nil { + return nil, err + } + if len(out)+len(filters) > 512 { + return nil, errors.New("too many owned WFP filters") + } + out = append(out, filters...) + } + return out, nil +} + +func (b *windowsBackend) filtersAtLayer(h uintptr, layer windows.GUID) ([]filterInfo, error) { + // SDK fwptypes.h: INCLUDE_BOOTTIME=8, INCLUDE_DISABLED=16. Cleanup must + // enumerate these too, even though they are not evidence of active policy. + template := filterTemplate{Provider: &providerKey, Layer: layer, Flags: 0x18, ActionMask: 0xffffffff} + var enum uintptr + if err := b.call("FwpmFilterCreateEnumHandle0", num(h), ptr(&template), ptr(&enum)); err != nil { + return nil, err + } + defer b.call("FwpmFilterDestroyEnumHandle0", num(h), num(enum)) + var out []filterInfo + for batch := 0; batch < 9; batch++ { + var entries **wfpFilter + var count uint32 + if err := b.call("FwpmFilterEnum0", num(h), num(enum), num(64), ptr(&entries), ptr(&count)); err != nil { + return nil, err + } + if count > 64 { + b.free(unsafe.Pointer(entries)) + return nil, errors.New("WFP enumeration exceeded batch bound") + } + if count == 0 { + if entries != nil { + b.free(unsafe.Pointer(entries)) + } + return out, nil + } + if entries == nil { + return nil, errors.New("WFP returned a nil filter array") + } + var invalid error + for _, f := range unsafe.Slice(entries, count) { + if f == nil || f.Provider == nil || *f.Provider != providerKey || f.Sublayer != sublayerKey || f.Layer != layer || !blobEqual(f.Data, marker) { + invalid = errors.New("refusing foreign filter in Tenebra provider") + break + } + var weight uint64 + if f.Weight.Type == 4 && f.Weight.Value != 0 { + weight = *(*uint64)(f.Weight.pointer()) + } + out = append(out, filterInfo{f.Key, f.Layer, f.Flags, f.Action.Type, f.Count, weight}) + } + b.free(unsafe.Pointer(entries)) + if invalid != nil { + return nil, invalid + } + } + return nil, errors.New("too many owned WFP filters; explicit recovery required") +} + +func completeBlocks(filters []filterInfo) bool { + seen := map[windows.GUID]bool{} + for _, r := range Policy(0) { + if len(r.Conditions) != 0 { + continue + } + key := filterKey(r.Key) + for _, f := range filters { + if f.key == key && f.layer == layerKeys[r.Layer] && f.flags&1 != 0 && f.flags&0x22 == 0 && f.action == blockAction && f.count == 0 && f.weight == 1 { + seen[key] = true + } + } + } + return len(seen) == 4 +} + +func (b *windowsBackend) Inspect() (present, complete bool, err error) { + err = b.session(func(h uintptr) error { + owned, e := b.ownedState(h) + if e != nil { + return e + } + present = owned.provider || owned.sublayer + if !owned.provider { + return nil + } + filters, e := b.filters(h) + if e != nil { + return e + } + complete = owned.sublayer && owned.enforcing && completeBlocks(filters) + return nil + }) + return +} + +func (b *windowsBackend) Replace(luid uint64) error { + identities, err := b.identities() + if err != nil { + return err + } + // Explicit system/admin-only object ACL; no ordinary user can widen or remove + // a persistent exception. Independent firewalls retain their own policies. + sd, err := windows.SecurityDescriptorFromString("O:SYG:SYD:P(A;;GA;;;SY)(A;;GA;;;BA)") + if err != nil { + return err + } + return b.session(func(h uintptr) error { + return transaction(b.call, h, func() error { + // Recreate the foundation as well as filters in the SAME transaction. + // This repairs a legitimate provider disabled by BFE; disabled is an + // output-only flag and cannot be cleared by changing an Add argument. + if err := b.removeOwned(h); err != nil { + return err + } + data := []byte(marker) + provider := wfpProvider{Key: providerKey, Display: display("Tenebra persistent host protection"), Flags: 1, Data: makeBlob(data)} + if err := b.call("FwpmProviderAdd0", num(h), ptr(&provider), ptr(sd)); err != nil { + return err + } + sub := wfpSublayer{Key: sublayerKey, Display: display("Tenebra host protection"), Flags: 1, Provider: &providerKey, Data: makeBlob(data), Weight: 0xffff} + if err := b.call("FwpmSubLayerAdd0", num(h), ptr(&sub), ptr(sd)); err != nil { + return err + } + for _, r := range Policy(luid) { + if err := b.addFilter(h, r, identities, sd); err != nil { + return err + } + } + runtime.KeepAlive(data) + return nil + }) + }) +} + +func (b *windowsBackend) Remove() error { + return b.session(func(h uintptr) error { + return transaction(b.call, h, func() error { return b.removeOwned(h) }) + }) +} + +func (b *windowsBackend) removeOwned(h uintptr) error { + p, s, err := b.owned(h) + if err != nil { + return err + } + if !p && !s { + return nil + } + old, err := b.filters(h) + if err != nil { + return err + } + for _, f := range old { + key := f.key + if err := b.call("FwpmFilterDeleteByKey0", num(h), ptr(&key)); err != nil { + return err + } + } + if s { + if err := b.call("FwpmSubLayerDeleteByKey0", num(h), ptr(&sublayerKey)); err != nil { + return err + } + } + if p { + return b.call("FwpmProviderDeleteByKey0", num(h), ptr(&providerKey)) + } + return nil +} + +type appIdentity struct { + app []byte + sd []byte // self-relative descriptor, wrapped in FWP_BYTE_BLOB for conditions +} + +func (b *windowsBackend) addFilter(h uintptr, r Rule, identities map[string]appIdentity, sd *windows.SECURITY_DESCRIPTOR) error { + data := []byte(marker) + weight := new(uint64) + *weight = r.Weight + f := wfpFilter{Key: filterKey(r.Key), Display: display("Tenebra " + r.Key), Flags: 1, Provider: &providerKey, Data: makeBlob(data), Layer: layerKeys[r.Layer], Sublayer: sublayerKey, Weight: wfpValue{Type: 4, Value: uintptr(unsafe.Pointer(weight))}, Action: wfpAction{Type: blockAction}} + if r.Permit { + f.Action.Type = permitAction + } // soft permit: no CLEAR_ACTION_RIGHT + var conditions []wfpCondition + roots := []any{weight, data, identities} + add := func(field windows.GUID, match, typ uint32, value uintptr) { + conditions = append(conditions, wfpCondition{field, match, wfpValue{typ, value}}) + } + for _, c := range r.Conditions { + switch c.Field { + case Loopback: + add(fieldFlags, 6, 3, 1) // FWP_MATCH_FLAGS_ALL_SET + case Interface: + v := new(uint64) + *v = c.Number + roots = append(roots, v) + // Reauthorization uses the ORIGINAL flow layer in both packet + // directions. An inbound-established flow also needs its outgoing + // reply path constrained; local interface alone can be stale. + add(fieldNextHop, 0, 4, uintptr(unsafe.Pointer(v))) + if !r.Layer.Outbound() { + add(fieldLocalInterface, 0, 4, uintptr(unsafe.Pointer(v))) + } + case Application: + id, ok := identities[c.Text] + if !ok || len(id.app) == 0 || len(id.sd) == 0 { + return errors.New("missing trusted WFP app identity") + } + blob := &byteBlob{Size: uint32(len(id.app)), Data: &id.app[0]} + sdBlob := &byteBlob{Size: uint32(len(id.sd)), Data: &id.sd[0]} + roots = append(roots, blob, sdBlob) + add(fieldApp, 0, 12, uintptr(unsafe.Pointer(blob))) + add(fieldUser, 0, 14, uintptr(unsafe.Pointer(sdBlob))) + case Protocol: + add(fieldProtocol, 0, 1, uintptr(c.Number)) + case LocalPort: + add(fieldLocalPort, 0, 2, uintptr(c.Number)) + case RemotePort: + add(fieldRemotePort, 0, 2, uintptr(c.Number)) + case RemoteAddress: + prefix, err := netip.ParsePrefix(c.Text) + if err != nil || !prefix.Addr().Is6() { + return errors.New("invalid NDP prefix") + } + mask := &v6Mask{prefix.Addr().As16(), uint8(prefix.Bits())} + roots = append(roots, mask) + add(fieldRemoteAddress, 0, 257, uintptr(unsafe.Pointer(mask))) + default: + return errors.New("unsupported WFP condition") + } + } + if len(conditions) > 0 { + f.Conditions = &conditions[0] + f.Count = uint32(len(conditions)) + } + var id uint64 + err := b.call("FwpmFilterAdd0", num(h), ptr(&f), ptr(sd), ptr(&id)) + runtime.KeepAlive(roots) + return err +} diff --git a/core/routing/audit_regression_test.go b/core/routing/audit_regression_test.go new file mode 100644 index 00000000..bf1ecd6d --- /dev/null +++ b/core/routing/audit_regression_test.go @@ -0,0 +1,69 @@ +package routing + +import "testing" + +func TestSplitOffGamesPresetIgnoresSavedCustomApps(t *testing.T) { + for _, mode := range []SplitMode{SplitOff, SplitExclude, SplitInclude} { + t.Run(string(mode), func(t *testing.T) { + o := (Options{Mode: ModeGlobal, SplitMode: mode, SplitApps: []string{"chrome.exe"}, GamesDirect: true}).Normalize() + for _, layer := range []struct { + name, target string + rules []map[string]any + }{ + {"route", "outbound", o.RouteRules()}, {"dns", "server", o.dnsRules()}, + } { + custom, game := "", "" + for _, rule := range layer.rules { + apps, _ := rule["process_name"].([]string) + if contains(apps, "chrome.exe") { + custom, _ = rule[layer.target].(string) + } + if contains(apps, "steam.exe") { + game, _ = rule[layer.target].(string) + } + } + direct, proxy := tagDirect, tagProxy + if layer.name == "dns" { + direct, proxy = dnsDirectTag, dnsRemoteTag + } + wantCustom, wantGame := "", direct + if mode == SplitExclude { + wantCustom = direct + } + if mode == SplitInclude { + wantCustom, wantGame = proxy, "" + } + if custom != wantCustom || game != wantGame { + t.Errorf("%s: custom=%q game=%q; want %q/%q", layer.name, custom, game, wantCustom, wantGame) + } + } + }) + } +} + +func TestBypassKeepsProxyPinsWhenDirectIsForbidden(t *testing.T) { + for _, kill := range []bool{false, true} { + o := (Options{Mode: ModeSmart, KillSwitch: kill, ZapretActive: true, UnblockServices: true}).Normalize() + for _, layer := range []struct { + name, target, direct, proxy string + rules []map[string]any + }{ + {"route", "outbound", tagDirect, tagProxy, o.RouteRules()}, + {"dns", "server", dnsDirectTag, dnsRemoteTag, o.dnsRules()}, + } { + var got []string + for _, rule := range layer.rules { + if contains(suffixesOf(rule), "googlevideo.com") { + got = append(got, rule[layer.target].(string)) + } + } + want := layer.direct + if kill { + want = layer.proxy + } + if len(got) != 1 || got[0] != want { + t.Errorf("kill=%v %s: googlevideo targets=%v, want [%s]", kill, layer.name, got, want) + } + } + } +} diff --git a/core/routing/presets.go b/core/routing/presets.go index 3fcb488c..1d04e2f3 100644 --- a/core/routing/presets.go +++ b/core/routing/presets.go @@ -230,10 +230,9 @@ func (o Options) proxySuffixesWithPresets() []string { } merged := make([]string, 0, len(base)+len(blockedServiceSuffixes)) merged = append(merged, base...) - if o.ZapretActive { - covered := o.coverage() + if direct := o.zapretDirectSuffixes(); len(direct) > 0 { for _, s := range blockedServiceSuffixes { - if !coveredByZapret(covered, s) { + if !coveredByZapret(direct, s) { merged = append(merged, s) } } @@ -285,7 +284,7 @@ func (o Options) directSplitApps() []string { if !o.gamesDirectActive() { return nil } - return o.splitAppsWithPresets() + return normalizeApps(gameProcesses) } } diff --git a/core/routing/routing.go b/core/routing/routing.go index 8f99b9b2..c9d9f080 100644 --- a/core/routing/routing.go +++ b/core/routing/routing.go @@ -307,10 +307,8 @@ type Options struct { // at the exit. MultihopEntry and MultihopExit are the builder outbound tags // (what singbox.sanitizeTag assigns) of the two chosen nodes, already resolved // from the user's stable server-ID selection by the control layer — the builder - // works only in tags. Multihop is inert unless both tags are set, distinct, and - // resolve to regular built outbounds; the builder then falls back to the normal - // selector, so a stale or unresolvable selection degrades to a single hop rather - // than a broken config. + // works only in tags. An enabled chain requires distinct tags resolving to + // regular built outbounds; the builder rejects stale or unsupported selections. Multihop bool MultihopEntry string MultihopExit string diff --git a/core/singbox/builder.go b/core/singbox/builder.go index 8a83f3a7..4acb6c47 100644 --- a/core/singbox/builder.go +++ b/core/singbox/builder.go @@ -241,23 +241,19 @@ func Build(nodes []model.Node, selectedTag string, ro routing.Options, tun TunOp // Multihop rewires the topology into a two-hop chain: the exit outbound gets a // detour through the entry outbound, and the selector collapses to the exit so // the route final (still proxyTag) egresses via exit -> entry -> internet. It - // engages only when both endpoints resolve to distinct regular outbounds this - // config actually built — an AmneziaWG endpoint or a dropped/invalid node leaves - // its tag out of outs — so a stale or unsupported selection degrades to the - // normal selector rather than emitting a dangling detour, which sing-box accepts - // at check time and then silently misroutes. sing-box's detour is a plain + // requires distinct regular outbounds this config actually built. An enabled + // chain that cannot be built is an error, never permission to use one hop. + // sing-box's detour is a plain // top-level outbound field naming the tag to dial through (verified against the // bundled 1.13 schema). - if ro.Multihop && ro.MultihopEntry != "" && ro.MultihopExit != "" && ro.MultihopEntry != ro.MultihopExit { - _, entryOK := outboundByTag(outs, ro.MultihopEntry) - exitObj, exitOK := outboundByTag(outs, ro.MultihopExit) - if entryOK && exitOK { - // The entry outbound needs no change: it is dialed as an ordinary - // outbound and only referenced by the exit's detour. - exitObj["detour"] = ro.MultihopEntry - selOutbounds = []string{ro.MultihopExit} - def = ro.MultihopExit + if ro.Multihop { + if err := validateMultihopOutbounds(outs, ro.MultihopEntry, ro.MultihopExit); err != nil { + return nil, err } + exitObj, _ := outboundByTag(outs, ro.MultihopExit) + exitObj["detour"] = ro.MultihopEntry + selOutbounds = []string{ro.MultihopExit} + def = ro.MultihopExit } // Shared outbounds: the selector over the eligible nodes, plus direct/block. diff --git a/core/singbox/multihop.go b/core/singbox/multihop.go new file mode 100644 index 00000000..db2edfb5 --- /dev/null +++ b/core/singbox/multihop.go @@ -0,0 +1,33 @@ +package singbox + +import ( + "fmt" + + "github.com/Divaaaan/tenebra/core/model" +) + +// ValidateMultihop checks the same rendered outbound capabilities as Build. +// Controllers use it before accepting settings or replacing a working tunnel. +func ValidateMultihop(nodes []model.Node, entryTag, exitTag string) error { + outs, _, _, err := buildNodes(nodes) + if err != nil { + return err + } + return validateMultihopOutbounds(outs, entryTag, exitTag) +} + +func validateMultihopOutbounds(outs []map[string]any, entryTag, exitTag string) error { + if entryTag == "" || exitTag == "" { + return fmt.Errorf("multihop: entry and exit nodes are required") + } + if entryTag == exitTag { + return fmt.Errorf("multihop: entry and exit nodes must differ") + } + if _, ok := outboundByTag(outs, entryTag); !ok { + return fmt.Errorf("multihop: entry node is missing or does not support chaining") + } + if _, ok := outboundByTag(outs, exitTag); !ok { + return fmt.Errorf("multihop: exit node is missing or does not support chaining") + } + return nil +} diff --git a/core/singbox/multihop_test.go b/core/singbox/multihop_test.go index a66cfb7a..da4c74ca 100644 --- a/core/singbox/multihop_test.go +++ b/core/singbox/multihop_test.go @@ -9,8 +9,7 @@ import ( // These tests cover the multihop chain the builder emits: the exit outbound gains // a detour through the entry outbound, the selector collapses to the exit so the -// route final egresses via exit -> entry, and — crucially — the whole thing degrades -// to the normal single-hop selector for any selection that can't form a real chain +// route final egresses via exit -> entry, and the build rejects any selection that can't form a real chain // (missing tag, equal endpoints, an AmneziaWG endpoint that isn't a regular // outbound), never a config carrying a dangling detour. TestMultihopPassesSingBoxCheck // validates the emitted shape against a real sing-box. @@ -81,10 +80,8 @@ func TestMultihopDefaultsOff(t *testing.T) { } } -// TestMultihopInertOnUnresolvableSelection: a selection the builder can't turn into -// a real two-hop chain must leave the normal single-hop selector untouched rather -// than emit a dangling detour (which sing-box accepts and then silently misroutes). -func TestMultihopInertOnUnresolvableSelection(t *testing.T) { +// An explicitly enabled two-hop chain must never degrade silently to one hop. +func TestMultihopRejectsUnresolvableSelection(t *testing.T) { cases := []struct { name string entry, exit string @@ -102,25 +99,17 @@ func TestMultihopInertOnUnresolvableSelection(t *testing.T) { MultihopEntry: c.entry, MultihopExit: c.exit, }, TunOptions{}) - if err != nil { - t.Fatalf("Build() error: %v", err) - } - for tag, o := range outboundsByTag(t, cfg) { - if _, ok := o["detour"]; ok { - t.Errorf("outbound %q carries a detour for an unresolvable multihop selection", tag) - } - } - if outs, _ := selectorOf(t, cfg)["outbounds"].([]string); len(outs) != 2 { - t.Errorf("selector narrowed to %d outbounds; an unresolvable selection must keep the full selector", len(outs)) + if err == nil || cfg != nil { + t.Error("invalid multihop must return an error and no config") } }) } } -// TestMultihopInertWhenEndpointIsWireGuard: an AmneziaWG node is emitted as a +// TestMultihopRejectsWireGuardEndpoint: an AmneziaWG node is emitted as a // top-level endpoint, not a regular outbound, so it can neither carry a detour nor -// be one. Selecting it as the exit leaves the config single-hop. -func TestMultihopInertWhenEndpointIsWireGuard(t *testing.T) { +// be one. Selecting it must fail rather than build a single-hop config. +func TestMultihopRejectsWireGuardEndpoint(t *testing.T) { nodes := []model.Node{ { Protocol: model.VLESS, Name: "vless-ws", Server: "ws.example.test", Port: 443, @@ -138,11 +127,8 @@ func TestMultihopInertWhenEndpointIsWireGuard(t *testing.T) { MultihopEntry: "vless-ws", MultihopExit: "awg", }, TunOptions{}) - if err != nil { - t.Fatalf("Build() error: %v", err) - } - if _, ok := outboundsByTag(t, cfg)["vless-ws"]["detour"]; ok { - t.Error("a WireGuard-endpoint exit must not chain: no detour should be set") + if err == nil || cfg != nil { + t.Error("WireGuard multihop must return an error and no config") } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 04214f5a..070cbd62 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -127,44 +127,57 @@ always serves the well-known name. (The unix transport is symmetric here instead: both ends honour `TENEBRA_SOCKET`.) The GUI dials with `SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION`, capping -impersonation at identification: an instance-squatter admitted by the DACL -(see below) could learn who the client is, but cannot act as it. +impersonation at identification. A server that receives a connection cannot +use that connection to impersonate the client with greater authority. ### Pipe security The pipe is created with the SDDL -`D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)`, admitting exactly three -identities: +`D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x120083;;;IU)`. Its transport DACL admits +three identities: - **SYSTEM** — the service itself; - **Administrators** — elevated processes; -- **INTERACTIVE** — any locally logged-in user. This is what lets the - unprivileged GUI drive the privileged service, and it is the same trust - decision Tailscale's LocalAPI pipe makes on Windows. +- **INTERACTIVE** — locally logged-in users can open a client connection. + The exact client mask grants read/write data, read attributes, read control + and synchronization. It excludes `FILE_CREATE_PIPE_INSTANCE`, security + modification and generic-write access. + +Transport access is followed by peer authentication. The service reads the +kernel-reported client PID and token. It admits its own account, the current +console user, or a fully elevated administrator. Administrative admission +requires enabled Administrators membership, elevation, High integrity or +above, and no token restrictions; a deny-only or filtered membership does not +qualify. This lets an installer elevated as another account reach the service +while the ordinary console user remains logged in. Failed peer identity +lookups are rejected; an ordinary non-console user is rejected as well. The honest limits of that model: -- the tunnel is machine-wide, and so is control over it: *any* interactive - local user — not just the one who started the GUI — can drive the tunnel, - see its state and events, and take the session over. On a genuinely - multi-user machine that is a real sharing of control, not an oversight. +- the tunnel is machine-wide; the current console user can control it and + inspect its state even if another user originally started it. Fully elevated + administrators already administer the service and are also admitted. - processes of the same user are not defended against each other; same-user malware already owns the session. -- remote (network-logon) callers never carry the INTERACTIVE SID, so reaching - the pipe remotely requires administrator credentials — a caller that already - administers the machine. +- the listener rejects remote pipe clients; the local interactive grant is + not remote network access. Driving the tunnel is where that trust stops. The commands that hand the daemon executable code need more than admission — see [Commands that need the daemon's own authority](#commands-that-need-the-daemons-own-authority). -The listener claims the name with `FILE_FLAG_FIRST_PIPE_INSTANCE`, so if -something else already holds it the service fails loudly at start instead of -silently sharing the name. That flag does not stop an *already-admitted* -identity from adding instances to the bound name later (on pipes, -`GENERIC_WRITE` implies `FILE_CREATE_PIPE_INSTANCE`) — which is another face -of the same trust statement: interactive users are trusted with this control -surface. +The listener claims the name exclusively for its first instance. A preexisting +pipe name makes service startup fail. The interactive ACE also prevents an +ordinary client from adding competing instances after startup: its mask does +not include `FILE_CREATE_PIPE_INSTANCE` (`0x4`), which generic write would grant. +The GUI requests the same minimal mask rather than `GENERIC_READ|GENERIC_WRITE`. + +Before sending IPC payload, the GUI additionally verifies the connected pipe's +server PID against the running LocalSystem own-process service, its registered +and actual executable paths, and a repeated PID/status check while retaining +the process handle. The service grants ordinary interactive users only the +process metadata-query right needed for this check; see +[Windows service authentication](windows-service-authentication.md). ### Unix-socket security @@ -176,11 +189,12 @@ accepted connection is authenticated from credentials the kernel attached to it, which the peer cannot forge or change after connecting: `LOCAL_PEERCRED` on macOS, `SO_PEERCRED` on Linux. -The policy those credentials feed is shared with Windows, which resolves the -caller's SID instead: a peer is admitted if it is the daemon's own account +The base policy those credentials feed is shared with Windows, which resolves +the caller's SID instead: a peer is admitted if it is the daemon's own account (root, so an elevated same-account helper is not locked out) or the user of the interactive session. That is narrower than the historical "any local user" the -pipe DACL still grants, and it is where the two platforms differ in what +pipe DACL grants at the transport layer. Windows additionally admits the fully +elevated administrators described above. The two Unix platforms differ in what "interactive session" means: - macOS reads the owner of `/dev/console`, which the window server chowns to diff --git a/docs/delivery-acceptance.md b/docs/delivery-acceptance.md new file mode 100644 index 00000000..4b9e622c --- /dev/null +++ b/docs/delivery-acceptance.md @@ -0,0 +1,131 @@ +# Delivery acceptance after the September audit fixes + +The code changes are not an installation or live-tunnel acceptance result. +Run the following acceptance only in disposable machines or the hosted release +runners; never point tests at a developer's well-known production pipe. + +## Windows + +The NSIS installer accepts only service-not-found (1060), already-exists (1073), +already-stopped (1062) and already-running (1056) in their respective steps. +Other failures stop the installer with a nonzero exit and repair instructions. +It waits for STOPPED before replacing binaries and for a RUNNING, authenticated +service returning the exact installed app version after startup. The installed +GUI's `--service-check` mode exits before Tauri initialization and sends only +`status`, using a 30-second overall handshake budget. It does not initialize +profiles, a sidecar, the updater, autostart or a window. + +The GUI matches the kernel pipe server PID to an SCM RUNNING/OWN_PROCESS service, +its LocalSystem account and strictly quoted registered image. It retains a +process handle and rechecks SCM state/PID before trusting the stream. This uses +read-only SCM queries and PROCESS_QUERY_LIMITED_INFORMATION, not TOKEN_QUERY +or administrator elevation. Interactive pipe rights are `0x120083` on both +sides; they exclude instance creation, owner changes and DACL writes. + +Acceptance matrix: first install; same-version repair; update retaining machine +profiles; intentionally slow service startup; denied registration/configuration; +failed startup; stale daemon version; silent updater failures; uninstall and +reinstall. Test both a standard account and an unelevated administrator account. +A fake pipe on a unique test name must fail the production identity check before +any profile/import payload is sent. Verify blocked overlapped reads/writes +cancel and complete without outstanding OVERLAPPED buffers. Native test names: +`overlapped_backpressure_write_is_cancelled_and_reaped` and +`overlapped_idle_read_is_cancelled_and_reaped`. + +GUI release builds never silently fall back to a sidecar/profile store. Debug +builds may opt in using `TENEBRA_PIPE=off`; custom debug pipe names remain a +development facility. Repair instructions preserve profiles and require a GUI +restart after repairing the service. + +Explicit uninstall (`UpdateMode <> 1`) stops the service first, then queries only +the fixed T05 provider `fcb43b44-9358-4cd7-a998-9e7f822d5248` and sublayer +`fcb43b45-9358-4cd7-a998-9e7f822d5248`. Both exact WFP NOT_FOUND results permit +legacy removal without executing an old core. Query failures are not absence. +If either object exists, the installed `tenebra-core.exe +--release-host-protection` must confirm removal and a second probe must find both +objects absent before the service registration or binaries are removed. The +core alone checks ownership (`tenebra/persistent-host-guard/v1`) and deletes its +objects. A missing, unsupported or failing core while policy exists aborts +uninstall and retains the binary for repair. Cleanup has a 20-second child-process +deadline; the containing PowerShell invocation has an NSIS 35-second timeout. +The read-only WFP probe itself uses synchronous local Windows API calls. + +The embedded cleanup wrapper executes only the exact installed core. It checks +all path ancestors for reparse points and administrator/SYSTEM/TrustedInstaller +ownership plus ACLs excluding unprivileged mutation, then holds the EXE open +against writes and replacement while running the fixed cleanup command. Unsafe +custom install locations require repair into an administrator-controlled path. +No installed or temporary PowerShell script is executed; the reviewed source is +embedded as constant chunks. Regenerate its include with +`node scripts/embed-uninstall-helper.mjs`; CI checks the source and embed agree. + +Ordinary update and repair in update mode preserve persistent protection. The +first upgrade uses the previous uninstaller's compiled hooks and installs these +new hooks for later removals. Rolling back to a pre-T05 core requires explicitly +disabling/releasing host protection with a T05-capable core first and confirming +its provider/sublayer are absent; an older uninstaller cannot know how to remove +new policy. VM acceptance must cover legacy with no policy, current owned policy, +missing/old core with policy, query failure, cleanup failure/timeout, unsafe EXE +locations, ordinary update/repair retention, and rollback preparation. + +## Release channels + +All platform jobs upload into one draft. Only `publish` may open the release, +after Windows, macOS, Linux and Arch complete. The gate requires every expected +uploaded, nonempty asset; all nine updater platform entries; the exact tag +version; same-release URLs; matching attached signatures; a public release and +public asset downloads. No new release is published from this audit workspace. + +`beta.json` moves to the `update-channels` branch. GitHub Contents API updates it +with the previous blob SHA, giving one atomic Git commit rather than deleting +and replacing a public release asset. A concurrent publisher retries at most +three times and compares SemVer on each read: an older release cannot replace a +newer pointer. Network/API errors preserve the previous committed manifest. + +Bootstrap is performed by the final publish job only, after the release is +public and complete. It creates `update-channels` from the release commit if +needed. Repository branch rules must permit the workflow's `contents: write` +token to create/update that branch. A denied bootstrap leaves the new release +public but the old beta pointer untouched and fails the job; repair permissions +and rerun that final job. The raw-content endpoint can cache the previous valid +manifest briefly; this affects freshness, not artifact completeness. + +New clients query the atomic beta endpoint, with stable as a network-failure +fallback. Older clients continue querying the old latest-release `beta.json`: +that legacy file is populated only inside new stable drafts, and is never +clobbered on a public stable release. Thus old clients receive future stable +versions, but new prereleases require upgrading to a client with the new endpoint. +No current public refs or manifests have been changed by the audit fixes. + +## Toolchain and platform boundaries + +`.go-version` pins Go 1.26.8 across CI, desktop release and Android jobs; Arch +selects the same exact toolchain instead of its rolling distribution compiler. +Go's [release history](https://go.dev/doc/devel/release#go1.26.8) records this +supported 1.26 patch as released on 2026-09-01. Other jobs prohibit automatic +Go toolchain switching. Desktop release builds inspect binary build metadata +without running the binary: exact toolchain, OS/architecture, source revision +and clean source tree; retained reports include binary SHA-256. macOS validates +both slices before lipo. These reports do not replace a vulnerability scan. + +- Windows Authenticode needs a signing certificate and trusted signing setup. + Updater Minisign verification does not provide Authenticode trust. +- macOS remains an unsigned/unnotarized app with a manually installed daemon; + app updates do not update that daemon. A Developer ID, notarization credentials, + packaged privileged-helper lifecycle and actual macOS tunnel/update acceptance + remain external/product prerequisites. +- Linux AppImage/deb still require the documented daemon setup; Arch installs + the packaged systemd unit. Test package upgrade, service restart and retained + profiles on supported distributions. +- Android requires the existing signing secrets (`ANDROID_KEYSTORE_B64` and the + configured alias/password secrets), a signed artifact, and device acceptance + for install/update/revoke/reconnect/Doze. No signing material is generated or + imported by these fixes; there is no new signed APK or parity claim. +- iOS remains a scaffold: Apple team/entitlements, Network Extension provisioning, + framework/Xcode build and real-device validation are prerequisites. These + delivery changes do not turn the scaffold into a supported product. + +API references: [Windows pipe access](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights), +[CancelIoEx completion lifetime](https://learn.microsoft.com/en-us/windows/win32/api/ioapiset/nf-ioapiset-cancelioex), +[token object access checks](https://learn.microsoft.com/en-us/windows/win32/secauthz/access-rights-for-access-token-objects), +[GitHub file updates](https://docs.github.com/en/rest/repos/contents#create-or-update-file-contents). diff --git a/docs/development.md b/docs/development.md index a67deee2..a1af5dc3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -14,7 +14,7 @@ wire format, [control-protocol.md](control-protocol.md). This document is the | [Rust](https://rustup.rs/) (stable) | latest stable | the Tauri desktop shell | | PowerShell | Windows built-in / [PS 7+](https://github.com/PowerShell/PowerShell) | `scripts/fetch-resources.ps1` | -CI builds the core on Go 1.26 and the desktop bundle with Node 24, so those are +CI builds the core on the exact Go patch in `.go-version` (currently 1.26.8) and the desktop bundle with Node 24, so those are known-good; the minimums above are what `go.mod` and the front end actually require. The desktop app builds for **Windows, macOS and Linux** (this guide is written from the Windows side; the platform-specific parts are in @@ -435,3 +435,12 @@ For contributors deciding where to dig in, the honest open items: See [CONTRIBUTING.md](../CONTRIBUTING.md) for how to pick something up and propose a change. + + +### Windows service development and delivery + +Release builds use the authenticated per-machine service exclusively. For a +standalone development core, set `TENEBRA_PIPE=off` with a debug build. A missing +service now leaves the GUI unavailable with repair instructions; it never opens +a different profile store. Installer and beta channel acceptance are documented +in [delivery acceptance](delivery-acceptance.md). diff --git a/docs/host-protection-acceptance.md b/docs/host-protection-acceptance.md new file mode 100644 index 00000000..ec301c3e --- /dev/null +++ b/docs/host-protection-acceptance.md @@ -0,0 +1,175 @@ +# Windows host protection: candidate contract and acceptance + +Status at the 2026-09-11 audit candidate: **implemented in source; native packet, +installer and reboot acceptance remains pending**. This document describes the +T05 candidate integrated at `7aff666`. It does not change the evidence for the +previous v0.5.11 release or imply that a published installer contains this guard. +Passing unit tests, a successful build or a green hosted CI run is not proof of +traffic blocking. Release approval requires the native gates below against the +exact candidate binaries. + +**INC-01 remains open: cause unknown.** The reported Windows connection failure +has not been causally reproduced or explained by these fixes. Neither the guard +implementation nor a passing unrelated subscription proves that incident fixed. + +## Implemented contract + +The Windows amd64/arm64 backend installs persistent WFP objects in four ALE +authorization layers: connect and receive/accept, each for IPv4 and IPv6. Once +applied, its intended contract is to block ordinary host application traffic +outside verified allowed paths even if the engine or Tenebra service dies. +Ordinary service stop, daemon Close, update and exhausted engine retries retain +the policy. The engine supervisor assigns its suspended child to a job before +resuming it, so service death is intended to terminate the engine as well. + +Allowed paths are genuine loopback, the exact verified TUN interface, trusted +core/engine executable identities plus execution SID, service-scoped DHCP and +minimal IPv6 NDP. Physical plaintext DNS is blocked above the application +exceptions. Core bootstrap uses certificate-verified DoH/DoT to an explicit +literal-IP endpoint; invalid settings or resolver failure have no silent +plaintext/OS-DNS fallback. Saved resolver settings are not silently replaced. +Engine transport, encrypted bootstrap and configured DIRECT routes carried by +the engine are deliberate exceptions; application/domain/LAN split choices do +not become general host firewall permits. + +Before an engine replacement, the daemon applies lockdown without a TUN permit. +It adds the verified TUN only after the engine probe and publishes Active only +after all local gates, including system proxy, succeed. A replacement adapter +with the same name cannot inherit the old LUID permission. System-proxy mode +uses loopback plus engine egress and grants no TUN exception. + +`kill_switch` is the desired setting. `protection` is separate evidence: + +| Status | Meaning | +| --- | --- | +| `off` | No confirmed owned policy; the first idle ON arms the next connection and does not itself assert enforcement. | +| `applying` | An operation is pending; previous confirmed enforcement flags remain. | +| `blocked` | Confirmed policy without an accepted engine connection. | +| `active` | Confirmed policy plus an accepted engine connection. | +| `error` | Apply, inspection or cleanup failed; previous confirmed flags remain, and the error must be visible. | +| `unavailable` | No supported protection backend. | + +`enforced` and `persistent` describe last-confirmed policy, not an independent +packet measurement. Service loss cannot justify displaying a live Active +connection. Startup inspects existing policy before autoconnect and repairs it +to lockdown even when preferences say OFF, because cleanup may have been +interrupted. IPC remains available for explicit recovery if that operation fails. + +## Ownership and maintenance + +The provider is `fcb43b44-9358-4cd7-a998-9e7f822d5248`; the sublayer is +`fcb43b45-9358-4cd7-a998-9e7f822d5248`. Both use the marker +`tenebra/persistent-host-guard/v1`. Replacement and removal validate ownership +and relationships and commit one transaction. Disabled owned objects remain +removable; they are not enforcement evidence. Foreign metadata or unresolved +references cause an error, not a broad firewall reset. + +- **OFF / explicit Disconnect:** request owned cleanup and require confirmation. + Saving OFF, closing the window, or stopping the service is insufficient. + Failed cleanup stays visible and can be retried even if the saved preference + already says OFF. A separate proxy-restore failure must also remain visible. +- **Update / same-version repair:** preserve the guard while the checked service + stop and coordinated GUI/core replacement run. The replacement core recovers + policy before autoconnect. Do not remove the guard as an update workaround. +- **Explicit uninstall:** after confirmed service stop, the installer probes both + fixed WFP GUIDs. Confirmed absence allows legacy/pre-T05 uninstall without an + unsupported CLI call. If either exists, the installed, trusted T05-capable + `tenebra-core.exe --release-host-protection` must exit successfully and a + second probe must confirm both absent before service/files are deleted. + Missing/unsupported core, foreign ownership, uncertainty, timeout or cleanup + failure aborts uninstall and preserves the recovery binary. Probe errors are + not absence. Ordinary update mode never invokes this remover. +- **Rollback to pre-T05:** explicitly release and confirm owned cleanup with a + T05-capable core before replacing it with an older binary. Keep a compatible + remover in a protected installation until that succeeds. An old core cannot + be expected to repair or remove the new policy. Never recover by deleting all + firewall rules, deleting unknown WFP objects, or running an arbitrary + user-writable executable elevated. + +The remover does not initialize a daemon, require an engine, or read profiles. +Its zero exit means owned cleanup succeeded or no owned policy existed. The +installer bounds the cleanup child to 20 seconds and its wrapper to 35 seconds; +the read-only WFP probes and general WFP RPCs remain synchronous without an +overall caller-enforced deadline. See [delivery acceptance](delivery-acceptance.md) +for the separate service/installer/proxy gates. + +## Boundaries that must stay explicit + +This contract covers ordinary host IPv4/IPv6 application flows. It does not +claim protection before BFE initializes, while BFE is deliberately unavailable, +for forwarded Hyper-V/WSL/container traffic, against administrator/kernel +adversaries, or against competing hard-permit/callout behavior. + +The implementation deliberately leaves the provider ServiceName unset, following +the SDK and [WFP object management](https://learn.microsoft.com/en-us/windows/win32/fwp/object-management). +The [provider reference](https://learn.microsoft.com/en-us/windows/win32/api/fwpmtypes/ns-fwpmtypes-fwpm_provider0) +has conflicting disabled-state wording. The implementation choice is settled; +post-BFE/reboot behavior still requires measured acceptance. Persistent filters +are not a separate boot-time packet policy. + +Established-flow safety depends on ALE reauthorization and actual interface +conditions. If the packet gates show an escape after commit, reject this +candidate; a separately reviewed packet/callout design is required before that +promise can be made. Do not turn the observed escape into an undocumented grace +period. [Microsoft ALE reauthorization](https://learn.microsoft.com/en-us/windows/win32/fwp/ale-re-authorization). + +## Isolated VM procedure + +These are acceptance instructions, not an executed result or authorization to +run on a workstation. **Do not inspect, change or interact with host Hiddify.** +All service, process, route, registry, firewall and adapter mutations belong only +in the disposable guest. A packet observer must see the guest's isolated uplink +independently of the application; an in-app IP check alone is insufficient. + +Use one Windows 11 VM, 4 GiB fixed RAM, two vCPUs, host CPU Maximum=50%, no +parallel builds and at most 15 minutes per acceptance phase. Provisioning/OOBE +is a separate phase. Use prebuilt artifacts; record their commit, versions and +SHA-256, guest Windows build, VM ID and guest BIOS UUID. The two IDs are distinct. +Before any native harness action require the expected hypervisor identity, +an affirmative invocation flag, and the provisioned guest marker +`C:\ProgramData\TenebraAcceptance\isolated-vm.json` with matching `schema=1`, +`vmId`, `guestUuid`, `vmName`, `runNonce`, `disposable=true`, and +`allowNativeAcceptance=true`. Guest checks must reject a workstation or ambiguous +identity. The host separately attests the allocation and CPU cap. + +Take a clean checkpoint. Inventory owned WFP objects and unrelated firewall +policy; retain the trusted remover and console access. Establish working IPv4 +and IPv6 external test endpoints before the run; lack of an IPv6 route means +the IPv6 gate is untested, not passed. Capture both families and UDP/TCP port 53 +at the independent uplink. Tag test payloads and timestamp policy commit, +process/service exit, BFE readiness and GUI state. Correlate payload sequence +numbers generated after commit; label any pre-commit in-flight packets separately +instead of inventing a post-commit grace period. Bound probes to 10 packets/s, +64 KiB responses and hard deadlines. Use fresh plus preexisting outbound TCP, +UDP and QUIC flows and independently initiated inbound-accepted flows. + +## Required packet and lifecycle gates + +Every row is **pending native acceptance**. Record the exact triggering event, +packet capture interval, state/ownership evidence, expected outcome and result. + +| Gate | Trigger and required observation | +| --- | --- | +| Initial lockdown | Start direct v4/v6 flows before ON, then connect to apply lockdown. From confirmed commit onward, their next payloads and new ordinary physical flows must not reach the uplink. Idle first ON alone is not a commit. | +| Accepted TUN | Verify the configured name/address/LUID, successful engine probe and local gates. Ordinary traffic must reach the remote endpoint through the tunnel; no ordinary direct physical payload. A failed local gate must never publish Active/Connected. | +| Inbound and reauthorization | Keep inbound-accepted and outbound flows alive across initial commit, reconnect and default-route/next-hop replacement. Replies and new packets must not escape to the physical path after the relevant policy change. | +| System proxy and DIRECT choices | Test mixed/system-proxy mode and app/domain/LAN DIRECT choices. Loopback clients may use the engine; an ordinary process attempting the same physical destination directly remains blocked. Record intended engine DIRECT traffic separately. | +| DNS and bootstrap | Attempt ordinary and trusted-core UDP/TCP port-53 DNS on the physical path; none may escape. Verify encrypted literal-IP bootstrap and successful hostname-server connection. Invalid/hostname/plaintext endpoints, bad certificates, redirects, outage and timeout must fail visibly without plaintext retry. | +| Engine / service death | Kill the engine, hard-kill the service, and separately request graceful service Stop. Record engine/job termination. Keep probes running: policy remains, no unintended physical payload, and UI loses live Active status. | +| Retry exhaustion / replacement | Trigger five immediate engine crashes and a normal reconnect. Lockdown persists after retry exhaustion and throughout replacement; only a newly verified accepted engine permits recovery. | +| TUN loss / route change | Remove the verified guest TUN, replace it with a same-name/different-LUID adapter, and introduce a new physical uplink/default route. No substitute gains the TUN permit; no direct escape occurs even before the watcher reacts. Failed engine Stop remains visible. | +| DHCP / NDP / resume | Renew v4/v6 leases, exercise IPv6 neighbor/router discovery, sleep/resume and change the guest uplink. Required configuration traffic works; arbitrary LAN payload and non-permitted ICMP/data stay blocked. Reconnect revalidates the TUN. | +| Update / repair gap | Run candidate upgrade and same-version repair with policy present. Capture the full checked-stop/replacement/start gap; policy persists and old executable exceptions are replaced on successful recovery. A failed update must not silently release protection. | +| BFE restart without Tenebra | With persistent policy installed and Tenebra kept stopped in the guest, restart BFE. Record the BFE-down interval separately as outside scope. After BFE is ready and before Tenebra restarts, confirm non-disabled persistent objects and zero unintended physical payload. Only then test core recovery. | +| Reboot without Tenebra | Keep Tenebra from autostarting for this guest-only case, retain policy and reboot. Capture before boot through BFE readiness and subsequent probes. Separate pre-BFE traffic from the gate: once BFE loads policy, blocking must work before any Tenebra process starts. Then start Tenebra and verify lockdown/reconnect recovery. | +| Failed apply / commit | Inject a controlled apply or commit failure. Compare owned inventory and packets: previous committed policy remains, no partial permit set or transient direct payload, no false Active. Include disabled owned provider/filter recovery and refusal of foreign ownership. | +| Identity rejection | Use guest fixtures with a user-writable executable path, reparse traversal, wrong execution identity or colliding TUN name. Protection must reject uncertain identity without widening old policy. Restore the checkpoint after malicious fixtures. | +| OFF / Disconnect / retry | Explicitly release and verify both owned GUIDs and filters absent, intended direct connectivity restored, and unrelated firewall inventory unchanged. Inject cleanup failure: preserve owned policy/error and retry successfully even with preference already OFF. Test proxy-restore errors separately. | +| Uninstall / legacy / rollback | Cover successful T05 cleanup, retained binary on failed cleanup, second-probe failure, legacy absence, and legacy/missing remover with objects present. Upgrade never clears policy. A pre-T05 rollback occurs only after confirmed cleanup with the compatible remover. | + +Pass requires zero unintended physical payload and plaintext DNS in the covered +blocked intervals for both IP families, measured tunnel recovery when Active, +and unchanged unrelated firewall inventory after release. Missing captures, +untested address families, UI-only evidence, or inability to exercise BFE/reboot +leave the corresponding gate open. Revert the disposable checkpoint on failure; +never perform workstation cleanup as a substitute. diff --git a/docs/releases/0.6.0.md b/docs/releases/0.6.0.md new file mode 100644 index 00000000..7c65cef9 --- /dev/null +++ b/docs/releases/0.6.0.md @@ -0,0 +1,53 @@ +# Tenebra 0.6.0 + +This desktop release revises both interface modes and Windows service, +installation and connection handling. + +- Simple mode brings subscription setup, server selection and connection + feedback into one flow. Full mode has a revised connection panel, searchable + servers and consistent settings, profiles, import and diagnostic screens. +- Connection errors distinguish local service, engine and proxy failures from + server failures. A failed TCP probe no longer prevents selecting a server or + claims that its VPN protocol cannot connect. +- Windows service communication verifies the installed service identity, + supports the ordinary signed-in user and elevated administrators, and bounds + requests when the service stops responding. +- System proxy handling tracks the owning Windows user and keeps the settings + required for restoration. Disconnect and cleanup errors remain visible. +- The Windows installer loads the service-management assembly before waiting + for the service to stop, fixing the false stop-state failure in a fresh + PowerShell process. +- Routing and reconnect changes preserve split settings, reject unusable + multihop chains and limit repeated engine failures. +- Keyboard focus, compact layouts, themes and Russian/English text have been + revised across both modes. + +## Windows protection + +When enabled and applied by a connection, persistent Windows protection keeps +blocking if the VPN engine or Tenebra service exits. Explicit Disconnect, +disabling protection with confirmed cleanup, or a successful uninstall releases +Tenebra's policy. Configured DIRECT exceptions use the running engine. + +Protection covers ordinary Windows application IPv4/IPv6 traffic after Windows +Filtering Platform loads its policy. It does not cover the interval before BFE +starts, deliberately disabled BFE, or forwarded VM/container traffic. The saved +preference, last confirmed protection state and errors are shown separately. + +## Packages and verification + +Desktop packages cover Windows, macOS, Linux and Arch Linux. Windows installs +the privileged service. macOS requires the documented manual daemon setup and +is not notarized. Linux package and AppImage setup requirements are documented +in the README. Updater signatures use the project's minisign key; they are not +Windows Authenticode certificates. Android and iOS are outside this release. + +Publication requires recorded Windows installation, service, ordinary-user UI, +tunnel and protection acceptance of these exact signed artifacts. The attached +`candidate.json` records source and file hashes; `acceptance.json` identifies +the reviewed native evidence. CI build reports alone do not establish native +runtime testing on every platform. + +The original report that all servers appeared unavailable on one Windows PC +has not been causally reproduced. Successful controlled-endpoint tests do not +establish that every subscription or network environment has been fixed. diff --git a/docs/signed-desktop-candidates.md b/docs/signed-desktop-candidates.md new file mode 100644 index 00000000..5396f97b --- /dev/null +++ b/docs/signed-desktop-candidates.md @@ -0,0 +1,165 @@ +# Test signed desktop bytes before reserving a stable version + +The existing tag-triggered release workflow and `TENEBRA_RELEASE_HOLD` remain +available. The manual **Signed desktop candidate** workflow provides a second +path: build signed files without a tag or release, test those exact files, then +promote the same bytes. A failed native test can be fixed in a new commit and a +new preparation run without moving a tag or consuming `v0.6.0`. + +## Launch requirements + +The new workflow must first be reviewed and merged into the repository's default +branch (`main`). GitHub will not offer a new `workflow_dispatch` workflow that +exists only in this feature branch. Dispatch with `--ref main`; `source_sha` +must equal the full main commit selected by that dispatch. This check applies +to both preparation and promotion. If main changes after preparation, prepare +and accept a new candidate. No premature stable tag is a workaround. + +The existing `TAURI_SIGNING_PRIVATE_KEY` and +`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` secrets are used only by preparation's +signing steps. Preparation has `contents: read`; promotion has `contents: write` +and `actions: read`, with no signing key. The two workflows share the +`tenebra-release` concurrency group. Neither workflow is started merely by +pushing this feature branch. + +The signatures here are Tauri/minisign updater signatures. They do not provide +Windows Authenticode trust or macOS notarization. Existing platform setup +limitations still apply. + +## Prepare + +From the reviewed, exact main checkout (PowerShell example): + +```powershell +$source = git rev-parse HEAD +gh workflow run desktop-candidate.yml --ref main -f mode=prepare -f source_sha=$source +``` + +The workflow first runs the full existing CI suite, then builds Windows NSIS, +macOS universal DMG/app updater, Linux deb/AppImage and Arch pkgrel 1. All six +files receive signatures, including DMG and Arch as additional downloadable +signatures. The Arch candidate uses `TENEBRA_SOURCE_COMMIT=`; +ordinary `makepkg` retains its existing `v${pkgver}` source tag by default. + +Assembly cryptographically verifies both the artifact signature and minisign's +authenticated comment against the public key in the exact source commit. It +requires every platform file and five existing core build reports with the +pinned Go version, target, clean VCS state and exact revision. It creates one +`tenebra-signed-desktop--` Actions artifact, with 18 files and +`candidate.json`. Its summary reports the artifact ID and archive SHA256. Runs +that are missing a platform cannot produce the final artifact. + +`candidate.json` records source commit/tree, workflow/run/attempt, Go version, +every file's size/SHA256 and the final stable updater URLs. It is data, not an +acceptance claim. Build reports identify the recorded CI core builds; they do +not prove that every packaged executable was extracted and run. Native +acceptance must independently verify installed/extracted payload identity. + +Version-specific notes come from `git show HEAD:docs/releases/.md`. +Their bytes are part of the candidate and acceptance file list, are repeated +in the updater manifest, and become the release body with its ownership marker. +Inspection and promotion compare them to the same source commit. Missing notes +or later edits to the release body fail closed; no network-generated notes are +added during publication. + +Do not rerun only failed jobs across attempts: final assembly accepts the four +parts from the current attempt only. Rerun all jobs to produce a new candidate. +Do not rerun a preparation after its artifact has been accepted; a changed run +attempt invalidates the old acceptance. Artifacts expire after 30 days. + +## Download and accept + +An operator obtains the exact run ID, attempt, source SHA, artifact ID and +`sha256` digest from the completed GitHub run/API. Save these as a JSON request +with numeric `prepareRunId`, `prepareAttempt`, `artifactId`, and string +`sourceSha`, `artifactSha256`. No secret belongs in this request. + +The read-only inspector requires Node 24, `unzip`, a read-capable +`GITHUB_TOKEN`/`GH_TOKEN`, and a checkout at that exact source commit: + +```sh +node scripts/candidate-github.mjs inspect request.json fresh-candidate-directory +``` + +It checks the actual successful main dispatch, workflow path, run attempt, +artifact ownership, GitHub archive digest, downloaded archive digest, exact +commit/tree, all file hashes and all six cryptographic signatures. ZIP entries +are read into buffers and must be unique flat filenames. No application is +executed. It writes the files, `candidate.json` and `acquisition.json` to a new +directory; it refuses directory reuse. + +Perform the native install/service/ordinary UI/tunnel/protection checks on the +signed candidate, retaining private detailed evidence and extracted/installed +GUI/core/engine hashes. Neither an earlier unsigned CI run nor an equal Git +tree can stand in for those bytes: Go embeds the actual VCS revision, and the +bundles are rebuilt. + +After actual acceptance, create a separate JSON object with this contract: + +- `schema: 1`, `kind: "tenebra-desktop-acceptance"`, `state: "pass"`; +- exact `version`, `sourceSha`, `sourceTree`, `prepareRunId`, `prepareAttempt`; +- `artifactId`, archive `artifactSha256`, actual `manifestSha256` from acquisition; +- `files`: the complete ordered file/size/SHA256 list from `candidate.json`; +- `evidence`: redacted `{kind, sha256}` references, including a combined actual + `windows-install-service-ui-tunnel-protection` report. Additional platform + evidence can be referenced in the same array. + +This JSON is an explicit operator attestation, not a machine inference from a +green build. The promotion code validates its identity, hashes and required +native evidence category; the operator remains responsible for reviewing the +referenced evidence. Private logs, subscriptions and credentials must not be +included: this compact acceptance JSON becomes a public release asset. + +## Promote the same bytes + +Dispatch `desktop-candidate.yml` on the same current main SHA with +`mode=promote`, the exact `source_sha`, and the acceptance JSON supplied through +the `acceptance_json` input. Use the GitHub UI or `gh workflow run --json` +with a JSON inputs file so quoting preserves the acceptance text. Promotion +redownloads the exact accepted artifact and repeats provenance, signatures, +all file hashes and acceptance checks before any release mutation. + +Promotion also queries the actual current `main` ref and GitHub Latest release. +The ref must still match the accepted source. Latest must be an older stable +version, absent, or this same owned release. A newer, prerelease, malformed or +foreign equal-version Latest fails closed. These checks run again immediately +before the publication PATCH and before completing the channel update. Actions +reruns keep historical GITHUB_SHA values; those values alone are insufficient. +The shared release concurrency serializes these workflows; an unrelated manual +release mutation outside that lock is not an atomic GitHub compare-and-swap. + +It persists a draft ownership marker binding the candidate, artifact, source, +run and acceptance digests, then creates the immutable stable tag at that same +source SHA. No build or re-sign operation occurs. It uploads the accepted files +plus deterministic `latest.json`/`beta.json` and provenance/acceptance/intent +JSON. Each uploaded asset is read back and SHA-verified before publication. +Publication is followed by the existing public-asset/channel CAS checks. + +Only the workflow's `GITHUB_TOKEN` performs these mutations. GitHub suppresses +new workflows triggered by that token's tag/release events, so promotion does +not rebuild the tag or trigger Winget/Android delivery. No separate Winget +dispatch is part of this path. + +Interrupted promotion can be retried with exactly the same inputs. It resumes +only an owned draft/tag with matching intent and source; already uploaded bytes +are checked and skipped, and only missing assets are added. Mismatched assets, +unknown tags/releases or changed acceptance are never overwritten. A known, +already public complete release is verified without file mutation, then its +channel CAS can be completed if publication succeeded before an interruption. +An incomplete GitHub `starter` upload is retained and fails closed rather than +being deleted automatically. No force-push, retag or clobber path exists. +For a retained `starter` asset, first read the exact owned draft and retain its +intent and asset ID as evidence. A maintainer must separately approve removal +of that single incomplete asset, without touching the tag, notes or accepted +files. Then the same promotion inputs can upload the missing asset and resume. +The workflow does not perform that deletion or recover mismatched uploaded bytes. + +## Verification scope + +`node --test ".github/scripts/*.test.mjs" "scripts/*.test.mjs"` exercises actual +Ed25519/BLAKE2b signatures, malformed records, payload and provenance mutations, +strict archive/run/artifact identity, receipt substitution, Arch commit source +selection, file staging and simulated interruptions after draft/tag/upload/ +publication. Network mutation is behind the injected API boundary; tests do +not create a GitHub release or run produced binaries. The hosted signing and +promotion workflow still requires its first real run after source review. diff --git a/docs/windows-service-authentication.md b/docs/windows-service-authentication.md new file mode 100644 index 00000000..910035e1 --- /dev/null +++ b/docs/windows-service-authentication.md @@ -0,0 +1,36 @@ +# Windows service authentication + +The desktop opens the control pipe with the exact client mask `0x120083` +and identification-only impersonation. Before sending a request, Rust checks +the pipe server PID against the running, own-process LocalSystem Tenebra +service, reads the registered image and actual process image, and repeats +the PID/status check while retaining the process handle. + +On Windows, a LocalSystem process can inherit a DACL that denies ordinary +users even `PROCESS_QUERY_LIMITED_INFORMATION`. A pipe connection and SCM +queries can therefore succeed while the process-image authentication fails +with access denied. This was reproduced in a clean Windows guest. + +Before constructing the daemon, listening, or reporting Running, the service +adds one non-inheritable `INTERACTIVE` (`S-1-5-4`) grant of exactly `0x1000` +to its own process DACL. It preserves existing ACEs, owner, group, SACL and +protection flags; it verifies the complete resulting DACL. Read, merge, +write or readback failures abort startup. Null or invalid DACLs are rejected. +Existing deny entries remain authoritative and are never removed to bypass +a stricter policy. + +This permits process metadata queries, including the executable path. It +does not grant process memory access, handle duplication, termination, +suspension, injection, token access or ACL changes. The ACL exists only for +the current service process and is recreated on each start. It changes no +machine-wide policy and does not weaken the desktop's server checks. + +The unit tests manipulate in-memory security descriptors only. Native +acceptance must separately verify the installed service from the ordinary +console-user token and from an elevated installer token. Source checks and +an SCM Running state alone do not prove the connection works. + +References: Microsoft documents the +[process access rights](https://learn.microsoft.com/en-us/windows/win32/procthread/process-security-and-access-rights), +[ACL merge behavior](https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setentriesinaclw), +and [handle-based security updates](https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setsecurityinfo). diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index bd2fcadf..196fdb98 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -17,7 +17,7 @@ pkgname=tenebra # has to name a tag that exists — scripts/set-version.mjs rewrites this line along # with the desktop manifests and the Go core, which is what keeps the two in step. # Do not edit it by hand. -pkgver=0.5.11 +pkgver=0.6.0 pkgrel=1 pkgdesc="VPN client built on sing-box: a privileged core daemon and a desktop UI" arch=('x86_64') @@ -78,8 +78,15 @@ _geositecommit=02b7bc85184c7fa94ccdfe9a35b7f4a169b28b4d # blocks sing-box startup for ~10s). They are pinned to immutable commits rather # than the rolling `rule-set` branch, which is regenerated daily and would drift # out from under the checksums. +_source_fragment="tag=v${pkgver}" +# Signed candidate builds have no stable tag yet. Accept only an immutable +# full commit, retaining the historical tag path for normal makepkg/release. +if [[ -n ${TENEBRA_SOURCE_COMMIT:-} ]]; then + [[ $TENEBRA_SOURCE_COMMIT =~ ^[0-9a-f]{40}$ ]] || { echo 'Invalid source commit' >&2; exit 1; } + _source_fragment="commit=${TENEBRA_SOURCE_COMMIT}" +fi source=( - "git+${url}.git#tag=v${pkgver}" + "git+${url}.git#${_source_fragment}" "sing-box-${_singboxver}-linux-amd64.tar.gz::https://github.com/SagerNet/sing-box/releases/download/v${_singboxver}/sing-box-${_singboxver}-linux-amd64.tar.gz" "geoip-ru-${_geoipcommit}.srs::https://raw.githubusercontent.com/SagerNet/sing-geoip/${_geoipcommit}/geoip-ru.srs" "geosite-ru-${_geositecommit}.srs::https://raw.githubusercontent.com/SagerNet/sing-geosite/${_geositecommit}/geosite-category-ru.srs" @@ -98,6 +105,9 @@ sha256sums=( prepare() { cd "${pkgname}" + if [[ -n ${TENEBRA_SOURCE_COMMIT:-} ]]; then + [[ $(git rev-parse HEAD) == "$TENEBRA_SOURCE_COMMIT" ]] || { echo 'Source checkout differs' >&2; return 1; } + fi # Fetch every dependency up front so build() does no network I/O, and keep the # caches inside $srcdir instead of the packager's home. @@ -113,6 +123,10 @@ prepare() { build() { cd "${pkgname}" + # Use exactly the patch tested by CI; the system Go only bootstraps it. + export GOTOOLCHAIN="go$(cat .go-version)" + go version + export GOPATH="${srcdir}/gopath" # Arch's Go packaging template builds with cgo and an external linker, which @@ -126,6 +140,7 @@ build() { # go.mod mid-build, and -modcacherw leaves the module cache deletable. export GOFLAGS="-trimpath -mod=readonly -modcacherw -buildmode=pie" go build -o build/tenebra-core ./cmd/tenebra-core + node scripts/verify-core-build.mjs build/tenebra-core linux amd64 build/core-buildinfo.json # Tauri resolves an externalBin sidecar by target triple at build time, so the # core has to exist under that name even though the packaged app never spawns diff --git a/scripts/arch-candidate.test.mjs b/scripts/arch-candidate.test.mjs new file mode 100644 index 00000000..e71ee5eb --- /dev/null +++ b/scripts/arch-candidate.test.mjs @@ -0,0 +1,14 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +const bash=process.platform==='win32'?'C:/Program Files/Git/bin/bash.exe':'bash'; +function source(commit) { + const env={...process.env};delete env.TENEBRA_SOURCE_COMMIT; + if(commit!==undefined)env.TENEBRA_SOURCE_COMMIT=commit; + return execFileSync(bash,['--noprofile','--norc','-c','source packaging/arch/PKGBUILD\nprintf "%s\\n" "${source[0]}"'],{env,encoding:'utf8',stdio:['ignore','pipe','pipe'],timeout:10000}).trim(); +} +test('Arch keeps tagged releases by default and accepts only a full immutable candidate commit',()=>{ + assert.equal(source(),'git+https://github.com/Divaaaan/tenebra.git#tag=v0.6.0'); + assert.equal(source('a'.repeat(40)),'git+https://github.com/Divaaaan/tenebra.git#commit='+'a'.repeat(40)); + for(const bad of ['main','v0.6.0','abcdef1','A'.repeat(40),'a'.repeat(40)+';false'])assert.throws(()=>source(bad)); +}); diff --git a/scripts/attach-android-release.mjs b/scripts/attach-android-release.mjs new file mode 100644 index 00000000..f4ad9e33 --- /dev/null +++ b/scripts/attach-android-release.mjs @@ -0,0 +1,88 @@ +// Android owns only its APK asset. Release creation, visibility and updater +// channels belong to the desktop release lifecycle, even when Android finishes +// first or uploads after publication. +import { execFile } from 'node:child_process'; +import { lstatSync } from 'node:fs'; +import { basename } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; +import { setTimeout as sleepFor } from 'node:timers/promises'; +import { compareVersions } from './release-lifecycle.mjs'; + +function validateAsset(tag, assetPath) { + if (typeof tag !== 'string' || !tag.startsWith('v')) throw new Error('expected a v-prefixed release tag'); + compareVersions(tag.slice(1), tag.slice(1)); + if (typeof assetPath !== 'string' || basename(assetPath) !== `tenebra-${tag}.apk`) { + throw new Error('signed APK filename does not match release tag'); + } +} + +export async function attachAndroidRelease({ tag, assetPath, api, + timeoutMs = 30 * 60 * 1000, pollMs = 15000, now = Date.now, sleep = sleepFor }) { + validateAsset(tag, assetPath); + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 30 * 60 * 1000 || + !Number.isFinite(pollMs) || pollMs <= 0) throw new Error('invalid desktop release wait bounds'); + const deadline = now() + timeoutMs; + while (now() < deadline) { + const release = await api.findRelease(tag, Math.min(30000, deadline - now())); + if (release) { + if (!Number.isSafeInteger(release.id) || release.id <= 0 || release.tag_name !== tag) { + throw new Error('desktop release identity does not match requested tag'); + } + if (now() >= deadline) break; + await api.upload(tag, assetPath, 120000); + return { releaseId: release.id }; + } + const remaining = deadline - now(); + if (remaining > 0) await sleep(Math.min(pollMs, remaining)); + } + throw new Error('existing desktop release was not found before the deadline; APK was not attached'); +} + +const execFileAsync = promisify(execFile); +async function invokeGh(args, timeout) { + try { + const { stdout } = await execFileAsync('gh', args, { timeout, maxBuffer: 4 * 1024 * 1024, windowsHide: true }); + return stdout; + } catch { + throw new Error('GitHub APK asset operation failed or timed out'); + } +} + +export function githubAndroidAssetApi(repo, invoke = invokeGh) { + if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) throw new Error('invalid GitHub repository'); + return { + async findRelease(tag, timeout) { + // List includes authenticated drafts; the public by-tag endpoint may not. + // Only a new tag release is awaited, so retain a bounded recent page. + const releases = JSON.parse(await invoke(['api', `repos/${repo}/releases?per_page=100`, '--method', 'GET'], timeout)); + if (!Array.isArray(releases)) throw new Error('invalid GitHub releases response'); + const matching = releases.filter(release => release.tag_name === tag); + if (matching.length > 1) throw new Error('multiple releases use the requested tag'); + return matching[0] ?? null; + }, + async upload(tag, assetPath, timeout) { + validateAsset(tag, assetPath); + // No create/edit call, visibility flags or clobber: an existing asset + // collision fails instead of deleting an already delivered APK. + await invoke(['release', 'upload', tag, assetPath, '--repo', repo], timeout); + }, + }; +} + +async function main() { + const args = process.argv.slice(2); + if (args.length !== 2) throw new Error('usage: attach-android-release.mjs '); + const [tag, assetPath] = args; + validateAsset(tag, assetPath); + const asset = lstatSync(assetPath); + if (!asset.isFile() || asset.size === 0) throw new Error('signed APK must be a non-empty regular file'); + const api = githubAndroidAssetApi(process.env.GITHUB_REPOSITORY); + console.log(`Waiting up to 30 minutes for the existing desktop release ${tag}.`); + const result = await attachAndroidRelease({ tag, assetPath, api }); + console.log(`Attached ${basename(assetPath)} to release ${result.releaseId}; release visibility was not changed.`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch(error => { console.error(error.message); process.exitCode = 1; }); +} diff --git a/scripts/attach-android-release.test.mjs b/scripts/attach-android-release.test.mjs new file mode 100644 index 00000000..92073038 --- /dev/null +++ b/scripts/attach-android-release.test.mjs @@ -0,0 +1,74 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { attachAndroidRelease, githubAndroidAssetApi } from './attach-android-release.mjs'; + +const tag = 'v0.6.0'; +const assetPath = '/runner/tenebra-v0.6.0.apk'; + +test('APK attach waits for the existing desktop release and never mutates visibility', async () => { + for (const draft of [true, false]) { + let now = 0, reads = 0; + const uploads = []; + const release = { id: 123, tag_name: tag, draft }; + const result = await attachAndroidRelease({ tag, assetPath, now: () => now, sleep: async ms => { now += ms; }, + api: { findRelease: async () => ++reads < 3 ? null : release, upload: async (...args) => uploads.push(args) } }); + assert.equal(result.releaseId, 123); + assert.equal(reads, 3); + assert.deepEqual(uploads, [[tag, assetPath, 120000]]); + assert.equal(release.draft, draft); + } +}); + +test('missing desktop release has a deadline and cannot create one', async () => { + let now = 0, uploaded = false; + await assert.rejects(attachAndroidRelease({ tag, assetPath, timeoutMs: 30, pollMs: 10, + now: () => now, sleep: async ms => { now += ms; }, + api: { findRelease: async () => null, upload: async () => { uploaded = true; } }, + }), /desktop release.*deadline/); + assert.equal(now, 30); + assert.equal(uploaded, false); +}); + +test('wrong tag, APK filename or release identity fails before upload', async () => { + for (const invalid of [ + { tag: 'v0.6.0;evil' }, { tag: '0.6.0' }, + { assetPath: '/runner/tenebra-v0.5.11.apk' }, + { assetPath: '/runner/tenebra-v0.6.0.apk.sig' }, + { release: { id: 123, tag_name: 'v0.5.11', draft: true } }, + { release: { id: '123', tag_name: tag, draft: true } }, + ]) { + let uploaded = false; + await assert.rejects(attachAndroidRelease({ tag, assetPath, ...invalid, + api: { findRelease: async () => invalid.release ?? { id: 123, tag_name: tag }, upload: async () => { uploaded = true; } }, + })); + assert.equal(uploaded, false); + } +}); + +test('API failures and upload failures are not converted into success', async () => { + for (const failing of ['findRelease', 'upload']) { + const api = { findRelease: async () => ({ id: 123, tag_name: tag }), upload: async () => {} }; + api[failing] = async () => { throw new Error('injected failure'); }; + await assert.rejects(attachAndroidRelease({ tag, assetPath, api }), /injected failure/); + } +}); + +test('GitHub adapter can only list releases and upload the exact existing-tag asset', async () => { + const commands = []; + const api = githubAndroidAssetApi('owner/repo', async (args, timeout) => { + commands.push({ args, timeout }); + return args[0] === 'api' ? JSON.stringify([{ id: 123, tag_name: tag, draft: false }]) : ''; + }); + assert.equal((await api.findRelease(tag, 5000)).id, 123); + await api.upload(tag, assetPath, 120000); + assert.deepEqual(commands, [ + { args: ['api', 'repos/owner/repo/releases?per_page=100', '--method', 'GET'], timeout: 5000 }, + { args: ['release', 'upload', tag, assetPath, '--repo', 'owner/repo'], timeout: 120000 }, + ]); + assert.ok(commands.every(({ args }) => !args.some(arg => ['create', 'edit', 'delete', 'PATCH', '--draft', '--draft=false', '--clobber'].includes(arg)))); +}); + +test('ambiguous same-tag releases fail instead of choosing an arbitrary draft', async () => { + const api = githubAndroidAssetApi('owner/repo', async () => JSON.stringify([{ id: 1, tag_name: tag }, { id: 2, tag_name: tag }])); + await assert.rejects(api.findRelease(tag, 5000), /multiple releases/); +}); diff --git a/scripts/candidate-files.mjs b/scripts/candidate-files.mjs new file mode 100644 index 00000000..2a694211 --- /dev/null +++ b/scripts/candidate-files.mjs @@ -0,0 +1,81 @@ +// Hosted build assembly only; never runs any produced application binary. +import { readdirSync, lstatSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'node:fs'; +import { join, resolve, basename } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { assertPrepareIdentity, makeCandidate, jsonBytes, bundleNames } from './signed-candidate.mjs'; + +export function readFlatFiles(dir) { + const result=new Map(); + for(const name of readdirSync(dir)) { + if(!/^[A-Za-z0-9_.-]+$/.test(name) || name.startsWith('.')) throw Error('Unsafe candidate filename'); + const path=join(dir,name), stat=lstatSync(path); + if(!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > 1024**3) throw Error('Unsafe candidate file'); + result.set(name,readFileSync(path)); + } + return result; +} +function find(dir, name) { + const hits=[]; + function visit(path,depth) { + if(depth>8) throw Error('Bundle directory depth exceeded'); + for(const entry of readdirSync(path,{withFileTypes:true})) { + if(entry.isSymbolicLink()) continue; // app symlinks are not release assets. + const next=join(path,entry.name); + if(entry.isDirectory()) { if(!entry.name.endsWith('.app')) visit(next,depth+1); } + else if(entry.isFile() && entry.name===name) hits.push(next); + } + } + visit(dir,0); if(hits.length!==1) throw Error(`Expected one bundle ${name}, found ${hits.length}`); return hits[0]; +} +export function signFile(path, cliPath=resolve('ui-desktop/node_modules/@tauri-apps/cli/tauri.js')) { + try { execFileSync(process.execPath,[cliPath,'signer','sign',resolve(path)],{stdio:'pipe',timeout:120000,maxBuffer:1024*1024}); } + catch { throw Error(`Signing failed for ${basename(path)}`); } +} +export function collect(platform, version, output='candidate-part', operations={signFile}) { + const names=bundleNames(version), root='ui-desktop/src-tauri/target'; + const sources={ + windows:[[`${root}/release/bundle`,names[0],names[0]]], + macos:[[`${root}/universal-apple-darwin/release/bundle`,names[1],names[1]],[`${root}/universal-apple-darwin/release/bundle`,'Tenebra.app.tar.gz',names[2]]], + linux:[[`${root}/release/bundle`,names[3],names[3]],[`${root}/release/bundle`,names[4],names[4]]], + arch:[['packaging/arch',names[5],names[5]]], + }; + if(!Object.hasOwn(sources,platform)) throw Error('Unsupported platform'); + mkdirSync(output); // refuse reuse: same-name uploads must never clobber. + for(const [dir,raw,name] of sources[platform]) { + const source=platform==='arch'?join(dir,raw):find(dir,raw); + const stat=lstatSync(source);if(!stat.isFile()||stat.isSymbolicLink())throw Error('Expected a regular bundle'); + copyFileSync(source,join(output,name)); + // Sign the staged filenames, including deb/DMG/Arch. No key in argv/logs. + operations.signFile(join(output,name)); + } + const reports={windows:['windows'],macos:['macos-arm64','macos-amd64'],linux:['linux'],arch:['arch']}[platform]; + for(const id of reports) { + const path=id==='arch'?'packaging/arch/src/tenebra/build/core-buildinfo.json':`core-buildinfo-${id}.json`; + copyFileSync(path,join(output,`core-buildinfo-${id}.json`)); + } +} +function identity(sourceSha) { + assertPrepareIdentity({event:process.env.GITHUB_EVENT_NAME,ref:process.env.GITHUB_REF,sha:process.env.GITHUB_SHA,sourceSha,repo:process.env.GITHUB_REPOSITORY}); + if(execFileSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).trim()!==sourceSha) throw Error('Checkout differs from requested source'); +} +if(process.argv[1] && import.meta.url===pathToFileURL(process.argv[1]).href) { + try { + const [mode,arg,output]=process.argv.slice(2); + identity(process.env.SOURCE_SHA); + const config=JSON.parse(readFileSync('ui-desktop/src-tauri/tauri.conf.json')); + bundleNames(config.version); // refuse a prerelease version before signing. + if(mode==='identity') { /* validation already completed */ } + else if(mode==='collect') collect(arg,config.version,output); + else if(mode==='manifest') { + const files=readFlatFiles(arg); + if(files.has('release-notes.md'))throw Error('Release notes must come from the exact source commit'); + files.set('release-notes.md',execFileSync('git',['show',`HEAD:docs/releases/${config.version}.md`],{maxBuffer:65536})); + const candidate=makeCandidate(files,{repo:process.env.GITHUB_REPOSITORY,sourceSha:process.env.SOURCE_SHA,sourceTree:execFileSync('git',['rev-parse','HEAD^{tree}'],{encoding:'utf8'}).trim(),version:config.version,goVersion:readFileSync('.go-version','utf8').trim(),runId:Number(process.env.GITHUB_RUN_ID),runAttempt:Number(process.env.GITHUB_RUN_ATTEMPT),pubkey:config.plugins.updater.pubkey}); + mkdirSync(output); + for(const [name,bytes] of files) writeFileSync(join(output,basename(name)),bytes,{flag:'wx'}); + writeFileSync(join(output,'candidate.json'),jsonBytes(candidate),{flag:'wx'}); + console.log(`Verified signed desktop candidate ${candidate.sourceSha}; no tag, release or channel created.`); + } else throw Error('Usage: candidate-files.mjs identity | collect [dir] | manifest '); + } catch(error) { console.error(error.message);process.exitCode=1; } +} diff --git a/scripts/candidate-files.test.mjs b/scripts/candidate-files.test.mjs new file mode 100644 index 00000000..4c049b70 --- /dev/null +++ b/scripts/candidate-files.test.mjs @@ -0,0 +1,24 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, rmdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { collect, readFlatFiles } from './candidate-files.mjs'; +test('Arch collection reads its one package without traversing dependency caches or rebuilt source',()=>{ + const dir=mkdtempSync(join(tmpdir(),'candidate-test-')),old=process.cwd(); + try { + process.chdir(dir);mkdirSync('packaging/arch/src/tenebra/build',{recursive:true}); + mkdirSync('packaging/arch/src/cache/a/b/c/d/e/f/g/h/i',{recursive:true}); + writeFileSync('packaging/arch/tenebra-0.6.0-1-x86_64.pkg.tar.zst','package bytes'); + writeFileSync('packaging/arch/src/tenebra/build/core-buildinfo.json','report bytes'); + collect('arch','0.6.0','output',{signFile:path=>writeFileSync(path+'.sig','test signature')}); + assert.equal(readFileSync('output/tenebra-0.6.0-1-x86_64.pkg.tar.zst','utf8'),'package bytes'); + assert.equal(readFileSync('output/core-buildinfo-arch.json','utf8'),'report bytes'); + assert.equal(readFlatFiles('output').size,3); + assert.throws(()=>collect('arch','0.6.0','output',{signFile:()=>{}})); + } finally {process.chdir(old);rmSync(dir,{recursive:true});} +}); +test('flat candidate collection rejects a directory or dotfile instead of uploading extra content',()=>{ + const dir=mkdtempSync(join(tmpdir(),'candidate-test-')); + try {writeFileSync(join(dir,'safe'),'bytes');assert.equal(readFlatFiles(dir).get('safe').toString(),'bytes');mkdirSync(join(dir,'nested'));assert.throws(()=>readFlatFiles(dir));rmdirSync(join(dir,'nested'));writeFileSync(join(dir,'.secret'),'not allowed');assert.throws(()=>readFlatFiles(dir));} finally {rmSync(dir,{recursive:true});} +}); diff --git a/scripts/candidate-github.mjs b/scripts/candidate-github.mjs new file mode 100644 index 00000000..25b2ca44 --- /dev/null +++ b/scripts/candidate-github.mjs @@ -0,0 +1,125 @@ +// The only network/mutation adapter for manual signed candidates. Prepare has +// no contents:write; promotion uses only the Actions GITHUB_TOKEN. +import { readFileSync, writeFileSync, mkdtempSync, unlinkSync, rmdirSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { sha256, jsonBytes, verifyCandidate, promoteCandidate, assertPrepareIdentity, releaseNotes, assertStablePromotionState } from './signed-candidate.mjs'; +import { publishCompleteRelease } from './release-lifecycle.mjs'; +import { githubReleaseApi } from './release-api.mjs'; +const repo='Divaaaan/tenebra', base=`https://api.github.com/repos/${repo}`; +const positive=value=>Number.isSafeInteger(value)&&value>0; +function demand(ok,message){if(!ok)throw Error(message);} +export function verifyPrepareRun(run,a) { + demand(run.id===a.prepareRunId && run.run_attempt===a.prepareAttempt && run.event==='workflow_dispatch' && run.head_branch==='main' && run.head_sha===a.sourceSha && run.path==='.github/workflows/desktop-candidate.yml' && run.status==='completed' && run.conclusion==='success' && run.repository?.full_name===repo,'prepare run provenance differs'); return true; +} +export function verifyArtifact(artifact,a) { + demand(artifact.id===a.artifactId && artifact.name===`tenebra-signed-desktop-${a.prepareRunId}-${a.prepareAttempt}` && artifact.expired===false && artifact.digest===`sha256:${a.artifactSha256}` && positive(artifact.size_in_bytes) && artifact.size_in_bytes<=2*1024**3 && artifact.workflow_run?.id===a.prepareRunId && artifact.workflow_run?.head_sha===a.sourceSha && artifact.workflow_run?.head_branch==='main','artifact provenance differs'); return true; +} +export function safeArchiveNames(text) { + const names=text.trimEnd().split('\n'); + demand(names.length>1 && names.length<=64 && names.includes('candidate.json') && new Set(names).size===names.length && names.every(n=>/^[A-Za-z0-9][A-Za-z0-9_.-]{0,180}$/.test(n) && !n.includes('..')),'unsafe or duplicate ZIP entries'); return names; +} +function http(token) { + demand(typeof token==='string' && token.length>0,'GitHub token required'); + async function call(url,{method='GET',body,octet=false,limit=20*1024**2}={}) { + demand(url.startsWith(base+'/') || url.startsWith(`https://uploads.github.com/repos/${repo}/`),'unexpected GitHub request destination'); + const response=await fetch(url,{method,headers:{Authorization:`Bearer ${token}`,Accept:octet?'application/octet-stream':'application/vnd.github+json','X-GitHub-Api-Version':'2022-11-28',...(body?{'Content-Type':Buffer.isBuffer(body)?'application/octet-stream':'application/json'}:{})},body:body?(Buffer.isBuffer(body)?body:JSON.stringify(body)):undefined,signal:AbortSignal.timeout(180000)}); + if(!response.ok)throw Object.assign(Error(`GitHub ${method} operation failed (HTTP ${response.status})`),{status:response.status}); + demand(Number(response.headers.get('content-length')??0)<=limit,'GitHub response exceeds size bound'); + const chunks=[];let size=0; + for await(const chunk of response.body){size+=chunk.length;demand(size<=limit,'GitHub response exceeds size bound');chunks.push(Buffer.from(chunk));} + const bytes=Buffer.concat(chunks);return octet?bytes:(bytes.length?JSON.parse(bytes):null); + } + return call; +} +export async function acquireCandidate(a,pubkey,token) { + demand(positive(a.prepareRunId) && positive(a.prepareAttempt) && positive(a.artifactId) && /^[a-f0-9]{40}$/.test(a.sourceSha) && /^[a-f0-9]{64}$/.test(a.artifactSha256),'explicit acquisition pins required'); + const call=http(token),run=await call(`${base}/actions/runs/${a.prepareRunId}`);verifyPrepareRun(run,a); + const asset=await call(`${base}/actions/artifacts/${a.artifactId}`);verifyArtifact(asset,a); + const archive=await call(`${base}/actions/artifacts/${a.artifactId}/zip`,{octet:true,limit:2*1024**3}); + demand(sha256(archive)===a.artifactSha256,'downloaded ZIP SHA differs'); + const temporary=mkdtempSync(join(tmpdir(),'tenebra-candidate-')),zip=join(temporary,'candidate.zip'); + const files=new Map(); + try { + writeFileSync(zip,archive,{flag:'wx'}); + // Read entries to buffers, never unzip paths onto a filesystem. Exact flat + // names, duplicates, output sizes and every resulting SHA are checked. + const names=safeArchiveNames(execFileSync('unzip',['-Z1',zip],{encoding:'utf8',timeout:30000,maxBuffer:65536})); + let total=0; + for(const name of names){const bytes=execFileSync('unzip',['-p',zip,name],{timeout:120000,maxBuffer:1024**3});total+=bytes.length;demand(total<=2*1024**3,'expanded candidate exceeds bound');files.set(name,bytes);} + } finally {unlinkSync(zip);rmdirSync(temporary);} + const manifestBytes=files.get('candidate.json');demand(manifestBytes?.length<=1024**2,'candidate manifest size invalid'); + const candidate=JSON.parse(manifestBytes),manifestSha256=sha256(manifestBytes);files.delete('candidate.json'); + demand(manifestBytes.equals(jsonBytes(candidate)),'candidate manifest encoding is not canonical'); + verifyCandidate(candidate,files,pubkey); + demand(candidate.sourceSha===a.sourceSha && candidate.runId===a.prepareRunId && candidate.runAttempt===a.prepareAttempt,'candidate disagrees with Actions provenance'); + const commit=await call(`${base}/git/commits/${a.sourceSha}`);demand(commit.sha===a.sourceSha && commit.tree.sha===candidate.sourceTree,'source tree differs from exact Git commit'); + return {candidate,files,artifact:{artifactId:a.artifactId,artifactSha256:a.artifactSha256,manifestSha256},run:{id:run.id,attempt:run.run_attempt,headSha:run.head_sha},context:{pubkey}}; +} +export function publisher(token,candidate,notesBytes) { + const call=http(token),tag=`v${candidate.version}`; + const notes=releaseNotes(notesBytes,candidate.version); + demand(sha256(notesBytes)===candidate.files.find(f=>f.name==='release-notes.md')?.sha256,'release notes are not candidate-bound'); + const body=promotionId=>`\n${notes}`; + function marker(release){const found=[...(release.body??'').matchAll(//g)];return found.length===1?found[0][1]:null;} + async function readTag(){try{return (await call(`${base}/git/ref/tags/${tag}`)).object;}catch(e){if(e.status===404)return null;throw e;}} + async function assertTag(allowMissing=false){const ref=await readTag();demand((!ref&&allowMissing)||(ref?.type==='commit'&&ref.sha===candidate.sourceSha),'stable tag changed or disappeared');} + async function ownedRelease(id,promotionId){const r=await call(`${base}/releases/${id}`);demand(r.id===id&&r.tag_name===tag&&r.prerelease===false&&marker(r)===promotionId&&r.body===body(promotionId),'promotion ownership or release notes changed');return r;} + async function assertPublicationReady(ownedReleaseId=null){ + const main=(await call(`${base}/git/ref/heads/main`)).object; + let latest;try{latest=await call(`${base}/releases/latest`);}catch(e){if(e.status!==404)throw e;latest=null;} + assertStablePromotionState(candidate,main,latest,ownedReleaseId); + } + async function checkAsset(asset,bytes){demand(positive(asset.id)&&asset.state==='uploaded'&&asset.size===bytes.length,'existing asset is incomplete or has another size');const actual=await call(`${base}/releases/assets/${asset.id}`,{octet:true,limit:bytes.length});demand(sha256(actual)===sha256(bytes),'existing bytes differ; never overwrite');} + return { + assertPublicationReady, + async getState(name){ + let releases=[],complete=false; + for(let page=1;page<=10;page++){const list=await call(`${base}/releases?per_page=100&page=${page}`);releases.push(...list.filter(r=>r.tag_name===name));if(list.length<100){complete=true;break;}} + demand(complete&&releases.length<=1,'release listing is incomplete or ambiguous'); + const r=releases[0];return {tag:await readTag(),release:r?{id:r.id,isDraft:r.draft,promotionId:marker(r)}:null}; + }, + async createTag(name,source){await assertPublicationReady();await call(`${base}/git/refs`,{method:'POST',body:{ref:`refs/tags/${name}`,sha:source}});}, + async createDraft(name,source,promotionId){await assertPublicationReady();return call(`${base}/releases`,{method:'POST',body:{tag_name:name,target_commitish:source,name:`Tenebra ${name}`,draft:true,prerelease:false,body:body(promotionId)}});}, + async ensureAsset(id,name,bytes,promotionId){ + await assertTag();const release=await ownedRelease(id,promotionId);demand(release.draft===true,'cannot add assets to a public release'); + const existing=release.assets.filter(a=>a.name===name);demand(existing.length<=1,'duplicate release asset'); + if(existing.length){await checkAsset(existing[0],bytes);return;} + await call(`https://uploads.github.com/repos/${repo}/releases/${id}/assets?name=${encodeURIComponent(name)}`,{method:'POST',body:bytes}); + }, + async verifyUploaded(id,files,{partial=false,promotionId}={}){ + await assertTag(partial);const release=await ownedRelease(id,promotionId); + demand(!partial||release.draft===true,'public release changed during preparation'); + demand((partial||release.assets.length===files.size)&&new Set(release.assets.map(a=>a.name)).size===release.assets.length&&release.assets.every(a=>files.has(a.name)),'release asset set changed'); + for(const asset of release.assets)await checkAsset(asset,files.get(asset.name)); + }, + async publish(id,name,promotionId){await assertTag();const release=await ownedRelease(id,promotionId);await assertPublicationReady(id);if(release.draft)await call(`${base}/releases/${id}`,{method:'PATCH',body:{draft:false,prerelease:false,make_latest:'true'}});}, + async updateChannel(name){await assertTag();const state=await this.getState(name);await assertPublicationReady(state.release?.id??null);delete process.env.GH_TOKEN;await publishCompleteRelease({tag:name,repo,api:githubReleaseApi(repo,name)});}, + }; +} +if(process.argv[1] && import.meta.url===pathToFileURL(process.argv[1]).href) { + try { + const [mode,input,output]=process.argv.slice(2); + demand(mode==='inspect'||mode==='promote','usage: candidate-github.mjs inspect | promote'); + const raw=mode==='promote'?process.env.ACCEPTANCE_JSON:readFileSync(input,'utf8');demand(raw?.length<=65536,'acceptance input size invalid');const acceptance=JSON.parse(raw); + const config=JSON.parse(readFileSync('ui-desktop/src-tauri/tauri.conf.json')); + demand(execFileSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).trim()===acceptance.sourceSha,'verification checkout must equal candidate source SHA'); + if(mode==='promote') { + demand(process.env.GITHUB_ACTIONS==='true','promotion is restricted to the reviewed GitHub workflow'); + assertPrepareIdentity({event:process.env.GITHUB_EVENT_NAME,ref:process.env.GITHUB_REF,sha:process.env.GITHUB_SHA,sourceSha:acceptance.sourceSha,repo:process.env.GITHUB_REPOSITORY}); + demand(process.env.SOURCE_SHA===acceptance.sourceSha,'dispatch source differs from acceptance'); + } + const acquired=await acquireCandidate(acceptance,config.plugins.updater.pubkey,process.env.GITHUB_TOKEN||(mode==='inspect'?process.env.GH_TOKEN:undefined)); + demand(acquired.candidate.goVersion===readFileSync('.go-version','utf8').trim() && acquired.candidate.version===config.version,'candidate toolchain/version differs from pinned source'); + demand(acquired.files.get('release-notes.md').equals(execFileSync('git',['show',`HEAD:docs/releases/${config.version}.md`],{maxBuffer:65536})),'release notes differ from exact source commit'); + if(mode==='inspect') { + mkdirSync(output); + for(const [name,bytes] of acquired.files)writeFileSync(join(output,name),bytes,{flag:'wx'}); + writeFileSync(join(output,'candidate.json'),jsonBytes(acquired.candidate),{flag:'wx'}); + writeFileSync(join(output,'acquisition.json'),jsonBytes({schema:1,kind:'tenebra-signed-candidate-acquisition',state:'verified-only',...acquired.artifact,run:acquired.run,sourceSha:acquired.candidate.sourceSha,sourceTree:acquired.candidate.sourceTree,files:acquired.candidate.files,executed:false}),{flag:'wx'}); + console.log('Signed candidate verified and downloaded; no application was executed.'); + } else console.log(JSON.stringify(await promoteCandidate({...acquired,acceptance,api:publisher(process.env.GITHUB_TOKEN,acquired.candidate,acquired.files.get('release-notes.md'))}))); + } catch(error){console.error(error.message);process.exitCode=1;} +} diff --git a/scripts/candidate-github.test.mjs b/scripts/candidate-github.test.mjs new file mode 100644 index 00000000..efa8b753 --- /dev/null +++ b/scripts/candidate-github.test.mjs @@ -0,0 +1,69 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { verifyPrepareRun, verifyArtifact, safeArchiveNames, publisher } from './candidate-github.mjs'; +import {sha256} from './signed-candidate.mjs'; +const source='a'.repeat(40), acceptance={prepareRunId:123,prepareAttempt:2,sourceSha:source,artifactId:456,artifactSha256:'b'.repeat(64)}; +test('artifact acquisition pins an actual completed main dispatch, not a PR/tree-equivalent build',()=>{ + const run={id:123,run_attempt:2,event:'workflow_dispatch',head_branch:'main',head_sha:source,path:'.github/workflows/desktop-candidate.yml',status:'completed',conclusion:'success',repository:{full_name:'Divaaaan/tenebra'}}; + assert.equal(verifyPrepareRun(run,acceptance),true); + for(const delta of [{id:124},{run_attempt:1},{event:'pull_request'},{head_branch:'feature'},{head_sha:'c'.repeat(40)},{path:'.github/workflows/release.yml'},{status:'in_progress'},{conclusion:'failure'},{repository:{full_name:'other/fork'}}]) assert.throws(()=>verifyPrepareRun({...run,...delta},acceptance)); +}); +test('artifact must belong to accepted run/commit and exact immutable ID, digest and attempt name',()=>{ + const a={id:456,name:'tenebra-signed-desktop-123-2',expired:false,digest:'sha256:'+'b'.repeat(64),size_in_bytes:500,workflow_run:{id:123,head_sha:source,head_branch:'main'}}; + assert.equal(verifyArtifact(a,acceptance),true); + for(const delta of [{id:457},{name:'tenebra-signed-desktop-123-1'},{expired:true},{digest:null},{digest:'sha256:'+'f'.repeat(64)},{workflow_run:{id:124,head_sha:source,head_branch:'main'}},{size_in_bytes:0}]) assert.throws(()=>verifyArtifact({...a,...delta},acceptance)); +}); +test('ZIP entries are flat, unique, bounded and require candidate.json',()=>{ + assert.deepEqual(safeArchiveNames('candidate.json\nTenebra_0.6.0_x64-setup.exe\n'),['candidate.json','Tenebra_0.6.0_x64-setup.exe']); + for(const raw of ['../candidate.json\n','candidate.json\ncandidate.json\n','candidate.json\na/b\n','candidate.json\n--help\n','file.exe\n','candidate.json\n'+Array.from({length:70},(_,i)=>`file${i}`).join('\n')]) assert.throws(()=>safeArchiveNames(raw)); +}); + +test('publication adapter preserves candidate-bound notes and refuses body changes before mutation',async t=>{ + const notes=Buffer.from('# Tenebra 0.6.0\n\nExact reviewed notes.\n'),promotionId='c'.repeat(64); + const candidate={version:'0.6.0',sourceSha:source,files:[{name:'release-notes.md',sha256:sha256(notes)}]}; + const mutations=[];let release; + t.mock.method(globalThis,'fetch',async(url,options)=>{ + if(url.endsWith('/git/ref/heads/main'))return new Response(JSON.stringify({object:{type:'commit',sha:source}})); + if(url.endsWith('/releases/latest'))return new Response(JSON.stringify({id:11,tag_name:'v0.5.11',draft:false,prerelease:false})); + if(url.endsWith('/releases')&&options.method==='POST'){ + mutations.push('draft');release={...JSON.parse(options.body),id:42,assets:[]};return new Response(JSON.stringify(release)); + } + if(url.endsWith('/git/ref/tags/v0.6.0'))return new Response(JSON.stringify({object:{type:'commit',sha:source}})); + if(url.endsWith('/releases/42')&&options.method==='GET')return new Response(JSON.stringify(release)); + throw Error('unexpected request'); + }); + assert.throws(()=>publisher('test-token',candidate,Buffer.from('# Tenebra 0.6.0\n\nOther notes.\n'))); + const api=publisher('test-token',candidate,notes); + await api.createDraft('v0.6.0',source,promotionId); + assert.equal(release.body,`\n${notes}`); + await api.verifyUploaded(42,new Map(),{partial:true,promotionId}); + release.body+='Unreviewed claim.\n'; + await assert.rejects(()=>api.publish(42,'v0.6.0',promotionId),/notes changed/); + assert.deepEqual(mutations,['draft']); +}); +test('publication adapter leaves incomplete starter asset intact and fails closed',async t=>{ + const notes=Buffer.from('# Tenebra 0.6.0\n\nNotes.\n'),promotionId='c'.repeat(64),methods=[]; + const candidate={version:'0.6.0',sourceSha:source,files:[{name:'release-notes.md',sha256:sha256(notes)}]}; + t.mock.method(globalThis,'fetch',async(url,options)=>{ + methods.push(options.method); + if(url.endsWith('/git/ref/tags/v0.6.0'))return new Response(JSON.stringify({object:{type:'commit',sha:source}})); + if(url.endsWith('/releases/42'))return new Response(JSON.stringify({id:42,tag_name:'v0.6.0',prerelease:false,draft:true,body:`\n${notes}`,assets:[{id:8,name:'file.exe',state:'starter',size:0}]})); + throw Error('unexpected request'); + }); + await assert.rejects(()=>publisher('test-token',candidate,notes).verifyUploaded(42,new Map([['file.exe',Buffer.from('bytes')]]),{partial:true,promotionId}),/incomplete/); + assert.deepEqual(methods,['GET','GET']); +}); + +for(const changed of ['newer-latest','advanced-main'])test(`publication rechecks ${changed} immediately before making a draft Latest`,async t=>{ + const notes=Buffer.from('# Tenebra 0.6.0\n\nNotes.\n'),promotionId='c'.repeat(64),writes=[]; + const candidate={version:'0.6.0',sourceSha:source,files:[{name:'release-notes.md',sha256:sha256(notes)}]}; + t.mock.method(globalThis,'fetch',async(url,options)=>{ + if(options.method!=='GET'){writes.push(options.method);return new Response('{}');} + if(url.endsWith('/git/ref/heads/main'))return new Response(JSON.stringify({object:{type:'commit',sha:changed==='advanced-main'?'d'.repeat(40):source}})); + if(url.endsWith('/releases/latest'))return new Response(JSON.stringify({id:99,tag_name:changed==='newer-latest'?'v0.6.1':'v0.5.11',draft:false,prerelease:false})); + if(url.endsWith('/git/ref/tags/v0.6.0'))return new Response(JSON.stringify({object:{type:'commit',sha:source}})); + if(url.endsWith('/releases/42'))return new Response(JSON.stringify({id:42,tag_name:'v0.6.0',prerelease:false,draft:true,body:`\n${notes}`,assets:[]})); + throw Error('unexpected request'); + }); + await assert.rejects(()=>publisher('test-token',candidate,notes).publish(42,'v0.6.0',promotionId));assert.deepEqual(writes,[]); +}); diff --git a/scripts/embed-uninstall-helper.mjs b/scripts/embed-uninstall-helper.mjs new file mode 100644 index 00000000..40fed575 --- /dev/null +++ b/scripts/embed-uninstall-helper.mjs @@ -0,0 +1,48 @@ +import fs from 'node:fs'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +export const sourcePath = new URL('../ui-desktop/src-tauri/installer-release-protection.ps1', import.meta.url); +export const outputPath = new URL('../ui-desktop/src-tauri/installer-release-protection.nsh', import.meta.url); + +function nsisString(text) { + return text.replaceAll('$', () => '$$').replaceAll('"', '$\\"'); +} + +export function renderUninstallHelper(source) { + const encoded = Buffer.from(source.replaceAll('\r\n', '\n'), 'utf16le').toString('base64'); + const chunks = encoded.match(/.{1,512}/g); + const names = chunks.map((_, index) => `TENEBRA_RELEASE_PS${index}`); + const set = (name, value) => [ + ` StrCpy $0 "${value}"`, + ` System::Call 'kernel32::SetEnvironmentVariableW(w "${name}", w r0) i.r0'`, + ' ${If} $0 = 0', + " System::Call 'kernel32::GetLastError() i.r0'", + ' !insertmacro TenebraServiceFailure "prepare protection cleanup for"', + ' ${EndIf}', + ]; + const driver = `$s=(0..${chunks.length - 1}|ForEach-Object{[Environment]::GetEnvironmentVariable('TENEBRA_RELEASE_PS'+$_)})-join'';& ([ScriptBlock]::Create([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($s))))`; + const execute = ' nsExec::ExecToLog /TIMEOUT=35000 ' + '`"$SYSDIR\\WindowsPowerShell\\v1.0\\powershell.exe" -NoProfile -NonInteractive -Command "' + nsisString(driver) + '"`'; + return [ + '; Generated by scripts/embed-uninstall-helper.mjs. Edit the .ps1 source.', + '; Constant chunks avoid NSIS string limits and execution of a user-writable script.', + '!macro TenebraReleaseHostProtection', + ...set('TENEBRA_RELEASE_CORE', '$INSTDIR\\tenebra-core.exe'), + ...chunks.flatMap((chunk, index) => set(names[index], chunk)), + execute, + ' Pop $0', + ' Push $0', + ...['TENEBRA_RELEASE_CORE', ...names].map(name => ` System::Call 'kernel32::SetEnvironmentVariableW(w "${name}", p 0) i.r0'`), + ' Pop $0', + ' !insertmacro TenebraRequireSuccess "release owned host protection before unregistering"', + '!macroend', + '', + ].join('\n'); +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + const output = renderUninstallHelper(fs.readFileSync(sourcePath, 'utf8')); + if (process.argv.includes('--check')) { + if (fs.readFileSync(outputPath, 'utf8').replaceAll('\r\n', '\n') !== output) throw new Error('Run node scripts/embed-uninstall-helper.mjs'); + } else fs.writeFileSync(outputPath, output); + console.log(fileURLToPath(outputPath)); +} diff --git a/scripts/publish-beta-manifest.mjs b/scripts/publish-beta-manifest.mjs index 2b7de3f7..5123207e 100644 --- a/scripts/publish-beta-manifest.mjs +++ b/scripts/publish-beta-manifest.mjs @@ -1,135 +1,10 @@ -// Publishes the beta-channel updater manifest (beta.json) to the GitHub release -// whose assets back the /releases/latest/download/ URL the client polls. -// -// Tauri's updater serves one manifest per endpoint, and GitHub's -// /releases/latest/download/ always resolves to the newest NON-prerelease -// release. So the stable channel reads latest.json (published by tauri-action on -// the stable release, unchanged) and the beta channel reads beta.json, which we -// place on that same latest-stable release. beta.json is a copy of the manifest -// tauri-action generated for THIS build: -// -// - stable tag -> this release becomes the new /latest/; copy its latest.json -// to beta.json on the same release, so a beta user always sees -// the newest stable (the beta+stable cascade, at publish time). -// - prerelease -> GitHub keeps this release out of /latest/; copy its manifest -// to beta.json on the current latest-stable release, so beta -// users pick up the prerelease while stable users — reading -// latest.json on that same release — do not. -// -// The manifest is byte-for-byte tauri-action's signed output: same version, same -// minisign signature, same installer URL (a per-tag asset URL that stays -// reachable even for a prerelease). Verification is therefore identical on both -// channels, and the stable flow is never touched. -// -// node scripts/publish-beta-manifest.mjs -// -// where is the pushed git tag (e.g. v0.4.0-beta.1) and is -// "true" or "false". Authenticates through gh via GITHUB_TOKEN. - -import { execFileSync } from "node:child_process"; -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -/** - * The release whose assets beta.json must be uploaded to, so that - * /releases/latest/download/beta.json resolves to it. - * - * A stable build owns the new "latest" release, so beta.json goes on the tag - * itself. A prerelease is excluded from "latest" by GitHub, so beta.json goes on - * the current latest-stable release. When a prerelease is cut before any stable - * release exists there is nowhere `/releases/latest/` can point, so there is - * nothing to publish and the caller skips. - */ +// Deprecated entry point retained for old script consumers. A platform build +// must never publish beta. Use the complete lifecycle gate in publish-release. +import { pathToFileURL } from 'node:url'; export function resolveBetaTarget({ tag, prerelease, latestStable }) { - if (!prerelease) { - return tag; - } - return latestStable ?? null; + return prerelease ? latestStable ?? null : tag; } - -/** Look up the latest non-prerelease release tag, or null when none exists. */ -function latestStableTag(repo) { - try { - const out = execFileSync( - "gh", - ["api", `repos/${repo}/releases/latest`, "--jq", ".tag_name"], - { encoding: "utf8" }, - ); - const tag = out.trim(); - return tag.length > 0 ? tag : null; - } catch { - // 404 when the repo has no full (non-prerelease) release yet. - return null; - } -} - -function main() { - const [tag, prereleaseArg] = process.argv.slice(2); - if (!tag || prereleaseArg === undefined) { - console.error( - "usage: node scripts/publish-beta-manifest.mjs ", - ); - process.exit(1); - } - const prerelease = prereleaseArg === "true"; - const repo = process.env.GITHUB_REPOSITORY; - if (!repo) { - console.error("publish-beta-manifest: GITHUB_REPOSITORY is not set"); - process.exit(1); - } - - const latestStable = prerelease ? latestStableTag(repo) : null; - const target = resolveBetaTarget({ tag, prerelease, latestStable }); - if (!target) { - // A prerelease with no stable release to attach to: /releases/latest/ has - // nowhere to resolve, so there is no beta channel to serve yet. Nothing to - // do — the next stable release seeds it. - console.log( - "publish-beta-manifest: no latest-stable release yet; skipping beta.json", - ); - return; - } - - // Copy the manifest tauri-action just published for THIS build, renamed to - // beta.json, then attach it to the target release (replacing any prior one). - const dir = mkdtempSync(join(tmpdir(), "tenebra-beta-")); - execFileSync( - "gh", - [ - "release", - "download", - tag, - "--repo", - repo, - "--pattern", - "latest.json", - "--dir", - dir, - "--clobber", - ], - { stdio: "inherit" }, - ); - const manifest = readFileSync(join(dir, "latest.json")); - const betaPath = join(dir, "beta.json"); - writeFileSync(betaPath, manifest); - execFileSync( - "gh", - ["release", "upload", target, betaPath, "--repo", repo, "--clobber"], - { stdio: "inherit" }, - ); - - const version = JSON.parse(manifest.toString("utf8")).version; - console.log( - `publish-beta-manifest: beta.json (${version}) published to ${target}`, - ); -} - -// Run only when invoked as a script, so the pure helper can be unit-tested. if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main(); + console.error('Standalone beta publication is disabled; use .github/scripts/publish-release.mjs after every platform job.'); + process.exitCode = 1; } - -// Referenced by the test runner without triggering main(). -export const _scriptPath = fileURLToPath(import.meta.url); diff --git a/scripts/release-api.mjs b/scripts/release-api.mjs new file mode 100644 index 00000000..3729ed13 --- /dev/null +++ b/scripts/release-api.mjs @@ -0,0 +1,78 @@ +// GitHub adapter. Tests inject an in-memory adapter into release-lifecycle; +// this module is called only by the final publish job, never platform builds. +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export function githubReleaseApi(repo, tag) { + if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) throw new Error('invalid GitHub repository'); + const base = `repos/${repo}`; + const branch = 'update-channels'; + function gh(args, input) { + try { return execFileSync('gh', args, { encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'pipe'] }); } + catch (error) { + const detail = String(error.stderr ?? ''); + const status = Number(/HTTP (\d{3})/.exec(detail)?.[1]); + throw Object.assign(new Error(`GitHub release operation failed${status ? ` (HTTP ${status})` : ''}`), { status }); + } + } + function request(path, method = 'GET', payload) { + const args = ['api', path, '--method', method]; + if (payload) args.push('--input', '-'); + const output = gh(args, payload ? JSON.stringify(payload) : undefined); + return output.trim() ? JSON.parse(output) : undefined; + } + async function assetText(tag, name) { + const dir = mkdtempSync(join(tmpdir(), 'tenebra-release-')); + try { + gh(['release', 'download', tag, '--repo', repo, '--pattern', name, '--dir', dir]); + return readFileSync(join(dir, name), 'utf8'); + } finally { rmSync(dir, { recursive: true, force: true }); } + } + async function ensureBranch() { + try { request(`${base}/git/ref/heads/${branch}`); return; } + catch (e) { if (e.status !== 404) throw e; } + const sha = request(`${base}/commits/${encodeURIComponent(tag)}`).sha; + try { request(`${base}/git/refs`, 'POST', { ref: `refs/heads/${branch}`, sha }); } + catch (e) { if (e.status !== 422) throw e; } + // Recheck a racing bootstrap: a 422 must not mask another API failure. + request(`${base}/git/ref/heads/${branch}`); + } + return { + async getRelease(tag) { + const { databaseId } = JSON.parse(gh(['release', 'view', tag, '--repo', repo, '--json', 'databaseId'])); + const release = request(`${base}/releases/${databaseId}`); + return { isDraft: release.draft, isPrerelease: release.prerelease, assets: release.assets }; + }, + async readManifest(tag) { return JSON.parse(await assetText(tag, 'latest.json')); }, + readAssetText: assetText, + async seedLegacyBeta(tag, manifest) { + const dir = mkdtempSync(join(tmpdir(), 'tenebra-legacy-beta-')); + try { + const path = join(dir, 'beta.json'); + writeFileSync(path, JSON.stringify(manifest, null, 2) + '\n'); + gh(['release', 'upload', tag, path, '--repo', repo, '--clobber']); + } finally { rmSync(dir, { recursive: true, force: true }); } + }, + async publish(tag) { gh(['release', 'edit', tag, '--repo', repo, '--draft=false']); }, + async assertPublicAsset(url) { + const response = await fetch(url, { method: 'HEAD', redirect: 'follow', signal: AbortSignal.timeout(15000) }); + if (!response.ok) throw new Error(`release download is not public/ready (HTTP ${response.status})`); + }, + async readChannel() { + try { + const result = request(`${base}/contents/beta.json?ref=${branch}`); + return { sha: result.sha, manifest: JSON.parse(Buffer.from(result.content, 'base64').toString('utf8')) }; + } catch (error) { if (error.status === 404) return null; throw error; } + }, + async compareAndSwapChannel(manifest, sha) { + await ensureBranch(); + request(`${base}/contents/beta.json`, 'PUT', { + branch, message: `release: publish beta channel ${manifest.version}`, + content: Buffer.from(JSON.stringify(manifest, null, 2) + '\n').toString('base64'), + ...(sha ? { sha } : {}), + }); + }, + }; +} diff --git a/scripts/release-lifecycle.mjs b/scripts/release-lifecycle.mjs new file mode 100644 index 00000000..62d8aa1c --- /dev/null +++ b/scripts/release-lifecycle.mjs @@ -0,0 +1,94 @@ +// Release gate and atomic beta channel switch. All mutation goes through the +// injectable API boundary so ordering, partial delivery and races are tested. +import { expectedAssets, missingAssets } from '../.github/scripts/publish-release.mjs'; + +const platforms = ['windows-x86_64', 'windows-x86_64-nsis', + 'darwin-x86_64', 'darwin-aarch64', 'darwin-x86_64-app', 'darwin-aarch64-app', + 'linux-x86_64', 'linux-x86_64-appimage', 'linux-x86_64-deb']; + +function semver(value) { + const m = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(value); + if (!m) throw new Error(`invalid release version: ${value}`); + const pre = m[4]?.split('.'); + if (pre?.some(p => /^0\d+$/.test(p))) throw new Error(`invalid prerelease version: ${value}`); + return { core: m.slice(1, 4).map(BigInt), pre }; +} +export function compareVersions(a, b) { + const x = semver(a), y = semver(b); + for (let i = 0; i < 3; i++) if (x.core[i] !== y.core[i]) return x.core[i] > y.core[i] ? 1 : -1; + if (!x.pre || !y.pre) return x.pre ? -1 : y.pre ? 1 : 0; + for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) { + const l = x.pre[i], r = y.pre[i]; + if (l === r) continue; + if (l === undefined) return -1; + if (r === undefined) return 1; + const ln = /^\d+$/.test(l), rn = /^\d+$/.test(r); + if (ln && rn) return BigInt(l) > BigInt(r) ? 1 : -1; + if (ln !== rn) return ln ? -1 : 1; + return l > r ? 1 : -1; + } + return 0; +} + +export function validateManifest(manifest, { tag, repo, assets }) { + if (manifest.version !== tag.replace(/^v/, '')) throw new Error('updater manifest version does not match release tag'); + const attached = new Set(assets.map(a => a.name)); + const urls = new Set(); + for (const key of platforms) { + const entry = manifest.platforms?.[key]; + if (!entry || !entry.signature?.trim()) throw new Error(`missing signed updater platform: ${key}`); + const url = new URL(entry.url); + const prefix = `/${repo}/releases/download/${tag}/`; + if (url.origin !== 'https://github.com' || !decodeURIComponent(url.pathname).startsWith(prefix) || url.search || url.hash) + throw new Error(`untrusted updater URL for ${key}`); + const name = decodeURIComponent(url.pathname).slice(prefix.length); + if (name.includes('/') || !attached.has(name) || !attached.has(`${name}.sig`)) + throw new Error(`updater asset or signature missing for ${key}`); + urls.add(entry.url); + } + return [...urls]; +} + +export async function publishCompleteRelease({ tag, repo, api, prepareOnly = false }) { + if (!tag.startsWith('v')) throw new Error('release tag must start with v'); + const version = tag.slice(1); semver(version); + const prerelease = Boolean(semver(version).pre); + const release = await api.getRelease(tag); + if (prepareOnly && !release.isDraft) throw new Error('release is already public; cannot prepare a draft'); + if (release.isPrerelease !== prerelease) throw new Error('release channel disagrees with tag'); + // Legacy beta is staged only on a stable draft, after every platform job. + const expected = expectedAssets({ version, prerelease }).filter(a => a.want !== 'beta.json'); + const missing = missingAssets(expected, release.assets.filter(a => a.state === 'uploaded' && a.size > 0).map(a => a.name)); + if (missing.length) throw new Error(`incomplete release: ${missing.map(a => a.want).join(', ')}`); + const manifest = await api.readManifest(tag); + const urls = validateManifest(manifest, { tag, repo, assets: release.assets }); + for (const url of urls) { + const name = decodeURIComponent(new URL(url).pathname.split('/').pop()); + const signature = (await api.readAssetText(tag, `${name}.sig`)).trim(); + for (const entry of Object.values(manifest.platforms)) { + if (entry.url === url && entry.signature.trim() !== signature) throw new Error(`manifest signature differs from ${name}.sig`); + } + } + if (!prerelease && release.isDraft) await api.seedLegacyBeta(tag, manifest); + // A held release has the same complete signed assets and stable legacy + // manifest, but remains private until the exact installer is accepted. + if (prepareOnly) return { prepared: true, switched: false }; + if (release.isDraft) await api.publish(tag); + const visible = await api.getRelease(tag); + if (visible.isDraft) throw new Error('release is still a draft; beta pointer preserved'); + // Unauthenticated probes catch a draft/private/unavailable download before + // the public pointer is changed. No platform build may invoke this switch. + for (const asset of release.assets) { + await api.assertPublicAsset(`https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(asset.name)}`); + } + for (let attempt = 0; attempt < 3; attempt++) { + const current = await api.readChannel(); + if (current && compareVersions(current.manifest.version, version) >= 0) return { switched: false }; + try { + await api.compareAndSwapChannel(manifest, current?.sha); + return { switched: true }; + } catch (error) { + if (![409, 422].includes(error.status) || attempt === 2) throw error; + } + } +} diff --git a/scripts/release-lifecycle.test.mjs b/scripts/release-lifecycle.test.mjs new file mode 100644 index 00000000..83a5776b --- /dev/null +++ b/scripts/release-lifecycle.test.mjs @@ -0,0 +1,147 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as lifecycle from './release-lifecycle.mjs'; + +const version = '0.6.0-beta.1'; +const names = [ + `Tenebra_${version}_x64-setup.exe`, `Tenebra_${version}_x64-setup.exe.sig`, + `Tenebra_${version}_universal.dmg`, 'Tenebra_universal.app.tar.gz', 'Tenebra_universal.app.tar.gz.sig', + `Tenebra_${version}_amd64.deb`, `Tenebra_${version}_amd64.deb.sig`, + `Tenebra_${version}_amd64.AppImage`, `Tenebra_${version}_amd64.AppImage.sig`, + `tenebra-${version}-1-x86_64.pkg.tar.zst`, 'latest.json', +]; +function fixture(releaseVersion = version) { + const actualNames = names.map(n => n.replace(version, releaseVersion)); + const assets = actualNames.map((name) => ({ name, state: 'uploaded', size: 12 })); + const platforms = {}; + for (const [keys, asset] of [ + [['windows-x86_64', 'windows-x86_64-nsis'], actualNames[0]], + [['darwin-x86_64', 'darwin-aarch64', 'darwin-x86_64-app', 'darwin-aarch64-app'], actualNames[3]], + [['linux-x86_64', 'linux-x86_64-appimage'], actualNames[7]], + [['linux-x86_64-deb'], actualNames[5]], + ]) for (const key of keys) platforms[key] = { url: `https://github.com/owner/repo/releases/download/v${releaseVersion}/${asset}`, signature: 'signed' }; + let channel = { sha: 'old-sha', manifest: { version: '0.5.11' } }; + let release = { isDraft: true, isPrerelease: releaseVersion.includes('-'), assets }; + const events = []; + const api = { + getRelease: async () => structuredClone(release), + readManifest: async () => ({ version: releaseVersion, platforms }), + readAssetText: async () => 'signed', + assertPublicAsset: async (url) => { assert.equal(release.isDraft, false); events.push('ready'); }, + seedLegacyBeta: async () => { assert.equal(release.isDraft, true); events.push('legacy'); }, + publish: async () => { events.push('publish'); release.isDraft = false; }, + readChannel: async () => structuredClone(channel), + compareAndSwapChannel: async (manifest, sha) => { + assert.equal(release.isDraft, false); + assert.equal(sha, channel.sha); + events.push('switch'); channel = { sha: 'new-sha', manifest }; + }, + }; + return { api, events, channel: () => channel, release, setChannel: (value) => { channel = value; } }; +} +const run = (api) => lifecycle.publishCompleteRelease({ tag: `v${version}`, repo: 'owner/repo', api }); + +test('all public assets are ready before the only atomic channel switch', async () => { + const f = fixture(); await run(f.api); + assert.equal(f.events[0], 'publish'); + assert.equal(f.events.at(-1), 'switch'); + assert.equal(f.events.filter((e) => e === 'switch').length, 1); + assert.equal(f.channel().manifest.version, version); +}); +test('downstream missing or failed upload preserves draft and previous pointer', async () => { + for (const change of [r => r.assets.pop(), r => r.assets[0].state = 'starter']) { + const f = fixture(); change(f.release); + await assert.rejects(run(f.api)); + assert.equal(f.release.isDraft, true); + assert.equal(f.channel().sha, 'old-sha'); + assert.deepEqual(f.events, []); + } +}); +test('publication or public-download failure leaves prior channel unchanged', async () => { + for (const key of ['publish', 'assertPublicAsset']) { + const f = fixture(); f.api[key] = async () => { throw new Error('injected failure'); }; + await assert.rejects(run(f.api), /injected failure/); + assert.equal(f.channel().sha, 'old-sha'); + } +}); +test('wrong version, partial platform coverage, and foreign asset URLs fail closed', async () => { + for (const mutate of [m => m.version = '0.5.0', m => delete m.platforms['darwin-aarch64'], m => m.platforms['windows-x86_64'].url = 'https://example.com/setup.exe']) { + const f = fixture(); const manifest = await f.api.readManifest(); mutate(manifest); + f.api.readManifest = async () => manifest; + await assert.rejects(run(f.api)); + assert.equal(f.channel().sha, 'old-sha'); assert.deepEqual(f.events, []); + } +}); +test('older concurrent publisher cannot roll the channel back', async () => { + const f = fixture(); f.setChannel({ sha: 'newer', manifest: { version: '0.7.0' } }); + await run(f.api); assert.equal(f.channel().manifest.version, '0.7.0'); + assert.ok(!f.events.includes('switch')); +}); +test('CAS collision re-reads pointer and yields to newer publication', async () => { + const f = fixture(); let calls = 0; + f.api.compareAndSwapChannel = async () => { calls++; f.setChannel({ sha: 'race', manifest: { version: '0.7.0' } }); throw Object.assign(new Error('conflict'), { status: 409 }); }; + await run(f.api); assert.equal(calls, 1); assert.equal(f.channel().sha, 'race'); +}); +test('CAS retry is bounded and never deletes the existing pointer', async () => { + const f = fixture(); let calls = 0; + f.api.compareAndSwapChannel = async () => { calls++; throw Object.assign(new Error('conflict'), { status: 409 }); }; + await assert.rejects(run(f.api), /conflict/); assert.equal(calls, 3); assert.equal(f.channel().sha, 'old-sha'); +}); +test('numeric prerelease ordering and stable promotion obey SemVer', () => { + assert.ok(lifecycle.compareVersions('0.6.0-beta.10', '0.6.0-beta.2') > 0); + assert.ok(lifecycle.compareVersions('0.6.0', '0.6.0-rc.9') > 0); + assert.equal(lifecycle.compareVersions('0.6.0+one', '0.6.0+two'), 0); +}); + +test('stable legacy manifest is prepared inside the draft before publication', async () => { + const f = fixture('0.6.0'); + await lifecycle.publishCompleteRelease({ tag: 'v0.6.0', repo: 'owner/repo', api: f.api }); + assert.deepEqual(f.events.slice(0, 2), ['legacy', 'publish']); + assert.equal(f.events.at(-1), 'switch'); +}); +test('rerunning a published stable never clobbers its live legacy asset', async () => { + const f = fixture('0.6.0'); f.release.isDraft = false; + await lifecycle.publishCompleteRelease({ tag: 'v0.6.0', repo: 'owner/repo', api: f.api }); + assert.ok(!f.events.includes('legacy')); assert.ok(!f.events.includes('publish')); +}); +test('mismatched updater signature fails before publication', async () => { + const f = fixture(); f.api.readAssetText = async () => 'different-signature'; + await assert.rejects(run(f.api), /signature differs/); + assert.deepEqual(f.events, []); assert.equal(f.channel().sha, 'old-sha'); +}); + +test('prepare-only verifies and completes a stable draft without public or channel operations', async () => { + const f = fixture('0.6.0'); + f.api.readChannel = async () => { throw new Error('prepare must not read the public channel'); }; + const result = await lifecycle.publishCompleteRelease({ tag: 'v0.6.0', repo: 'owner/repo', api: f.api, prepareOnly: true }); + assert.deepEqual(result, { prepared: true, switched: false }); + assert.deepEqual(f.events, ['legacy']); + assert.equal(f.release.isDraft, true); + assert.equal(f.channel().sha, 'old-sha'); +}); + +test('prepare-only preserves prerelease channel and refuses an already public release', async () => { + const f = fixture(); + await lifecycle.publishCompleteRelease({ tag: `v${version}`, repo: 'owner/repo', api: f.api, prepareOnly: true }); + assert.deepEqual(f.events, []); + f.release.isDraft = false; + await assert.rejects(lifecycle.publishCompleteRelease({ tag: `v${version}`, repo: 'owner/repo', api: f.api, prepareOnly: true }), /already public/); + assert.deepEqual(f.events, []); +}); + +test('prepare-only does not bypass any asset, manifest or signature gate', async () => { + for (const change of [ + f => f.release.assets.pop(), + f => f.release.assets[0].state = 'starter', + f => f.release.assets[0].size = 0, + f => f.release.isPrerelease = true, + f => { f.api.readManifest = async () => ({ version: '0.5.11', platforms: {} }); }, + f => { f.api.readAssetText = async () => 'wrong-signature'; }, + ]) { + const f = fixture('0.6.0'); change(f); + await assert.rejects(lifecycle.publishCompleteRelease({ tag: 'v0.6.0', repo: 'owner/repo', api: f.api, prepareOnly: true })); + assert.deepEqual(f.events, []); + assert.equal(f.release.isDraft, true); + assert.equal(f.channel().sha, 'old-sha'); + } +}); diff --git a/scripts/set-version.mjs b/scripts/set-version.mjs index c87cca23..d332b6c3 100644 --- a/scripts/set-version.mjs +++ b/scripts/set-version.mjs @@ -6,8 +6,8 @@ // node scripts/set-version.mjs --check # assert all files already agree // node scripts/set-version.mjs 1.2.3 --check # assert every file is 1.2.3 // -// The files are the desktop package manifest, the Tauri bundle config, the Rust -// crate manifest and its lockfile entry, the Go core's build info, and the Arch +// The files are the desktop package manifest and npm lockfile, the Tauri bundle +// config, the Rust crate manifest and its lockfile entry, the Go core's build info, and the Arch // PKGBUILD. The release workflow reads the version from tauri.conf.json and the // updater's latest.json inherits it, so a stale copy would advertise the wrong // version to installed clients or leave the lockfile behind (build with --locked @@ -27,6 +27,13 @@ const root = fileURLToPath(new URL("..", import.meta.url)); // and the Cargo.lock one is tied to this crate's package block. const targets = [ { file: "ui-desktop/package.json", re: /("version":\s*")([^"]+)(")/ }, + { + file: "ui-desktop/package-lock.json", + // npm keeps the project's version at the top and in packages[""]. Match + // only this package's own entries, preserving every dependency version. + re: /("name":\s*"tenebra-desktop",\s*"version":\s*")([^"]+)(")/g, + matchCount: 2, + }, { file: "ui-desktop/src-tauri/tauri.conf.json", re: /("version":\s*")([^"]+)(")/ }, { file: "ui-desktop/src-tauri/Cargo.toml", re: /(^version = ")([^"]+)(")/m }, { @@ -71,18 +78,20 @@ if (wanted && !SEMVER.test(wanted)) { const files = targets.map((t) => { const path = join(root, t.file); const text = readFileSync(path, "utf8"); - const match = t.re.exec(text); - if (!match) { - fail(`could not find a version field in ${t.file}`); + const matcher = new RegExp(t.re.source, t.re.flags.includes("g") ? t.re.flags : `${t.re.flags}g`); + const matches = [...text.matchAll(matcher)]; + if (matches.length !== (t.matchCount ?? 1)) { + fail(`expected ${t.matchCount ?? 1} version field(s) in ${t.file}, found ${matches.length}`); } - return { ...t, path, text, current: match[2] }; + const versions = matches.map(match => match[2]); + return { ...t, path, text, versions, current: [...new Set(versions)].join(" / ") }; }); if (check) { const target = wanted ?? files[0].current; - const mismatched = files.filter((f) => f.current !== target); + const mismatched = files.filter((f) => f.versions.some(version => version !== target)); for (const f of files) { - const ok = f.current === target ? "ok" : "MISMATCH"; + const ok = f.versions.every(version => version === target) ? "ok" : "MISMATCH"; console.log(` ${f.current.padEnd(12)} ${f.file} [${ok}]`); } if (mismatched.length > 0) { @@ -96,7 +105,7 @@ if (check) { } else { let changed = 0; for (const f of files) { - if (f.current === wanted) { + if (f.versions.every(version => version === wanted)) { console.log(` ${f.file} already ${wanted}`); continue; } diff --git a/scripts/set-version.test.mjs b/scripts/set-version.test.mjs new file mode 100644 index 00000000..282861af --- /dev/null +++ b/scripts/set-version.test.mjs @@ -0,0 +1,43 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, copyFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +test('version checks include both npm lockfile copies and preserve dependency versions', t => { + const root = mkdtempSync(join(tmpdir(), 'tenebra-version-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const files = { + 'ui-desktop/package.json': '{"name":"tenebra-desktop","version":"0.5.11"}', + 'ui-desktop/package-lock.json': JSON.stringify({ + name: 'tenebra-desktop', version: '0.1.1', lockfileVersion: 3, + packages: { '': { name: 'tenebra-desktop', version: '0.5.11' }, 'node_modules/example': { version: '2.3.4' } }, + }), + 'ui-desktop/src-tauri/tauri.conf.json': '{"version":"0.5.11"}', + 'ui-desktop/src-tauri/Cargo.toml': 'version = "0.5.11"\n', + 'ui-desktop/src-tauri/Cargo.lock': 'name = "tenebra-desktop"\nversion = "0.5.11"\n', + 'core/buildinfo/buildinfo.go': 'const Version = "0.5.11"\n', + 'packaging/arch/PKGBUILD': 'pkgver=0.5.11\n', + }; + for (const [file, content] of Object.entries(files)) { + mkdirSync(dirname(join(root, file)), { recursive: true }); + writeFileSync(join(root, file), content); + } + mkdirSync(join(root, 'scripts')); + copyFileSync(new URL('./set-version.mjs', import.meta.url), join(root, 'scripts/set-version.mjs')); + const run = (...args) => spawnSync(process.execPath, [join(root, 'scripts/set-version.mjs'), ...args], { encoding: 'utf8' }); + const stale = run('--check'); + assert.equal(stale.status, 1, stale.stdout + stale.stderr); + const update = run('v0.6.0'); + assert.equal(update.status, 0, update.stdout + update.stderr); + const lock = JSON.parse(readFileSync(join(root, 'ui-desktop/package-lock.json'), 'utf8')); + assert.equal(lock.version, '0.6.0'); + assert.equal(lock.packages[''].version, '0.6.0'); + assert.equal(lock.packages['node_modules/example'].version, '2.3.4'); + const check = run('0.6.0', '--check'); + assert.equal(check.status, 0, check.stdout + check.stderr); + lock.packages[''].version = '0.5.11'; + writeFileSync(join(root, 'ui-desktop/package-lock.json'), JSON.stringify(lock)); + assert.equal(run('--check').status, 1, 'a stale root package entry must also fail'); +}); diff --git a/scripts/signed-candidate.mjs b/scripts/signed-candidate.mjs new file mode 100644 index 00000000..6b615651 --- /dev/null +++ b/scripts/signed-candidate.mjs @@ -0,0 +1,132 @@ +// Pure release policy. No network, credentials, process execution or filesystem +// access: callers supply downloaded bytes and an explicit acceptance receipt. +import { createHash, createPublicKey, verify } from 'node:crypto'; +import { verifyBuildInfo } from './verify-core-build.mjs'; +import { validateManifest, compareVersions } from './release-lifecycle.mjs'; + +export const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); +export const jsonBytes = value => Buffer.from(JSON.stringify(value, null, 2) + '\n'); +const sha = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value); +const commit = value => typeof value === 'string' && /^[a-f0-9]{40}$/.test(value); +const positive = value => Number.isSafeInteger(value) && value > 0; +const stable = value => /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value); +function requireValue(ok, message) { if (!ok) throw new Error(message); } +function base64(value) { + requireValue(typeof value === 'string' && value.length > 0 && value.length <= 16384 && /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value), 'invalid base64 record'); + const bytes = Buffer.from(value, 'base64'); + requireValue(bytes.toString('base64') === value, 'noncanonical base64 record'); return bytes; +} +function lines(encoded, count) { + const text = new TextDecoder('utf-8', { fatal: true }).decode(base64(encoded.trim())); + const result = text.replace(/\r\n/g, '\n').replace(/\n$/, '').split('\n'); + requireValue(result.length === count && result[0].startsWith('untrusted comment: '), 'invalid minisign framing'); return result; +} +// Minisign Ed signs the bytes; ED signs their BLAKE2b-512 digest. Both also +// authenticate signature || trusted-comment separately (upstream minisign). +export function verifyUpdaterSignature(bytes, encoded, encodedKey) { + requireValue(Buffer.isBuffer(bytes), 'payload bytes required'); + const s = lines(encoded, 4), k = lines(encodedKey, 2), signature = base64(s[1]), key = base64(k[1]); + requireValue(key.length === 42 && key.subarray(0,2).toString() === 'Ed' && signature.length === 74 && ['Ed','ED'].includes(signature.subarray(0,2).toString()), 'unsupported signature algorithm'); + requireValue(signature.subarray(2,10).equals(key.subarray(2,10)), 'signing key ID differs'); + requireValue(s[2].startsWith('trusted comment: ') && !/[\x00-\x08\x0a-\x1f\x7f]/.test(s[2]), 'invalid trusted comment'); + const publicKey = createPublicKey({ key:Buffer.concat([Buffer.from('302a300506032b6570032100','hex'),key.subarray(10)]), format:'der', type:'spki' }); + const message = signature.subarray(0,2).toString() === 'ED' ? createHash('blake2b512').update(bytes).digest() : bytes; + requireValue(verify(null, message, publicKey, signature.subarray(10)), 'artifact signature invalid'); + const global = base64(s[3]); + requireValue(global.length === 64 && verify(null,Buffer.concat([signature.subarray(10),Buffer.from(s[2].slice('trusted comment: '.length))]),publicKey,global), 'trusted comment signature invalid'); + return { valid:true, keyId:key.subarray(2,10).toString('hex') }; +} +export function assertPrepareIdentity({ event, ref, sha:actual, sourceSha, repo, expectedRepo='Divaaaan/tenebra' }) { + requireValue(event === 'workflow_dispatch' && ref === 'refs/heads/main' && repo === expectedRepo && expectedRepo === 'Divaaaan/tenebra' && commit(sourceSha) && actual === sourceSha, 'signing requires exact main workflow dispatch commit'); return true; +} +export function assertStablePromotionState(candidate,main,latest,ownedReleaseId=null) { + requireValue(main?.type==='commit' && main.sha===candidate.sourceSha,'live main differs from accepted source; prepare a new candidate'); + if(latest===null)return true; + requireValue(positive(latest?.id) && latest.draft===false && latest.prerelease===false && typeof latest.tag_name==='string' && latest.tag_name.startsWith('v') && stable(latest.tag_name.slice(1)),'unexpected GitHub Latest stable release'); + const order=compareVersions(latest.tag_name.slice(1),candidate.version); + requireValue(order<0 || (order===0 && latest.id===ownedReleaseId),'GitHub Latest is newer or is not this owned release; never roll stable back'); + return true; +} +export const buildReports = { + 'core-buildinfo-windows.json':['windows','amd64'], + 'core-buildinfo-macos-arm64.json':['darwin','arm64'], + 'core-buildinfo-macos-amd64.json':['darwin','amd64'], + 'core-buildinfo-linux.json':['linux','amd64'], + 'core-buildinfo-arch.json':['linux','amd64'], +}; +export function bundleNames(version) { + requireValue(stable(version), 'stable semantic version required'); + return [`Tenebra_${version}_x64-setup.exe`, `Tenebra_${version}_universal.dmg`, 'Tenebra_universal.app.tar.gz', `Tenebra_${version}_amd64.deb`, `Tenebra_${version}_amd64.AppImage`, `tenebra-${version}-1-x86_64.pkg.tar.zst`]; +} +export function releaseNotes(bytes,version) { + requireValue(Buffer.isBuffer(bytes) && bytes.length>0 && bytes.length<=65536,'release notes size invalid'); + const notes=new TextDecoder('utf-8',{fatal:true}).decode(bytes); + requireValue(notes.startsWith(`# Tenebra ${version}\n`) && !notes.includes('tenebra-promotion-sha256:') && !/[\x00-\x08\x0b-\x1f\x7f]/.test(notes),'release notes version or framing invalid'); + return notes; +} +export function makeCandidate(files, { repo, sourceSha, sourceTree, version, goVersion, runId, runAttempt, pubkey }) { + requireValue(repo === 'Divaaaan/tenebra' && commit(sourceSha) && commit(sourceTree) && positive(runId) && positive(runAttempt) && /^\d+\.\d+\.\d+$/.test(goVersion), 'invalid candidate provenance'); + const bundles=bundleNames(version), names=[...bundles.flatMap(name=>[name,name+'.sig']),...Object.keys(buildReports),'release-notes.md'].sort(); + requireValue(files instanceof Map && files.size === names.length && [...files.keys()].every(name=>names.includes(name)), 'candidate contains missing, extra or unsafe paths'); + const rows=names.map(name=>{ const bytes=files.get(name); requireValue(Buffer.isBuffer(bytes) && bytes.length > 0 && bytes.length <= 1024**3, 'invalid candidate file size'); return {name,bytes:bytes.length,sha256:sha256(bytes)}; }); + for (const name of bundles) verifyUpdaterSignature(files.get(name),files.get(name+'.sig').toString().trim(),pubkey); + for (const [name,[os,arch]] of Object.entries(buildReports)) { + const report=JSON.parse(files.get(name)); + requireValue(report.goVersion === goVersion && report.revision === sourceSha && report.os === os && report.arch === arch && sha(report.sha256), 'core provenance differs'); + verifyBuildInfo(report.metadata,{goVersion,revision:sourceSha,os,arch}); + } + const platforms={}; + for (const [keys,name] of [ + [['windows-x86_64','windows-x86_64-nsis'],bundles[0]], + [['darwin-x86_64','darwin-aarch64','darwin-x86_64-app','darwin-aarch64-app'],bundles[2]], + [['linux-x86_64','linux-x86_64-appimage'],bundles[4]], + [['linux-x86_64-deb'],bundles[3]], + ]) for(const key of keys) platforms[key]={url:`https://github.com/${repo}/releases/download/v${version}/${name}`,signature:files.get(name+'.sig').toString().trim()}; + const updater={version,notes:releaseNotes(files.get('release-notes.md'),version),platforms}; + validateManifest(updater,{tag:`v${version}`,repo,assets:names.map(name=>({name}))}); + return {schema:1,kind:'tenebra-signed-desktop-candidate',repo,sourceSha,sourceTree,version,goVersion,runId,runAttempt,workflowPath:'.github/workflows/desktop-candidate.yml',pubkeySha256:sha256(Buffer.from(pubkey)),files:rows,updater}; +} +export function verifyCandidate(candidate, files, pubkey) { + requireValue(JSON.stringify(candidate) === JSON.stringify(makeCandidate(files,{...candidate,pubkey})), 'candidate manifest/provenance differs from actual bytes'); return true; +} +export function verifyAcceptance(candidate, acceptance, artifact) { + requireValue(acceptance.schema === 1 && acceptance.kind === 'tenebra-desktop-acceptance' && acceptance.state === 'pass' && acceptance.version === candidate.version && acceptance.sourceSha === candidate.sourceSha && acceptance.sourceTree === candidate.sourceTree && acceptance.prepareRunId === candidate.runId && acceptance.prepareAttempt === candidate.runAttempt, 'acceptance source/run differs'); + requireValue(positive(artifact.artifactId) && sha(artifact.artifactSha256) && sha(artifact.manifestSha256) && acceptance.artifactId === artifact.artifactId && acceptance.artifactSha256 === artifact.artifactSha256 && acceptance.manifestSha256 === artifact.manifestSha256, 'acceptance artifact identity differs'); + requireValue(JSON.stringify(acceptance.files) === JSON.stringify(candidate.files), 'accepted file hashes differ'); + requireValue(Array.isArray(acceptance.evidence) && acceptance.evidence.length > 0 && acceptance.evidence.length <= 32 && acceptance.evidence.every(e=>typeof e.kind === 'string' && /^[a-z][a-z0-9-]{2,80}$/.test(e.kind) && sha(e.sha256)), 'native acceptance evidence hashes required'); + requireValue(acceptance.evidence.some(e=>e.kind==='windows-install-service-ui-tunnel-protection'),'combined Windows native acceptance evidence required'); return true; +} +export async function promoteCandidate({candidate,files,acceptance,artifact,context,api}) { + verifyCandidate(candidate,files,context.pubkey); verifyAcceptance(candidate,acceptance,artifact); + const tag=`v${candidate.version}`; + const intent={schema:1,kind:'tenebra-desktop-promotion',repo:candidate.repo,tag,sourceSha:candidate.sourceSha,prepareRunId:candidate.runId,prepareAttempt:candidate.runAttempt,...artifact,acceptanceSha256:sha256(jsonBytes(acceptance))}; + const promotionId=sha256(jsonBytes(intent)); + const uploaded=new Map(files); + uploaded.set('latest.json',jsonBytes(candidate.updater)); uploaded.set('beta.json',jsonBytes(candidate.updater)); + uploaded.set('candidate.json',jsonBytes(candidate)); uploaded.set('acceptance.json',jsonBytes(acceptance)); + uploaded.set('promotion.json',jsonBytes(intent)); + function own(state) { + requireValue(!state.tag || (state.tag.type==='commit' && state.tag.sha===candidate.sourceSha),'existing tag differs; never retag'); + requireValue(!state.release || (positive(state.release.id) && typeof state.release.isDraft==='boolean' && state.release.promotionId===promotionId),'existing release is not this accepted promotion'); + requireValue(!state.tag || state.release,'unknown existing tag without owned release'); + requireValue(!state.release || state.release.isDraft || state.tag,'published release lost its tag'); + return state; + } + let state=own(await api.getState(tag)); + await api.assertPublicationReady(state.release?.id??null); + // Persist ownership BEFORE separately creating the immutable tag. A timeout + // after either write can then be reconciled on the next identical request. + if(!state.release){await api.createDraft(tag,candidate.sourceSha,promotionId);state=own(await api.getState(tag));} + requireValue(state.release,'created draft is not observable'); + const release=state.release; + if(release.isDraft){ + await api.verifyUploaded(release.id,uploaded,{partial:true,promotionId}); + if(!state.tag)await api.createTag(tag,candidate.sourceSha); + own(await api.getState(tag)); + for(const [name,bytes] of uploaded)await api.ensureAsset(release.id,name,bytes,promotionId); + await api.verifyUploaded(release.id,uploaded,{promotionId}); + await api.publish(release.id,tag,promotionId); + } else await api.verifyUploaded(release.id,uploaded,{promotionId}); + await api.updateChannel(tag,candidate.updater); + return {tag,releaseId:release.id,sourceSha:candidate.sourceSha,promotionId,rebuilt:false}; +} diff --git a/scripts/signed-candidate.test.mjs b/scripts/signed-candidate.test.mjs new file mode 100644 index 00000000..5646e128 --- /dev/null +++ b/scripts/signed-candidate.test.mjs @@ -0,0 +1,114 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { generateKeyPairSync, sign, createHash } from 'node:crypto'; +import { verifyUpdaterSignature, makeCandidate, verifyCandidate, verifyAcceptance, assertPrepareIdentity, promoteCandidate, assertStablePromotionState } from './signed-candidate.mjs'; + +const sha = b => createHash('sha256').update(b).digest('hex'); +const { privateKey, publicKey } = generateKeyPairSync('ed25519'); +const keyId = Buffer.from('0102030405060708', 'hex'); +const rawKey = publicKey.export({ type: 'spki', format: 'der' }).subarray(-32); +const pubkey = Buffer.from('untrusted comment: test key\n' + Buffer.concat([Buffer.from('Ed'), keyId, rawKey]).toString('base64') + '\n').toString('base64'); +function signature(bytes, algorithm = 'ED') { + const message = algorithm === 'ED' ? createHash('blake2b512').update(bytes).digest() : bytes; + const sig = sign(null, message, privateKey), comment = 'timestamp:12345\tfile:test'; + return Buffer.from('untrusted comment: test\n' + Buffer.concat([Buffer.from(algorithm), keyId, sig]).toString('base64') + '\ntrusted comment: ' + comment + '\n' + sign(null, Buffer.concat([sig, Buffer.from(comment)]), privateKey).toString('base64') + '\n').toString('base64'); +} +for (const algorithm of ['Ed', 'ED']) test(`verifies ${algorithm} payload and authenticated comment; rejects different bytes`, () => { + const bytes = Buffer.from('signed executable fixture'), encoded = signature(bytes, algorithm); + assert.equal(verifyUpdaterSignature(bytes, encoded, pubkey).valid, true); + assert.throws(() => verifyUpdaterSignature(Buffer.from('replaced'), encoded, pubkey)); + const changed = Buffer.from(encoded, 'base64').toString().replace('timestamp:12345', 'timestamp:54321'); + assert.throws(() => verifyUpdaterSignature(bytes, Buffer.from(changed).toString('base64'), pubkey)); +}); +test('rejects malformed records, unknown algorithms, key IDs and another signing key', () => { + const bytes = Buffer.from('fixture'), encoded = signature(bytes); + for (const mutation of [s => s + '!', s => s.slice(2), s => Buffer.from(Buffer.from(s, 'base64').toString() + 'extra\n').toString('base64')]) assert.throws(() => verifyUpdaterSignature(bytes, mutation(encoded), pubkey)); + const lines = Buffer.from(encoded, 'base64').toString().trim().split('\n'); + for (const offset of [0, 2]) { const copy = [...lines], raw = Buffer.from(copy[1], 'base64'); raw[offset] ^= 1; copy[1] = raw.toString('base64'); assert.throws(() => verifyUpdaterSignature(bytes, Buffer.from(copy.join('\n')).toString('base64'), pubkey)); } + const otherKey = generateKeyPairSync('ed25519').publicKey.export({type:'spki',format:'der'}).subarray(-32); + const otherPubkey = Buffer.from('untrusted comment: other\n'+Buffer.concat([Buffer.from('Ed'),keyId,otherKey]).toString('base64')+'\n').toString('base64'); + assert.throws(()=>verifyUpdaterSignature(bytes,encoded,otherPubkey)); +}); +const sourceSha = 'a'.repeat(40), sourceTree = 'b'.repeat(40), repo = 'Divaaaan/tenebra'; +function fixture() { + const files = new Map(); + files.set('release-notes.md',Buffer.from('# Tenebra 0.6.0\n\nCandidate-bound release notes.\n')); + for (const name of ['Tenebra_0.6.0_x64-setup.exe', 'Tenebra_0.6.0_universal.dmg', 'Tenebra_universal.app.tar.gz', 'Tenebra_0.6.0_amd64.deb', 'Tenebra_0.6.0_amd64.AppImage', 'tenebra-0.6.0-1-x86_64.pkg.tar.zst']) { const bytes = Buffer.from(name); files.set(name, bytes); files.set(name + '.sig', Buffer.from(signature(bytes))); } + for (const [name, os, arch] of [['windows','windows','amd64'], ['macos-arm64','darwin','arm64'], ['macos-amd64','darwin','amd64'], ['linux','linux','amd64'], ['arch','linux','amd64']]) files.set(`core-buildinfo-${name}.json`, Buffer.from(JSON.stringify({ goVersion:'1.26.8', revision:sourceSha, os, arch, sha256:'c'.repeat(64), metadata:`core: go1.26.8\n build GOOS=${os}\n build GOARCH=${arch}\n build vcs.revision=${sourceSha}\n build vcs.modified=false\n` }))); + const context = { sourceSha, sourceTree, repo, version:'0.6.0', goVersion:'1.26.8', runId:123, runAttempt:1, pubkey }; + const candidate = makeCandidate(files, context); + const acceptance = { schema:1, kind:'tenebra-desktop-acceptance', state:'pass', version:'0.6.0', sourceSha, sourceTree, prepareRunId:123, prepareAttempt:1, artifactId:456, artifactSha256:'d'.repeat(64), manifestSha256:sha(Buffer.from(JSON.stringify(candidate, null, 2)+'\n')), files:candidate.files, evidence:[{ kind:'windows-install-service-ui-tunnel-protection', sha256:'e'.repeat(64) }] }; + return { files, candidate, acceptance, context }; +} +test('candidate covers all signed desktop bundles, fixed Go metadata and final stable URLs', () => { + const { files, candidate } = fixture(); + assert.equal(verifyCandidate(candidate, files, pubkey), true); + assert.equal(candidate.updater.platforms['windows-x86_64'].url, 'https://github.com/Divaaaan/tenebra/releases/download/v0.6.0/Tenebra_0.6.0_x64-setup.exe'); + assert.equal(candidate.files.length, 18); + assert.equal(candidate.updater.notes,files.get('release-notes.md').toString()); +}); +test('release notes are required, bounded UTF-8 for the exact version and cannot inject an ownership marker',()=>{ + const f=fixture(); + for(const content of [null,Buffer.from('# Tenebra 0.5.11\n'),Buffer.from([0xff]),Buffer.from('# Tenebra 0.6.0\n'),Buffer.from('# Tenebra 0.6.0\n'+'x'.repeat(65536))]) { + const files=new Map(f.files);if(content===null)files.delete('release-notes.md');else files.set('release-notes.md',content); + assert.throws(()=>makeCandidate(files,f.context)); + } + const files=new Map(f.files);files.set('release-notes.md',Buffer.from('# Tenebra 0.6.0\n\nAltered later.\n')); + assert.throws(()=>verifyCandidate(f.candidate,files,pubkey)); +}); +test('fails on replaced bytes, missing platform, unknown path, wrong core source or dirty build', () => { + const f = fixture(); + for (const mutate of [files => files.set('Tenebra_0.6.0_x64-setup.exe',Buffer.from('wrong')), files => files.delete('Tenebra_0.6.0_universal.dmg'), files => files.set('../escape',Buffer.from('bad')), files => { const name='core-buildinfo-windows.json', report=JSON.parse(files.get(name)); report.revision='f'.repeat(40); files.set(name,Buffer.from(JSON.stringify(report))); }, files => { const name='core-buildinfo-windows.json', report=JSON.parse(files.get(name)); report.metadata=report.metadata.replace('modified=false','modified=true'); files.set(name,Buffer.from(JSON.stringify(report))); }]) { const files = new Map(f.files); mutate(files); assert.throws(() => makeCandidate(files, f.context)); } +}); +test('acceptance binds every byte plus source, run, attempt, artifact identity and native evidence', () => { + const { candidate, acceptance } = fixture(); + assert.equal(verifyAcceptance(candidate, acceptance, { artifactId:456, artifactSha256:'d'.repeat(64), manifestSha256:acceptance.manifestSha256 }), true); + for (const mutate of [a => a.state='failed', a => a.sourceSha='f'.repeat(40), a => a.prepareRunId++, a => a.prepareAttempt++, a => a.artifactId++, a => a.artifactSha256='f'.repeat(64), a => a.manifestSha256='f'.repeat(64), a => a.files.pop(), a => a.files[0].sha256='f'.repeat(64), a => a.evidence=[], a => a.evidence[0].kind='build-only']) { const copy=structuredClone(acceptance); mutate(copy); assert.throws(() => verifyAcceptance(candidate,copy,{artifactId:456,artifactSha256:'d'.repeat(64),manifestSha256:acceptance.manifestSha256})); } +}); +test('only exact main dispatch can request signing, not PRs, tags, forks or historical checkout', () => { + const context={ event:'workflow_dispatch', ref:'refs/heads/main', sha:sourceSha, sourceSha, repo, expectedRepo:repo }; + assert.equal(assertPrepareIdentity(context), true); + for(const change of [{event:'pull_request'},{ref:'refs/tags/v0.6.0'},{sha:'f'.repeat(40)},{repo:'other/fork'}]) assert.throws(()=>assertPrepareIdentity({...context,...change})); +}); +function promotionFixture() { + const f=fixture(),effects=[],uploaded=new Map(),state={tag:null,release:null};let failure=null; + const fail=stage=>{if(failure===stage){failure=null;throw Error('injected '+stage);}}; + const api={ + assertPublicationReady:async()=>{}, + getState:async()=>structuredClone(state), + createDraft:async(tag,commit,promotionId)=>{state.release={id:42,isDraft:true,promotionId};effects.push('draft');fail('after-draft');return structuredClone(state.release);}, + createTag:async(tag,commit)=>{state.tag={type:'commit',sha:commit};effects.push('tag');fail('after-tag');}, + ensureAsset:async(id,name,bytes)=>{if(uploaded.has(name)){assert.deepEqual(uploaded.get(name),bytes);return;}uploaded.set(name,Buffer.from(bytes));effects.push('upload:'+name);fail('after-upload');}, + verifyUploaded:async(id,files,{partial=false}={})=>{for(const [name,bytes] of uploaded){assert.ok(files.has(name));assert.deepEqual(bytes,files.get(name));}if(!partial){assert.equal(uploaded.size,files.size);fail('readback');}}, + publish:async()=>{state.release.isDraft=false;effects.push('publish');fail('after-publish');}, + updateChannel:async()=>effects.push('channel'), + }; + return {f,api,effects,uploaded,state,setFailure:stage=>{failure=stage;},run:()=>promoteCandidate({...f,api,artifact:{artifactId:456,artifactSha256:'d'.repeat(64),manifestSha256:f.acceptance.manifestSha256}})}; +} +test('stable promotion requires live main and cannot replace newer, unexpected or foreign equal Latest',()=>{ + const candidate={version:'0.6.0',sourceSha},main={type:'commit',sha:sourceSha}; + const old={id:11,tag_name:'v0.5.11',draft:false,prerelease:false}; + assert.equal(assertStablePromotionState(candidate,main,null),true); + assert.equal(assertStablePromotionState(candidate,main,old),true); + assert.equal(assertStablePromotionState(candidate,main,{...old,id:42,tag_name:'v0.6.0'},42),true); + for(const latest of [{...old,tag_name:'v0.6.1'},{...old,tag_name:'v0.6.0'},{...old,tag_name:'v0.6.0-beta.1'},{...old,tag_name:'other'},{...old,draft:true},{...old,prerelease:true},undefined])assert.throws(()=>assertStablePromotionState(candidate,main,latest,42)); + assert.throws(()=>assertStablePromotionState(candidate,{...main,sha:'f'.repeat(40)},old)); +}); +test('old completed promotion retry cannot mutate anything after a newer release becomes Latest',async()=>{ + const p=promotionFixture();await p.run();const count=p.effects.length; + p.api.assertPublicationReady=async()=>assertStablePromotionState(p.f.candidate,{type:'commit',sha:sourceSha},{id:99,tag_name:'v0.6.1',draft:false,prerelease:false},42); + await assert.rejects(p.run);assert.equal(p.effects.length,count); +}); +test('unknown existing tag or draft fails without mutation',async()=>{ + for(const state of [{tag:{type:'commit',sha:sourceSha},release:null},{tag:null,release:{id:42,isDraft:true,promotionId:'0'.repeat(64)}},{tag:{type:'commit',sha:'f'.repeat(40)},release:{id:42,isDraft:false,promotionId:'0'.repeat(64)}}]){const p=promotionFixture();Object.assign(p.state,state);await assert.rejects(p.run);assert.deepEqual(p.effects,[]);} +}); +for(const stage of ['after-draft','after-tag','after-upload','after-publish']) test(`resume exact owned promotion after ${stage}, without retag/rebuild/asset overwrite`,async()=>{ + const p=promotionFixture();p.setFailure(stage);await assert.rejects(p.run,new RegExp(stage));const before=new Map(p.uploaded); + await p.run();assert.equal(p.effects.filter(x=>x==='draft').length,1);assert.equal(p.effects.filter(x=>x==='tag').length,1);assert.equal(p.effects.filter(x=>x==='publish').length,1);assert.equal(p.effects.at(-1),'channel'); + for(const [name,bytes] of before)assert.deepEqual(p.uploaded.get(name),bytes); + assert.deepEqual(p.uploaded.get('Tenebra_0.6.0_x64-setup.exe'),p.f.files.get('Tenebra_0.6.0_x64-setup.exe')); +}); +test('owned retry refuses changed tag, acceptance, or any preexisting bytes before more uploads',async()=>{ + for(const mutate of [p=>p.state.tag.sha='f'.repeat(40),p=>p.state.release.promotionId='0'.repeat(64),p=>p.uploaded.set([...p.uploaded.keys()][0],Buffer.from('replaced'))]){const p=promotionFixture();p.setFailure('after-upload');await assert.rejects(p.run);mutate(p);const count=p.effects.length;await assert.rejects(p.run);assert.equal(p.effects.length,count);} +}); +test('failed final readback cannot publish or advance updater clients',async()=>{const p=promotionFixture();p.setFailure('readback');await assert.rejects(p.run);assert.ok(!p.effects.includes('publish'));assert.ok(!p.effects.includes('channel'));}); diff --git a/scripts/test-installer-service-bootstrap.ps1 b/scripts/test-installer-service-bootstrap.ps1 new file mode 100644 index 00000000..9b1a88f9 --- /dev/null +++ b/scripts/test-installer-service-bootstrap.ps1 @@ -0,0 +1,43 @@ +$ErrorActionPreference = 'Stop' + +# Exercise the bootstrap from the actual NSIS wait command in fresh Windows +# PowerShell processes. A nameless ServiceController constructor opens no SCM +# handle and never queries or changes a host service. +$hooks = [IO.File]::ReadAllText((Join-Path $PSScriptRoot '../ui-desktop/src-tauri/installer-hooks.nsh')) +$pattern = 'try \{ (?(?:Add-Type -AssemblyName System\.ServiceProcess -ErrorAction Stop; )?)\(New-Object System\.ServiceProcess\.ServiceController\(''tenebra''\)\)\.WaitForStatus\(\[System\.ServiceProcess\.ServiceControllerStatus\]::Stopped,\[TimeSpan\]::FromSeconds\(30\)\); exit 0 \} catch \{ exit 1 \}' +$matches = [regex]::Matches($hooks, $pattern) +if ($matches.Count -ne 1) { throw 'Expected one reviewed installer WaitForStatus command.' } +$bootstrap = $matches[0].Groups['bootstrap'].Value +$command = '$ErrorActionPreference = ''Stop''; ' + $bootstrap + '$controller = New-Object System.ServiceProcess.ServiceController; [void][System.ServiceProcess.ServiceControllerStatus]; $controller.Dispose(); Write-Output ''PASS: service types loaded without a service name''' +$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) +$systemDirectory = if ([Environment]::Is64BitOperatingSystem -and -not [Environment]::Is64BitProcess) { 'Sysnative' } else { 'System32' } +$executables = @((Join-Path $env:SystemRoot "$systemDirectory/WindowsPowerShell/v1.0/powershell.exe")) +if ([Environment]::Is64BitOperatingSystem) { + $executables += Join-Path $env:SystemRoot 'SysWOW64/WindowsPowerShell/v1.0/powershell.exe' +} +foreach ($executable in $executables) { + $start = New-Object Diagnostics.ProcessStartInfo + $start.FileName = $executable + $start.Arguments = "-NoProfile -NonInteractive -OutputFormat Text -EncodedCommand $encoded" + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $child = New-Object Diagnostics.Process + $child.StartInfo = $start + try { + if (-not $child.Start()) { throw 'Could not start the isolated bootstrap check.' } + $null = $child.Handle + $stdout = $child.StandardOutput.ReadToEndAsync() + $stderr = $child.StandardError.ReadToEndAsync() + if (-not $child.WaitForExit(15000)) { + $child.Kill() + throw 'Installer bootstrap check exceeded 15 seconds.' + } + if ($child.ExitCode -ne 0) { throw "Fresh $executable bootstrap failed: $($stderr.Result)" } + if ($stdout.Result.Trim() -cne 'PASS: service types loaded without a service name') { throw 'Missing isolated bootstrap proof.' } + Write-Output "PASS: fresh $executable loads installer service types without SCM access." + } finally { + $child.Dispose() + } +} diff --git a/scripts/test-uninstall-policy.ps1 b/scripts/test-uninstall-policy.ps1 new file mode 100644 index 00000000..be1d15c7 --- /dev/null +++ b/scripts/test-uninstall-policy.ps1 @@ -0,0 +1,26 @@ +$ErrorActionPreference = 'Stop' +. "$PSScriptRoot/../ui-desktop/src-tauri/installer-release-protection.ps1" -PolicyOnly + +function Reject([scriptblock]$Action) { + $rejected = $false + try { & $Action } catch { $rejected = $true } + if (!$rejected) { throw 'Unsafe cleanup policy input was accepted.' } +} + +$paths = @(Get-TenebraCleanupPathChain 'C:\Program Files\Tenebra\tenebra-core.exe') +if ($paths.Count -ne 4 -or $paths[0] -cne 'C:\' -or $paths[3] -cne 'C:\Program Files\Tenebra\tenebra-core.exe') { throw 'Cleanup path chain is not rooted and ordered.' } +foreach ($path in @('', 'C:tenebra-core.exe', '\\host\share\tenebra-core.exe', 'C:\Tenebra\..\tenebra-core.exe', 'C:\Tenebra\other.exe', 'C:\Tenebra\tenebra-core.exe:stream', 'C:\Tenebra.\tenebra-core.exe')) { + Reject { Get-TenebraCleanupPathChain $path } +} + +$trusted = [Security.AccessControl.RawSecurityDescriptor]::new('O:BAG:BAD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;FR;;;BU)') +Assert-TenebraCleanupAcl $trusted $true +foreach ($sddl in @('O:BUG:BUD:(A;;FA;;;BU)', 'O:BAG:BAD:NO_ACCESS_CONTROL', 'O:BAG:BAD:(A;;FA;;;SY)(A;;FW;;;BU)', 'O:BAG:BAD:(A;;FA;;;SY)(A;;WD;;;BU)', 'O:BAG:BAD:(A;;FA;;;SY)(A;;WO;;;BU)')) { + $bad = [Security.AccessControl.RawSecurityDescriptor]::new($sddl) + Reject { Assert-TenebraCleanupAcl $bad $true } +} +$createChild = [Security.AccessControl.RawSecurityDescriptor]::new('O:BAG:BAD:(A;;FA;;;BA)(A;;0x6;;;BU)') +Assert-TenebraCleanupAcl $createChild $false +Reject { Assert-TenebraCleanupAcl $createChild $true } +Write-Output 'PASS: pure uninstall path and ACL policy; no files, services, processes or WFP objects opened.' +& "$PSScriptRoot/test-installer-service-bootstrap.ps1" diff --git a/scripts/test-wire-isolated.ps1 b/scripts/test-wire-isolated.ps1 new file mode 100644 index 00000000..b953d60b --- /dev/null +++ b/scripts/test-wire-isolated.ps1 @@ -0,0 +1,38 @@ +param( + [Parameter(Mandatory=$true)][string]$DependencyDirectory, + [Parameter(Mandatory=$true)][string]$OutputDirectory +) +$ErrorActionPreference = 'Stop' +# Build the real protocol implementation without Tauri/WebView or OS pipes. +# Only unrelated event/backend mappings are excluded. No network or daemon runs. +$repoRoot = Split-Path $PSScriptRoot -Parent +$wireSource = Get-Content -LiteralPath (Join-Path $repoRoot 'ui-desktop/src-tauri/src/backend/wire.rs') -Raw +$prefix = $wireSource.Substring(0, $wireSource.IndexOf('/// Read the stream to EOF')) +$prefix = [regex]::Replace($prefix, '(?s)use super::\{.*?\};', '') +$start = $wireSource.IndexOf('fn fail_all_pending(') +$end = $wireSource.IndexOf('/// Forward a protocol event') +$helpers = $wireSource.Substring($start, $end - $start) +$testsPath = (Join-Path $repoRoot 'ui-desktop/src-tauri/src/backend/wire_deadline_tests.rs').Replace('\', '/') +New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null +$harnessPath = Join-Path $OutputDirectory 'wire-isolated.rs' +[IO.File]::WriteAllText($harnessPath, $prefix + $helpers + "`n#[cfg(test)]`n#[path = `"$testsPath`"]`nmod deadline_tests;`n") +$jsonLib = Get-ChildItem -LiteralPath $DependencyDirectory -Filter 'libserde_json-*.rlib' | Sort-Object LastWriteTime -Descending | Select-Object -First 1 +$serdeLib = Get-ChildItem -LiteralPath $DependencyDirectory -Filter 'libserde-*.rlib' | Sort-Object LastWriteTime -Descending | Select-Object -First 1 +$exe = Join-Path $OutputDirectory 'wire-isolated.exe' +& rustc --edition 2021 --test $harnessPath -L "dependency=$DependencyDirectory" --extern "serde_json=$($jsonLib.FullName)" --extern "serde=$($serdeLib.FullName)" -o $exe +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +& $exe --test-threads=1 +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +# Also exercise the real State serde bridge, with no Tauri dependency. +$stateSource = Get-Content -LiteralPath (Join-Path $repoRoot 'ui-desktop/src-tauri/src/backend/mod.rs') -Raw +$stateStart = $stateSource.IndexOf('use serde::{') +$nodeStart = $stateSource.LastIndexOf('#[derive(', $stateSource.IndexOf('pub struct Node {')) +$stateTypes = $stateSource.Substring($stateStart, $nodeStart - $stateStart) +$stateTests = (Join-Path $repoRoot 'ui-desktop/src-tauri/src/backend/protection_relay_tests.rs').Replace('\', '/') +$statePath = Join-Path $OutputDirectory 'state-isolated.rs' +[IO.File]::WriteAllText($statePath, $stateTypes + "`n#[cfg(test)]`n#[path = `"$stateTests`"]`nmod protection_tests;`n") +$stateExe = Join-Path $OutputDirectory 'state-isolated.exe' +& rustc --edition 2021 --test $statePath -L "dependency=$DependencyDirectory" --extern "serde_json=$($jsonLib.FullName)" --extern "serde=$($serdeLib.FullName)" -o $stateExe +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +& $stateExe --test-threads=1 +exit $LASTEXITCODE diff --git a/scripts/uninstall-helper.test.mjs b/scripts/uninstall-helper.test.mjs new file mode 100644 index 00000000..27c1d576 --- /dev/null +++ b/scripts/uninstall-helper.test.mjs @@ -0,0 +1,26 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { renderUninstallHelper, sourcePath, outputPath } from './embed-uninstall-helper.mjs'; + +test('embedded uninstall helper is exactly the reviewed source and fits NSIS strings', () => { + const source = fs.readFileSync(sourcePath, 'utf8').replaceAll('\r\n', '\n'); + const generated = fs.readFileSync(outputPath, 'utf8').replaceAll('\r\n', '\n'); + assert.equal(generated, renderUninstallHelper(source)); + const chunks = [...generated.matchAll(/StrCpy \$0 "([A-Za-z0-9+/=]{64,})"/g)].map(m => m[1]); + assert.equal(Buffer.from(chunks.join(''), 'base64').toString('utf16le'), source); + assert.ok(generated.split('\n').every(line => line.length < 900)); + assert.ok(generated.includes('$$s=')); // literal PowerShell $, not an NSIS variable + for (let i = 0; i < chunks.length; i++) { + assert.ok(generated.includes(`SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS${i}", p 0)`)); + } +}); + +test('host protection cleanup is exclusive to explicit uninstall before service deletion', () => { + const hooks = fs.readFileSync(new URL('../ui-desktop/src-tauri/installer-hooks.nsh', import.meta.url), 'utf8'); + const uninstall = hooks.slice(hooks.indexOf('!macro NSIS_HOOK_PREUNINSTALL')); + assert.equal((hooks.match(/!insertmacro TenebraReleaseHostProtection/g) ?? []).length, 1); + assert.match(uninstall, /TenebraStopService[\s\S]*\$UpdateMode <> 1[\s\S]*TenebraReleaseHostProtection[\s\S]*sc\.exe" delete tenebra/); + assert.ok(!uninstall.includes('${FileExists}')); // missing core must fail closed + assert.equal((uninstall.match(/!insertmacro TenebraProbeHostProtection/g) ?? []).length, 2); +}); diff --git a/scripts/verify-core-build.mjs b/scripts/verify-core-build.mjs new file mode 100644 index 00000000..0b2d5452 --- /dev/null +++ b/scripts/verify-core-build.mjs @@ -0,0 +1,26 @@ +// Inspect a binary's embedded Go build metadata without executing the binary. +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { pathToFileURL } from 'node:url'; +export function verifyBuildInfo(text, { goVersion, revision, os, arch }) { + const actualGo = /^.+:\s+go(\S+)$/m.exec(text)?.[1]; + if (actualGo !== goVersion) throw new Error(`core Go version ${actualGo} differs from pinned ${goVersion}`); + const fields = new Map([...text.matchAll(/^\s*build\s+([^=\s]+)=(.+)$/gm)].map(m => [m[1], m[2]])); + for (const [key, want] of [['GOOS', os], ['GOARCH', arch], ['vcs.revision', revision], ['vcs.modified', 'false']]) { + if (fields.get(key) !== want) throw new Error(`core ${key}=${fields.get(key)}; expected ${want}`); + } +} +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const [binary, os, arch, output] = process.argv.slice(2); + if (!binary || !os || !arch || !output) throw new Error('usage: verify-core-build.mjs '); + const goVersion = readFileSync('.go-version', 'utf8').trim(); + const revision = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + const metadata = execFileSync('go', ['version', '-m', binary], { encoding: 'utf8' }); + verifyBuildInfo(metadata, { goVersion, revision, os, arch }); + const sha256 = createHash('sha256').update(readFileSync(binary)).digest('hex'); + writeFileSync(output, JSON.stringify({ binary, goVersion, revision, os, arch, sha256, metadata }, null, 2) + '\n'); + console.log(`verified core ${os}/${arch}: Go ${goVersion}, source ${revision}, sha256 ${sha256}`); + } catch (error) { console.error(error.message); process.exitCode = 1; } +} diff --git a/scripts/verify-core-build.test.mjs b/scripts/verify-core-build.test.mjs new file mode 100644 index 00000000..b6d1a724 --- /dev/null +++ b/scripts/verify-core-build.test.mjs @@ -0,0 +1,12 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { verifyBuildInfo } from './verify-core-build.mjs'; +const info = 'core.exe: go1.26.8\n\tpath\tgithub.com/Divaaaan/tenebra/cmd/tenebra-core\n\tbuild\tGOOS=windows\n\tbuild\tGOARCH=amd64\n\tbuild\tvcs.revision=abc123\n\tbuild\tvcs.modified=false\n'; +const expected = { goVersion: '1.26.8', revision: 'abc123', os: 'windows', arch: 'amd64' }; +test('build evidence validates exact toolchain, target, and clean source revision', () => { + assert.doesNotThrow(() => verifyBuildInfo(info, expected)); + for (const [before, after] of [['go1.26.8', 'go1.26.7'], ['GOARCH=amd64', 'GOARCH=arm64'], ['GOOS=windows', 'GOOS=linux'], ['abc123', 'old123'], ['vcs.modified=false', 'vcs.modified=true']]) { + assert.throws(() => verifyBuildInfo(info.replace(before, after), expected)); + } + assert.throws(() => verifyBuildInfo('', expected)); +}); diff --git a/ui-desktop/package-lock.json b/ui-desktop/package-lock.json index 1e1992bd..af2ee31a 100644 --- a/ui-desktop/package-lock.json +++ b/ui-desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "tenebra-desktop", - "version": "0.1.1", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tenebra-desktop", - "version": "0.1.1", + "version": "0.6.0", "license": "GPL-3.0-only", "dependencies": { "@fontsource-variable/jetbrains-mono": "5.2.8", diff --git a/ui-desktop/package.json b/ui-desktop/package.json index 408e29be..4ffebb47 100644 --- a/ui-desktop/package.json +++ b/ui-desktop/package.json @@ -1,6 +1,6 @@ { "name": "tenebra-desktop", - "version": "0.5.11", + "version": "0.6.0", "description": "Tenebra desktop client", "license": "GPL-3.0-only", "private": true, diff --git a/ui-desktop/src-tauri/Cargo.lock b/ui-desktop/src-tauri/Cargo.lock index b1d0d20f..d947b5ce 100644 --- a/ui-desktop/src-tauri/Cargo.lock +++ b/ui-desktop/src-tauri/Cargo.lock @@ -4259,7 +4259,7 @@ dependencies = [ [[package]] name = "tenebra-desktop" -version = "0.5.11" +version = "0.6.0" dependencies = [ "open", "serde", diff --git a/ui-desktop/src-tauri/Cargo.toml b/ui-desktop/src-tauri/Cargo.toml index 7b88507e..8eb91839 100644 --- a/ui-desktop/src-tauri/Cargo.toml +++ b/ui-desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tenebra-desktop" -version = "0.5.11" +version = "0.6.0" description = "Tenebra desktop client" authors = ["Tenebra contributors"] license = "GPL-3.0-only" @@ -39,17 +39,16 @@ url = "2" open = "5" [target.'cfg(windows)'.dependencies] -# Raw Win32 declarations for the named-pipe transport: PeekNamedPipe lets the -# client poll a synchronous pipe handle without wedging writes (see -# backend/pipe.rs), and the tests stand up an in-process pipe server with -# CreateNamedPipeW/ConnectNamedPipe. Already in the tree via tauri; pinned here -# as a direct use. +# Cancellable overlapped pipe I/O and read-only SCM/process identity checks. +# No service mutation or token rights are used by the GUI. windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_Pipes", + "Win32_System_Services", + "Win32_System_Threading", ] } [profile.release] diff --git a/ui-desktop/src-tauri/installer-hooks.nsh b/ui-desktop/src-tauri/installer-hooks.nsh index b5bee944..c9899ff4 100644 --- a/ui-desktop/src-tauri/installer-hooks.nsh +++ b/ui-desktop/src-tauri/installer-hooks.nsh @@ -24,16 +24,47 @@ ; installer without UI. External binaries are invoked by absolute path — the ; installer inherits the invoking user's PATH, which elevation must not trust. -!macro NSIS_HOOK_PREINSTALL - ; Stop a service left by a previous version so its binaries can be - ; replaced. `net stop` (unlike `sc stop`) waits for the service to report - ; stopped; on a first install the query fails and everything is skipped. - nsExec::Exec '"$SYSDIR\sc.exe" query tenebra' +!macro TenebraServiceFailure step + DetailPrint "Tenebra service ${step} failed (code $0)." + MessageBox MB_ICONSTOP|MB_OK "Tenebra could not ${step} its Windows service (code $0). Installation needs repair.$\r$\nRerun this installer as administrator. Check %ProgramData%\Tenebra\service.log and Windows Event Viewer. Existing profiles are preserved." /SD IDOK + SetErrorLevel 1 + Abort +!macroend + +!macro TenebraRequireSuccess step + ${If} $0 != 0 + !insertmacro TenebraServiceFailure "${step}" + ${EndIf} +!macroend + +!include "${__FILEDIR__}\installer-wfp-probe.nsh" +!include "${__FILEDIR__}\installer-release-protection.nsh" + +!macro TenebraStopService + ; 1060 means first install. Other query failures (including denied access) + ; must stop installation before replacing a live service's files. + nsExec::Exec /TIMEOUT=35000 '"$SYSDIR\sc.exe" query tenebra' Pop $0 - ${If} $0 = 0 - nsExec::Exec '"$SYSDIR\net.exe" stop tenebra /y' + ${If} $0 == "0" + nsExec::Exec /TIMEOUT=35000 '"$SYSDIR\sc.exe" stop tenebra' Pop $0 + ${If} $0 != 1062 + !insertmacro TenebraRequireSuccess "stop" + ${EndIf} + ; sc stop is asynchronous. WaitForStatus uses SCM's numeric state and is + ; independent of the localized sc.exe output. No PATH or profile scripts. + ; A fresh Windows PowerShell process has not loaded ServiceProcess yet. + ; Load it explicitly before constructing the controller (also on x86). + nsExec::Exec /TIMEOUT=35000 `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -Command "try { Add-Type -AssemblyName System.ServiceProcess -ErrorAction Stop; (New-Object System.ServiceProcess.ServiceController('tenebra')).WaitForStatus([System.ServiceProcess.ServiceControllerStatus]::Stopped,[TimeSpan]::FromSeconds(30)); exit 0 } catch { exit 1 }"` + Pop $0 + !insertmacro TenebraRequireSuccess "wait for stopped state of" + ${ElseIf} $0 != 1060 + !insertmacro TenebraServiceFailure "query" ${EndIf} +!macroend + +!macro NSIS_HOOK_PREINSTALL + !insertmacro TenebraStopService ; The stopped state can precede the process exit by a moment, and the file ; stays locked until then: probe the old binary with an append-mode open ; (a write-lock test) before letting the template overwrite it. Bounded so @@ -51,6 +82,10 @@ Sleep 500 IntOp $1 $1 - 1 ${LoopUntil} $1 < 1 + ${If} $1 < 1 + StrCpy $0 "binary still locked" + !insertmacro TenebraServiceFailure "replace files for" + ${EndIf} ${EndIf} !macroend @@ -129,28 +164,51 @@ ; ""..."" on the wire, which CommandLineToArgvW splits at the path's space: ; sc then sees binPath= C:\Program and answers with its usage text (1639), ; silently, and the service never exists. - nsExec::Exec '"$SYSDIR\sc.exe" create tenebra binPath= "\$\"$INSTDIR\tenebra-core.exe\$\"" start= auto DisplayName= "Tenebra"' + nsExec::Exec /TIMEOUT=35000 '"$SYSDIR\sc.exe" create tenebra binPath= "\$\"$INSTDIR\tenebra-core.exe\$\"" start= auto DisplayName= "Tenebra"' + Pop $0 + ${If} $0 != 1073 + !insertmacro TenebraRequireSuccess "register" + ${EndIf} + nsExec::Exec /TIMEOUT=35000 '"$SYSDIR\sc.exe" config tenebra binPath= "\$\"$INSTDIR\tenebra-core.exe\$\"" start= auto obj= LocalSystem DisplayName= "Tenebra"' Pop $0 - nsExec::Exec '"$SYSDIR\sc.exe" config tenebra binPath= "\$\"$INSTDIR\tenebra-core.exe\$\"" start= auto DisplayName= "Tenebra"' + !insertmacro TenebraRequireSuccess "configure" + nsExec::Exec /TIMEOUT=35000 '"$SYSDIR\sc.exe" description tenebra "Runs the Tenebra VPN tunnel and serves the local control endpoint."' Pop $0 - nsExec::Exec '"$SYSDIR\sc.exe" description tenebra "Runs the Tenebra VPN tunnel and serves the local control endpoint."' + !insertmacro TenebraRequireSuccess "describe" + nsExec::Exec /TIMEOUT=35000 '"$SYSDIR\sc.exe" start tenebra' Pop $0 - ; A failed start is not an installer failure: the service logs the reason - ; to %ProgramData%\Tenebra\service.log and start=auto retries at boot. - nsExec::Exec '"$SYSDIR\sc.exe" start tenebra' + ${If} $0 != 1056 + !insertmacro TenebraRequireSuccess "start" + ${EndIf} + ; This executable has just been installed in the administrator-owned install + ; directory. The helper runs BEFORE Tauri initialization: no window, sidecar, + ; autostart, updater or imports. It authenticates SCM PID, LocalSystem account and registered image, + ; requires RUNNING and a status response matching its compiled-in version. + nsExec::Exec /TIMEOUT=35000 '"$INSTDIR\${MAINBINARYNAME}.exe" --service-check' Pop $0 + !insertmacro TenebraRequireSuccess "verify readiness of" !macroend !macro NSIS_HOOK_PREUNINSTALL - ; Stop before the files go away; net stop waits, so tenebra-core.exe is - ; deletable when the section runs. During an update ($UpdateMode — the new - ; installer runs this uninstaller with /UPDATE before laying its own files) - ; the registration is kept: POSTINSTALL re-points and restarts it, and not - ; deleting avoids the marked-for-deletion limbo an open SCM handle causes. - nsExec::Exec '"$SYSDIR\net.exe" stop tenebra /y' - Pop $0 + ; The same checked stop applies before both update and real uninstall. + ; Keep the registration through updates; POSTINSTALL reconfigures it. + !insertmacro TenebraStopService ${If} $UpdateMode <> 1 - nsExec::Exec '"$SYSDIR\sc.exe" delete tenebra' + ; Legacy cores cannot create T05 policy and do not implement its remover. + ; A read-only absence proof permits their uninstall without executing them. + !insertmacro TenebraProbeHostProtection + ${If} $0 == "present" + !insertmacro TenebraReleaseHostProtection + !insertmacro TenebraProbeHostProtection + ${If} $0 != "absent" + StrCpy $0 "owned WFP objects remain after cleanup" + !insertmacro TenebraServiceFailure "confirm host protection removal before unregistering" + ${EndIf} + ${EndIf} + nsExec::Exec /TIMEOUT=35000 '"$SYSDIR\sc.exe" delete tenebra' Pop $0 + ${If} $0 != 1060 + !insertmacro TenebraRequireSuccess "unregister" + ${EndIf} ${EndIf} !macroend diff --git a/ui-desktop/src-tauri/installer-release-protection.nsh b/ui-desktop/src-tauri/installer-release-protection.nsh new file mode 100644 index 00000000..0920a409 --- /dev/null +++ b/ui-desktop/src-tauri/installer-release-protection.nsh @@ -0,0 +1,184 @@ +; Generated by scripts/embed-uninstall-helper.mjs. Edit the .ps1 source. +; Constant chunks avoid NSIS string limits and execution of a user-writable script. +!macro TenebraReleaseHostProtection + StrCpy $0 "$INSTDIR\tenebra-core.exe" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_CORE", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "cABhAHIAYQBtACgAWwBzAHcAaQB0AGMAaABdACQAUABvAGwAaQBjAHkATwBuAGwAeQApAAoACgAjACAARQBtAGIAZQBkAGQAZQBkACAAYQBzACAAYwBvAG4AcwB0AGEAbgB0ACAAcwBvAHUAcgBjAGUAIABpAG4AIAB0AGgAZQAgAHUAbgBpAG4AcwB0AGEAbABsAGUAcgAsACAAbgBlAHYAZQByACAAbABvAGEAZABlAGQAIABmAHIAbwBtACAAYQBuACAAaQBuAHMAdABhAGwAbABlAGQACgAjACAAbwByACAAdABlAG0AcABvAHIAYQByAHkAIABzAGMAcgBpAHAAdAAgAGYAaQBsAGUALgAgAC0AUABvAGwAaQBjAHkATwBuAGwAeQAgAGUAeABwAG8AcwBlAHMAIABvAG4AbAB5ACAAcAB1AHIAZQAgAHAAYQB0AGgALwBBAEMATAAgAGMAaABlAGMAawBzACAAdABvACAAQwBJAC4ACgAkAEUA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS0", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "cgByAG8AcgBBAGMAdABpAG8AbgBQAHIAZQBmAGUAcgBlAG4AYwBlACAAPQAgACcAUwB0AG8AcAAnAAoACgBmAHUAbgBjAHQAaQBvAG4AIABHAGUAdAAtAFQAZQBuAGUAYgByAGEAQwBsAGUAYQBuAHUAcABQAGEAdABoAEMAaABhAGkAbgAoAFsAcwB0AHIAaQBuAGcAXQAkAEMAYQBuAGQAaQBkAGEAdABlACkAIAB7AAoAIAAgACAAIABpAGYAIAAoACQAQwBhAG4AZABpAGQAYQB0AGUAIAAtAG4AbwB0AG0AYQB0AGMAaAAgACcAXgBbAEEALQBaAGEALQB6AF0AOgBcAFwAJwAgAC0AbwByACAAJABDAGEAbgBkAGkAZABhAHQAZQAuAFMAdQBiAHMAdAByAGkAbgBnACgAMgApAC4AQwBvAG4AdABhAGkAbgBzACgAJwA6ACcAKQAgAC0AbwByACAAJABDAGEAbgBkAGkAZABhAHQAZQAuAEMA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS1", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "bwBuAHQAYQBpAG4AcwAoAFsAYwBoAGEAcgBdADAAKQApACAAewAKACAAIAAgACAAIAAgACAAIAB0AGgAcgBvAHcAIAAnAFAAcgBvAHQAZQBjAHQAaQBvAG4AIABjAGwAZQBhAG4AdQBwACAAcgBlAHEAdQBpAHIAZQBzACAAYQBuACAAYQBiAHMAbwBsAHUAdABlACAAbABvAGMAYQBsACAAaQBuAHMAdABhAGwAbABlAGQAIABjAG8AcgBlACAAcABhAHQAaAAuACcACgAgACAAIAAgAH0ACgAgACAAIAAgACQAYwBvAHIAZQAgAD0AIABbAEkATwAuAFAAYQB0AGgAXQA6ADoARwBlAHQARgB1AGwAbABQAGEAdABoACgAJABDAGEAbgBkAGkAZABhAHQAZQApAAoAIAAgACAAIABpAGYAIAAoACQAYwBvAHIAZQAgAC0AYwBuAGUAIAAkAEMAYQBuAGQAaQBkAGEAdABlACAALQBvAHIAIABbAEkA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS2", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "TwAuAFAAYQB0AGgAXQA6ADoARwBlAHQARgBpAGwAZQBOAGEAbQBlACgAJABjAG8AcgBlACkAIAAtAGkAbgBlACAAJwB0AGUAbgBlAGIAcgBhAC0AYwBvAHIAZQAuAGUAeABlACcAKQAgAHsACgAgACAAIAAgACAAIAAgACAAdABoAHIAbwB3ACAAJwBQAHIAbwB0AGUAYwB0AGkAbwBuACAAYwBsAGUAYQBuAHUAcAAgAGUAeABlAGMAdQB0AGEAYgBsAGUAIABwAGEAdABoACAAaQBzACAAYQBtAGIAaQBnAHUAbwB1AHMALgAnAAoAIAAgACAAIAB9AAoAIAAgACAAIAAkAGMAaABhAGkAbgAgAD0AIABAACgAKQAKACAAIAAgACAAZgBvAHIAIAAoACQAcABhAHQAaAAgAD0AIAAkAGMAbwByAGUAOwAgACQAcABhAHQAaAA7ACAAJABwAGEAdABoACAAPQAgAFsASQBPAC4AUABhAHQAaABdADoA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS3", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "OgBHAGUAdABEAGkAcgBlAGMAdABvAHIAeQBOAGEAbQBlACgAJABwAGEAdABoACkAKQAgAHsACgAgACAAIAAgACAAIAAgACAAJABuAGEAbQBlACAAPQAgAFsASQBPAC4AUABhAHQAaABdADoAOgBHAGUAdABGAGkAbABlAE4AYQBtAGUAKAAkAHAAYQB0AGgAKQAKACAAIAAgACAAIAAgACAAIABpAGYAIAAoACQAbgBhAG0AZQAgAC0AYQBuAGQAIAAoACQAbgBhAG0AZQAuAFQAcgBpAG0ARQBuAGQAKAAnACAAJwAsACAAJwAuACcAKQAgAC0AYwBuAGUAIAAkAG4AYQBtAGUAKQApACAAewAgAHQAaAByAG8AdwAgACcAQQBtAGIAaQBnAHUAbwB1AHMAIABjAGwAZQBhAG4AdQBwACAAcABhAHQAaAAgAGMAbwBtAHAAbwBuAGUAbgB0AC4AJwAgAH0ACgAgACAAIAAgACAAIAAgACAAJABjAGgA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS4", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "YQBpAG4AIAA9ACAAQAAoACQAcABhAHQAaAApACAAKwAgACQAYwBoAGEAaQBuAAoAIAAgACAAIAB9AAoAIAAgACAAIAByAGUAdAB1AHIAbgAgACQAYwBoAGEAaQBuAAoAfQAKAAoAZgB1AG4AYwB0AGkAbwBuACAAQQBzAHMAZQByAHQALQBUAGUAbgBlAGIAcgBhAEMAbABlAGEAbgB1AHAAQQBjAGwAKABbAFMAZQBjAHUAcgBpAHQAeQAuAEEAYwBjAGUAcwBzAEMAbwBuAHQAcgBvAGwALgBSAGEAdwBTAGUAYwB1AHIAaQB0AHkARABlAHMAYwByAGkAcAB0AG8AcgBdACQARABlAHMAYwByAGkAcAB0AG8AcgAsACAAWwBiAG8AbwBsAF0AJABGAGkAbABlACkAIAB7AAoAIAAgACAAIAAkAHQAcgB1AHMAdABlAGQAIAA9ACAAQAAoACcAUwAtADEALQA1AC0AMQA4ACcALAAgACcAUwAtADEA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS5", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "LQA1AC0AMwAyAC0ANQA0ADQAJwAsACAAJwBTAC0AMQAtADUALQA4ADAALQA5ADUANgAwADAAOAA4ADgANQAtADMANAAxADgANQAyADIANgA0ADkALQAxADgAMwAxADAAMwA4ADAANAA0AC0AMQA4ADUAMwAyADkAMgA2ADMAMQAtADIAMgA3ADEANAA3ADgANAA2ADQAJwApAAoAIAAgACAAIABpAGYAIAAoACEAJABEAGUAcwBjAHIAaQBwAHQAbwByAC4ATwB3AG4AZQByACAALQBvAHIAIAAkAEQAZQBzAGMAcgBpAHAAdABvAHIALgBPAHcAbgBlAHIALgBWAGEAbAB1AGUAIAAtAG4AbwB0AGkAbgAgACQAdAByAHUAcwB0AGUAZAAgAC0AbwByACAAJABuAHUAbABsACAALQBlAHEAIAAkAEQAZQBzAGMAcgBpAHAAdABvAHIALgBEAGkAcwBjAHIAZQB0AGkAbwBuAGEAcgB5AEEAYwBsACkA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS6", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "IAB7AAoAIAAgACAAIAAgACAAIAAgAHQAaAByAG8AdwAgACcAQwBsAGUAYQBuAHUAcAAgAHAAYQB0AGgAIABuAGUAZQBkAHMAIABhAGQAbQBpAG4AaQBzAHQAcgBhAHQAbwByACAAbwB3AG4AZQByAHMAaABpAHAAIABhAG4AZAAgAGEAIAByAGUAcwB0AHIAaQBjAHQAaQB2AGUAIABEAEEAQwBMAC4AJwAKACAAIAAgACAAfQAKACAAIAAgACAAIwAgAEEAbgBjAGUAcwB0AG8AcgBzACAAbQBhAHkAIABhAGwAbABvAHcAIABjAHIAZQBhAHQAaQBvAG4AIABvAGYAIAB1AG4AcgBlAGwAYQB0AGUAZAAgAGMAaABpAGwAZAByAGUAbgAgACgAdABoAGUAIAB2AG8AbAB1AG0AZQAgAHIAbwBvAHQAIABkAG8AZQBzACkALgAKACAAIAAgACAAIwAgAE0AdQB0AGEAdABpAG8AbgAsACAAZABlAGwA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS7", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "ZQB0AGUALQBjAGgAaQBsAGQALAAgAEEAQwBMAC8AbwB3AG4AZQByACAAYwBoAGEAbgBnAGUAcwAgAGEAbgBkACAAYQBsAGwAIAB3AHIAaQB0AGUAcwAgAHQAbwAgAHQAaABlACAARQBYAEUAIABhAHIAZQAgAGQAZQBuAGkAZQBkAC4ACgAgACAAIAAgACQAbQB1AHQAYQB0AGkAbwBuACAAPQAgADAAeAA1ADIAMABEADAAMQA1ADAATAAKACAAIAAgACAAaQBmACAAKAAkAEYAaQBsAGUAKQAgAHsAIAAkAG0AdQB0AGEAdABpAG8AbgAgAD0AIAAkAG0AdQB0AGEAdABpAG8AbgAgAC0AYgBvAHIAIAA2ACAAfQAKACAAIAAgACAAZgBvAHIAZQBhAGMAaAAgACgAJABhAGMAZQAgAGkAbgAgACQARABlAHMAYwByAGkAcAB0AG8AcgAuAEQAaQBzAGMAcgBlAHQAaQBvAG4AYQByAHkAQQBjAGwA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS8", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "KQAgAHsACgAgACAAIAAgACAAIAAgACAAaQBmACAAKAAoAFsAaQBuAHQAXQAkAGEAYwBlAC4AQQBjAGUARgBsAGEAZwBzACAALQBiAGEAbgBkACAAWwBpAG4AdABdAFsAUwBlAGMAdQByAGkAdAB5AC4AQQBjAGMAZQBzAHMAQwBvAG4AdAByAG8AbAAuAEEAYwBlAEYAbABhAGcAcwBdADoAOgBJAG4AaABlAHIAaQB0AE8AbgBsAHkAKQAgAC0AbgBlACAAMAApACAAewAgAGMAbwBuAHQAaQBuAHUAZQAgAH0ACgAgACAAIAAgACAAIAAgACAAaQBmACAAKAAkAGEAYwBlACAALQBpAHMAbgBvAHQAIABbAFMAZQBjAHUAcgBpAHQAeQAuAEEAYwBjAGUAcwBzAEMAbwBuAHQAcgBvAGwALgBDAG8AbQBtAG8AbgBBAGMAZQBdACkAIAB7ACAAdABoAHIAbwB3ACAAJwBVAG4AcwB1AHAAcABvAHIA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS9", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "dABlAGQAIABjAGwAZQBhAG4AdQBwACAAcABhAHQAaAAgAEEAQwBMACAAZQBuAHQAcgB5AC4AJwAgAH0ACgAgACAAIAAgACAAIAAgACAAaQBmACAAKAAkAGEAYwBlAC4AQQBjAGUAUQB1AGEAbABpAGYAaQBlAHIAIAAtAGUAcQAgAFsAUwBlAGMAdQByAGkAdAB5AC4AQQBjAGMAZQBzAHMAQwBvAG4AdAByAG8AbAAuAEEAYwBlAFEAdQBhAGwAaQBmAGkAZQByAF0AOgA6AEEAYwBjAGUAcwBzAEQAZQBuAGkAZQBkACkAIAB7ACAAYwBvAG4AdABpAG4AdQBlACAAfQAKACAAIAAgACAAIAAgACAAIABpAGYAIAAoACQAYQBjAGUALgBBAGMAZQBRAHUAYQBsAGkAZgBpAGUAcgAgAC0AbgBlACAAWwBTAGUAYwB1AHIAaQB0AHkALgBBAGMAYwBlAHMAcwBDAG8AbgB0AHIAbwBsAC4AQQBjAGUA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS10", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "UQB1AGEAbABpAGYAaQBlAHIAXQA6ADoAQQBjAGMAZQBzAHMAQQBsAGwAbwB3AGUAZAAgAC0AbwByACAAJABhAGMAZQAuAEkAcwBDAGEAbABsAGIAYQBjAGsAKQAgAHsAIAB0AGgAcgBvAHcAIAAnAFUAbgBzAHUAcABwAG8AcgB0AGUAZAAgAGMAbABlAGEAbgB1AHAAIABwAGEAdABoACAAQQBDAEwAIABlAG4AdAByAHkALgAnACAAfQAKACAAIAAgACAAIAAgACAAIABpAGYAIAAoACQAYQBjAGUALgBTAGUAYwB1AHIAaQB0AHkASQBkAGUAbgB0AGkAZgBpAGUAcgAuAFYAYQBsAHUAZQAgAC0AbgBvAHQAaQBuACAAJAB0AHIAdQBzAHQAZQBkACAALQBhAG4AZAAgACgAKABbAGwAbwBuAGcAXQAkAGEAYwBlAC4AQQBjAGMAZQBzAHMATQBhAHMAawAgAC0AYgBhAG4AZAAgACQAbQB1AHQA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS11", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "YQB0AGkAbwBuACkAIAAtAG4AZQAgADAAKQApACAAewAKACAAIAAgACAAIAAgACAAIAAgACAAIAAgAHQAaAByAG8AdwAgACcAQwBsAGUAYQBuAHUAcAAgAHAAYQB0AGgAIABpAHMAIAB3AHIAaQB0AGEAYgBsAGUAIABiAHkAIABhACAAbgBvAG4ALQBhAGQAbQBpAG4AaQBzAHQAcgBhAHQAbwByAC4AJwAKACAAIAAgACAAIAAgACAAIAB9AAoAIAAgACAAIAB9AAoAfQAKAAoAZgB1AG4AYwB0AGkAbwBuACAAQQBzAHMAZQByAHQALQBUAGUAbgBlAGIAcgBhAEMAbABlAGEAbgB1AHAASQBtAGEAZwBlACgAWwBzAHQAcgBpAG4AZwBbAF0AXQAkAEMAaABhAGkAbgApACAAewAKACAAIAAgACAAZgBvAHIAZQBhAGMAaAAgACgAJABwAGEAdABoACAAaQBuACAAJABDAGgAYQBpAG4AKQAgAHsA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS12", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "CgAgACAAIAAgACAAIAAgACAAJABpAHQAZQBtACAAPQAgAEcAZQB0AC0ASQB0AGUAbQAgAC0ATABpAHQAZQByAGEAbABQAGEAdABoACAAJABwAGEAdABoACAALQBGAG8AcgBjAGUACgAgACAAIAAgACAAIAAgACAAaQBmACAAKAAoAFsAaQBuAHQAXQAkAGkAdABlAG0ALgBBAHQAdAByAGkAYgB1AHQAZQBzACAALQBiAGEAbgBkACAAWwBpAG4AdABdAFsASQBPAC4ARgBpAGwAZQBBAHQAdAByAGkAYgB1AHQAZQBzAF0AOgA6AFIAZQBwAGEAcgBzAGUAUABvAGkAbgB0ACkAIAAtAG4AZQAgADAAKQAgAHsAIAB0AGgAcgBvAHcAIAAnAEMAbABlAGEAbgB1AHAAIABwAGEAdABoACAAYwBvAG4AdABhAGkAbgBzACAAYQAgAHIAZQBwAGEAcgBzAGUAIABwAG8AaQBuAHQALgAnACAAfQAKACAA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS13", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "IAAgACAAIAAgACAAIAAkAGkAcwBGAGkAbABlACAAPQAgACQAcABhAHQAaAAgAC0AYwBlAHEAIAAkAEMAaABhAGkAbgBbAC0AMQBdAAoAIAAgACAAIAAgACAAIAAgAGkAZgAgACgAJABpAHQAZQBtAC4AUABTAEkAcwBDAG8AbgB0AGEAaQBuAGUAcgAgAC0AZQBxACAAJABpAHMARgBpAGwAZQApACAAewAgAHQAaAByAG8AdwAgACcAQwBsAGUAYQBuAHUAcAAgAHAAYQB0AGgAIABoAGEAcwAgAHQAaABlACAAdwByAG8AbgBnACAAZgBpAGwAZQAgAHQAeQBwAGUALgAnACAAfQAKACAAIAAgACAAIAAgACAAIAAkAGEAYwBsACAAPQAgAEcAZQB0AC0AQQBjAGwAIAAtAEwAaQB0AGUAcgBhAGwAUABhAHQAaAAgACQAcABhAHQAaAAKACAAIAAgACAAIAAgACAAIAAkAGQAZQBzAGMAcgBpAHAA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS14", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "dABvAHIAIAA9ACAAWwBTAGUAYwB1AHIAaQB0AHkALgBBAGMAYwBlAHMAcwBDAG8AbgB0AHIAbwBsAC4AUgBhAHcAUwBlAGMAdQByAGkAdAB5AEQAZQBzAGMAcgBpAHAAdABvAHIAXQA6ADoAbgBlAHcAKAAkAGEAYwBsAC4ARwBlAHQAUwBlAGMAdQByAGkAdAB5AEQAZQBzAGMAcgBpAHAAdABvAHIAQgBpAG4AYQByAHkARgBvAHIAbQAoACkALAAgADAAKQAKACAAIAAgACAAIAAgACAAIABBAHMAcwBlAHIAdAAtAFQAZQBuAGUAYgByAGEAQwBsAGUAYQBuAHUAcABBAGMAbAAgACQAZABlAHMAYwByAGkAcAB0AG8AcgAgACQAaQBzAEYAaQBsAGUACgAgACAAIAAgAH0ACgB9AAoACgBpAGYAIAAoACQAUABvAGwAaQBjAHkATwBuAGwAeQApACAAewAgAHIAZQB0AHUAcgBuACAAfQAKAAoA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS15", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "JABpAG0AYQBnAGUAIAA9ACAAJABuAHUAbABsAAoAJABwAHIAbwBjAGUAcwBzACAAPQAgACQAbgB1AGwAbAAKAHQAcgB5ACAAewAKACAAIAAgACAAJABjAGgAYQBpAG4AIAA9ACAAQAAoAEcAZQB0AC0AVABlAG4AZQBiAHIAYQBDAGwAZQBhAG4AdQBwAFAAYQB0AGgAQwBoAGEAaQBuACAAKABbAEUAbgB2AGkAcgBvAG4AbQBlAG4AdABdADoAOgBHAGUAdABFAG4AdgBpAHIAbwBuAG0AZQBuAHQAVgBhAHIAaQBhAGIAbABlACgAJwBUAEUATgBFAEIAUgBBAF8AUgBFAEwARQBBAFMARQBfAEMATwBSAEUAJwApACkAKQAKACAAIAAgACAAaQBmACAAKABbAEkATwAuAEQAcgBpAHYAZQBJAG4AZgBvAF0AOgA6AG4AZQB3ACgAJABjAGgAYQBpAG4AWwAwAF0AKQAuAEQAcgBpAHYAZQBUAHkA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS16", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "cABlACAALQBuAGUAIABbAEkATwAuAEQAcgBpAHYAZQBUAHkAcABlAF0AOgA6AEYAaQB4AGUAZAApACAAewAgAHQAaAByAG8AdwAgACcAQwBsAGUAYQBuAHUAcAAgAGkAbQBhAGcAZQAgAG0AdQBzAHQAIABiAGUAIABvAG4AIABhACAAbABvAGMAYQBsACAAZgBpAHgAZQBkACAAZAByAGkAdgBlAC4AJwAgAH0ACgAgACAAIAAgAEEAcwBzAGUAcgB0AC0AVABlAG4AZQBiAHIAYQBDAGwAZQBhAG4AdQBwAEkAbQBhAGcAZQAgACQAYwBoAGEAaQBuAAoAIAAgACAAIAAkAGMAbwByAGUAIAA9ACAAJABjAGgAYQBpAG4AWwAtADEAXQAKACAAIAAgACAAIwAgAEgAbwBsAGQAIABhACAAbgBvAG4ALQBpAG4AaABlAHIAaQB0AGEAYgBsAGUAIABoAGEAbgBkAGwAZQAgAGQAZQBuAHkAaQBuAGcA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS17", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "IAB3AHIAaQB0AGUAcwAvAGQAZQBsAGUAdABlACAAdwBoAGkAbABlACAAbABhAHUAbgBjAGgAaQBuAGcAIAB0AGgAZQAKACAAIAAgACAAIwAgAGMAaABlAGMAawBlAGQAIABpAG0AYQBnAGUALgAgAEUAeABpAHMAdABpAG4AZwAgAGgAbwBzAHQAaQBsAGUAIAB3AHIAaQB0AGUAIABoAGEAbgBkAGwAZQBzACAAYwBhAHUAcwBlACAAdABoAGkAcwAgAG8AcABlAG4AIAB0AG8AIABmAGEAaQBsAC4ACgAgACAAIAAgACQAaQBtAGEAZwBlACAAPQAgAFsASQBPAC4ARgBpAGwAZQBdADoAOgBPAHAAZQBuACgAJABjAG8AcgBlACwAIABbAEkATwAuAEYAaQBsAGUATQBvAGQAZQBdADoAOgBPAHAAZQBuACwAIABbAEkATwAuAEYAaQBsAGUAQQBjAGMAZQBzAHMAXQA6ADoAUgBlAGEAZAAsACAA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS18", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "WwBJAE8ALgBGAGkAbABlAFMAaABhAHIAZQBdADoAOgBSAGUAYQBkACkACgAgACAAIAAgACQAcwB0AGEAcgB0ACAAPQAgAFsARABpAGEAZwBuAG8AcwB0AGkAYwBzAC4AUAByAG8AYwBlAHMAcwBTAHQAYQByAHQASQBuAGYAbwBdADoAOgBuAGUAdwAoACkACgAgACAAIAAgACQAcwB0AGEAcgB0AC4ARgBpAGwAZQBOAGEAbQBlACAAPQAgACQAYwBvAHIAZQAKACAAIAAgACAAJABzAHQAYQByAHQALgBXAG8AcgBrAGkAbgBnAEQAaQByAGUAYwB0AG8AcgB5ACAAPQAgAFsASQBPAC4AUABhAHQAaABdADoAOgBHAGUAdABEAGkAcgBlAGMAdABvAHIAeQBOAGEAbQBlACgAJABjAG8AcgBlACkACgAgACAAIAAgACQAcwB0AGEAcgB0AC4AQQByAGcAdQBtAGUAbgB0AHMAIAA9ACAAJwAtAC0A" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS19", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "cgBlAGwAZQBhAHMAZQAtAGgAbwBzAHQALQBwAHIAbwB0AGUAYwB0AGkAbwBuACcACgAgACAAIAAgACQAcwB0AGEAcgB0AC4AVQBzAGUAUwBoAGUAbABsAEUAeABlAGMAdQB0AGUAIAA9ACAAJABmAGEAbABzAGUACgAgACAAIAAgACQAcwB0AGEAcgB0AC4AQwByAGUAYQB0AGUATgBvAFcAaQBuAGQAbwB3ACAAPQAgACQAdAByAHUAZQAKACAAIAAgACAAJABwAHIAbwBjAGUAcwBzACAAPQAgAFsARABpAGEAZwBuAG8AcwB0AGkAYwBzAC4AUAByAG8AYwBlAHMAcwBdADoAOgBTAHQAYQByAHQAKAAkAHMAdABhAHIAdAApAAoAIAAgACAAIABpAGYAIAAoACEAJABwAHIAbwBjAGUAcwBzAC4AVwBhAGkAdABGAG8AcgBFAHgAaQB0ACgAMgAwADAAMAAwACkAKQAgAHsACgAgACAAIAAgACAA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS20", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "IAAgACAAJABwAHIAbwBjAGUAcwBzAC4ASwBpAGwAbAAoACkACgAgACAAIAAgACAAIAAgACAAJABuAHUAbABsACAAPQAgACQAcAByAG8AYwBlAHMAcwAuAFcAYQBpAHQARgBvAHIARQB4AGkAdAAoADEAMAAwADAAKQAKACAAIAAgACAAIAAgACAAIAB0AGgAcgBvAHcAIAAnAE8AdwBuAGUAZAAgAGgAbwBzAHQALQBwAHIAbwB0AGUAYwB0AGkAbwBuACAAYwBsAGUAYQBuAHUAcAAgAHQAaQBtAGUAZAAgAG8AdQB0ADsAIAByAGUAcABhAGkAcgAgAGIAZQBmAG8AcgBlACAAdQBuAGkAbgBzAHQAYQBsAGwAaQBuAGcALgAnAAoAIAAgACAAIAB9AAoAIAAgACAAIABpAGYAIAAoACQAcAByAG8AYwBlAHMAcwAuAEUAeABpAHQAQwBvAGQAZQAgAC0AbgBlACAAMAApACAAewAgAHQAaAByAG8A" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS21", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "dwAgACIATwB3AG4AZQBkACAAaABvAHMAdAAtAHAAcgBvAHQAZQBjAHQAaQBvAG4AIABjAGwAZQBhAG4AdQBwACAAdwBhAHMAIABuAG8AdAAgAGMAbwBuAGYAaQByAG0AZQBkACAAKABlAHgAaQB0ACAAJAAoACQAcAByAG8AYwBlAHMAcwAuAEUAeABpAHQAQwBvAGQAZQApACkAOwAgAHIAZQBwAGEAaQByACAAdwBpAHQAaAAgAGEAIABwAHIAbwB0AGUAYwB0AGkAbwBuAC0AYQB3AGEAcgBlACAAaQBuAHMAdABhAGwAbABlAHIALgAiACAAfQAKACAAIAAgACAAZQB4AGkAdAAgADAACgB9ACAAYwBhAHQAYwBoACAAewAKACAAIAAgACAAWwBDAG8AbgBzAG8AbABlAF0AOgA6AEUAcgByAG8AcgAuAFcAcgBpAHQAZQBMAGkAbgBlACgAIgBUAGUAbgBlAGIAcgBhACAAcAByAG8AdABlAGMA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS22", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "dABpAG8AbgAgAGMAbABlAGEAbgB1AHAAIAByAGUAZgB1AHMAZQBkADoAIAAkACgAJABfAC4ARQB4AGMAZQBwAHQAaQBvAG4ALgBNAGUAcwBzAGEAZwBlACkAIgApAAoAIAAgACAAIABlAHgAaQB0ACAAMQAKAH0AIABmAGkAbgBhAGwAbAB5ACAAewAKACAAIAAgACAAaQBmACAAKAAkAHAAcgBvAGMAZQBzAHMAKQAgAHsAIAAkAHAAcgBvAGMAZQBzAHMALgBEAGkAcwBwAG8AcwBlACgAKQAgAH0ACgAgACAAIAAgAGkAZgAgACgAJABpAG0AYQBnAGUAKQAgAHsAIAAkAGkAbQBhAGcAZQAuAEQAaQBzAHAAbwBzAGUAKAApACAAfQAKAH0ACgA=" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS23", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + nsExec::ExecToLog /TIMEOUT=35000 `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -Command "$$s=(0..23|ForEach-Object{[Environment]::GetEnvironmentVariable('TENEBRA_RELEASE_PS'+$$_)})-join'';& ([ScriptBlock]::Create([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($$s))))"` + Pop $0 + Push $0 + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_CORE", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS0", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS1", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS2", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS3", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS4", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS5", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS6", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS7", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS8", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS9", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS10", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS11", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS12", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS13", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS14", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS15", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS16", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS17", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS18", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS19", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS20", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS21", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS22", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS23", p 0) i.r0' + Pop $0 + !insertmacro TenebraRequireSuccess "release owned host protection before unregistering" +!macroend diff --git a/ui-desktop/src-tauri/installer-release-protection.ps1 b/ui-desktop/src-tauri/installer-release-protection.ps1 new file mode 100644 index 00000000..e12d4085 --- /dev/null +++ b/ui-desktop/src-tauri/installer-release-protection.ps1 @@ -0,0 +1,88 @@ +param([switch]$PolicyOnly) + +# Embedded as constant source in the uninstaller, never loaded from an installed +# or temporary script file. -PolicyOnly exposes only pure path/ACL checks to CI. +$ErrorActionPreference = 'Stop' + +function Get-TenebraCleanupPathChain([string]$Candidate) { + if ($Candidate -notmatch '^[A-Za-z]:\\' -or $Candidate.Substring(2).Contains(':') -or $Candidate.Contains([char]0)) { + throw 'Protection cleanup requires an absolute local installed core path.' + } + $core = [IO.Path]::GetFullPath($Candidate) + if ($core -cne $Candidate -or [IO.Path]::GetFileName($core) -ine 'tenebra-core.exe') { + throw 'Protection cleanup executable path is ambiguous.' + } + $chain = @() + for ($path = $core; $path; $path = [IO.Path]::GetDirectoryName($path)) { + $name = [IO.Path]::GetFileName($path) + if ($name -and ($name.TrimEnd(' ', '.') -cne $name)) { throw 'Ambiguous cleanup path component.' } + $chain = @($path) + $chain + } + return $chain +} + +function Assert-TenebraCleanupAcl([Security.AccessControl.RawSecurityDescriptor]$Descriptor, [bool]$File) { + $trusted = @('S-1-5-18', 'S-1-5-32-544', 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464') + if (!$Descriptor.Owner -or $Descriptor.Owner.Value -notin $trusted -or $null -eq $Descriptor.DiscretionaryAcl) { + throw 'Cleanup path needs administrator ownership and a restrictive DACL.' + } + # Ancestors may allow creation of unrelated children (the volume root does). + # Mutation, delete-child, ACL/owner changes and all writes to the EXE are denied. + $mutation = 0x520D0150L + if ($File) { $mutation = $mutation -bor 6 } + foreach ($ace in $Descriptor.DiscretionaryAcl) { + if (([int]$ace.AceFlags -band [int][Security.AccessControl.AceFlags]::InheritOnly) -ne 0) { continue } + if ($ace -isnot [Security.AccessControl.CommonAce]) { throw 'Unsupported cleanup path ACL entry.' } + if ($ace.AceQualifier -eq [Security.AccessControl.AceQualifier]::AccessDenied) { continue } + if ($ace.AceQualifier -ne [Security.AccessControl.AceQualifier]::AccessAllowed -or $ace.IsCallback) { throw 'Unsupported cleanup path ACL entry.' } + if ($ace.SecurityIdentifier.Value -notin $trusted -and (([long]$ace.AccessMask -band $mutation) -ne 0)) { + throw 'Cleanup path is writable by a non-administrator.' + } + } +} + +function Assert-TenebraCleanupImage([string[]]$Chain) { + foreach ($path in $Chain) { + $item = Get-Item -LiteralPath $path -Force + if (([int]$item.Attributes -band [int][IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Cleanup path contains a reparse point.' } + $isFile = $path -ceq $Chain[-1] + if ($item.PSIsContainer -eq $isFile) { throw 'Cleanup path has the wrong file type.' } + $acl = Get-Acl -LiteralPath $path + $descriptor = [Security.AccessControl.RawSecurityDescriptor]::new($acl.GetSecurityDescriptorBinaryForm(), 0) + Assert-TenebraCleanupAcl $descriptor $isFile + } +} + +if ($PolicyOnly) { return } + +$image = $null +$process = $null +try { + $chain = @(Get-TenebraCleanupPathChain ([Environment]::GetEnvironmentVariable('TENEBRA_RELEASE_CORE'))) + if ([IO.DriveInfo]::new($chain[0]).DriveType -ne [IO.DriveType]::Fixed) { throw 'Cleanup image must be on a local fixed drive.' } + Assert-TenebraCleanupImage $chain + $core = $chain[-1] + # Hold a non-inheritable handle denying writes/delete while launching the + # checked image. Existing hostile write handles cause this open to fail. + $image = [IO.File]::Open($core, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = $core + $start.WorkingDirectory = [IO.Path]::GetDirectoryName($core) + $start.Arguments = '--release-host-protection' + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $process = [Diagnostics.Process]::Start($start) + if (!$process.WaitForExit(20000)) { + $process.Kill() + $null = $process.WaitForExit(1000) + throw 'Owned host-protection cleanup timed out; repair before uninstalling.' + } + if ($process.ExitCode -ne 0) { throw "Owned host-protection cleanup was not confirmed (exit $($process.ExitCode)); repair with a protection-aware installer." } + exit 0 +} catch { + [Console]::Error.WriteLine("Tenebra protection cleanup refused: $($_.Exception.Message)") + exit 1 +} finally { + if ($process) { $process.Dispose() } + if ($image) { $image.Dispose() } +} diff --git a/ui-desktop/src-tauri/installer-wfp-probe.nsh b/ui-desktop/src-tauri/installer-wfp-probe.nsh new file mode 100644 index 00000000..72d687fb --- /dev/null +++ b/ui-desktop/src-tauri/installer-wfp-probe.nsh @@ -0,0 +1,60 @@ +; Read-only probe of the fixed provider/sublayer identities. It neither inspects +; nor removes foreign policy and never launches an installed executable. +; $0 = absent only when BOTH exact NOT_FOUND results prove absence, else present. +; Any other result aborts. Keep p handles/pointer-to-pointer outputs pointer-sized +; because the NSIS uninstaller is 32-bit even for the x64 bundle. +!macro TenebraProbeHostProtection + Push $1 + Push $2 + Push $3 + Push $4 + Push $5 + StrCpy $1 0 + StrCpy $0 "WFP API unavailable" + System::Call '"$SYSDIR\fwpuclnt.dll"::FwpmEngineOpen0(p 0, i 10, p 0, p 0, *p.r1) i.r0' + ${If} $0 == "0" + ${AndIf} $1 != "0" + StrCpy $2 0 + StrCpy $4 "WFP provider API unavailable" + System::Call '"$SYSDIR\fwpuclnt.dll"::FwpmProviderGetByKey0(p r1, g "{fcb43b44-9358-4cd7-a998-9e7f822d5248}", *p.r2) i.r4' + ${If} $2 != "0" + System::Call '"$SYSDIR\fwpuclnt.dll"::FwpmFreeMemory0(*p r2) v' + ${EndIf} + StrCpy $2 0 + StrCpy $5 "WFP sublayer API unavailable" + System::Call '"$SYSDIR\fwpuclnt.dll"::FwpmSubLayerGetByKey0(p r1, g "{fcb43b45-9358-4cd7-a998-9e7f822d5248}", *p.r2) i.r5' + ${If} $2 != "0" + System::Call '"$SYSDIR\fwpuclnt.dll"::FwpmFreeMemory0(*p r2) v' + ${EndIf} + StrCpy $3 "WFP close API unavailable" + System::Call '"$SYSDIR\fwpuclnt.dll"::FwpmEngineClose0(p r1) i.r3' + ${If} $3 != "0" + StrCpy $0 $3 + ${ElseIf} $4 == "-2144206843" + ${AndIf} $5 == "-2144206841" + ; FWP_E_PROVIDER_NOT_FOUND 0x80320005 / SUBLAYER_NOT_FOUND 0x80320007. + StrCpy $0 "absent" + ${Else} + ${If} $4 != "0" + ${AndIf} $4 != "-2144206843" + StrCpy $0 $4 + ${ElseIf} $5 != "0" + ${AndIf} $5 != "-2144206841" + StrCpy $0 $5 + ${Else} + StrCpy $0 "present" + ${EndIf} + ${EndIf} + ${ElseIf} $0 == "0" + StrCpy $0 "WFP returned no engine handle" + ${EndIf} + Pop $5 + Pop $4 + Pop $3 + Pop $2 + Pop $1 + ${If} $0 != "absent" + ${AndIf} $0 != "present" + !insertmacro TenebraServiceFailure "query owned host protection before unregistering" + ${EndIf} +!macroend diff --git a/ui-desktop/src-tauri/src/backend/mock.rs b/ui-desktop/src-tauri/src/backend/mock.rs index 9773029f..2f6262b7 100644 --- a/ui-desktop/src-tauri/src/backend/mock.rs +++ b/ui-desktop/src-tauri/src/backend/mock.rs @@ -72,6 +72,7 @@ impl MockBackend { // The mock plays the part of a core built alongside this app, so // it reports the app's own version — the skew banner stays off in // mock-driven dev/tests unless a test injects a mismatch itself. + protection: None, daemon_version: Some(env!("CARGO_PKG_VERSION").into()), split: None, split_apps: None, diff --git a/ui-desktop/src-tauri/src/backend/mod.rs b/ui-desktop/src-tauri/src/backend/mod.rs index 8d79be72..3a4e53a3 100644 --- a/ui-desktop/src-tauri/src/backend/mod.rs +++ b/ui-desktop/src-tauri/src/backend/mod.rs @@ -1,4 +1,4 @@ -//! The boundary the UI talks to. +//! The boundary the UI talks to. //! //! Every implementation of the [`Backend`] trait drives the same control //! protocol (see `docs/control-protocol.md`); the Tauri command layer in @@ -21,6 +21,10 @@ pub mod mock; #[cfg(windows)] pub mod pipe; +#[cfg(windows)] +pub(crate) mod pipe_io; +#[cfg(any(windows, test))] +pub(crate) mod service_policy; pub mod sidecar; #[cfg(test)] pub mod testutil; @@ -142,9 +146,24 @@ pub struct Multihop { pub exit_id: String, } +/// Actual protection evidence from the core, separate from the user's request. +/// Keep status as a string so a future core status survives this relay unchanged; +/// the renderer decides how to display unknown statuses conservatively. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProtectionState { + pub status: String, + pub enforced: bool, + pub persistent: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct State { pub state: ConnectionState, + /// Absent on old cores and synthetic reconnect states: never infer active. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub protection: Option, #[serde(skip_serializing_if = "Option::is_none")] pub node: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -1124,6 +1143,7 @@ mod tests { node: Some("demo-nl".into()), profile: Some("demo-sub".into()), routing: Some(RoutingMode::Smart), + protection: None, daemon_version: Some("0.4.4".into()), split: Some(SplitMode::Exclude), split_apps: Some(vec!["chrome.exe".into(), "steam.exe".into()]), @@ -1170,6 +1190,7 @@ mod tests { node: None, profile: None, routing: None, + protection: None, daemon_version: None, split: None, split_apps: None, @@ -1572,3 +1593,7 @@ mod tests { assert_eq!(to_value(&node).unwrap()["insecure"], json!(true)); } } + +#[cfg(test)] +#[path = "protection_relay_tests.rs"] +mod protection_relay_tests; diff --git a/ui-desktop/src-tauri/src/backend/pipe.rs b/ui-desktop/src-tauri/src/backend/pipe.rs index bfe33827..3b375c4b 100644 --- a/ui-desktop/src-tauri/src/backend/pipe.rs +++ b/ui-desktop/src-tauri/src/backend/pipe.rs @@ -25,36 +25,25 @@ //! back well inside the window and never reads as a failure. While //! disconnected, commands fail fast instead of timing out. //! -//! # Why the reader polls -//! -//! The pipe handle is opened synchronously (no `FILE_FLAG_OVERLAPPED`), and -//! Windows serializes I/O on a synchronous file object: a `ReadFile` parked -//! waiting for data holds the file-object lock and blocks any `WriteFile` on -//! the same object — including one through a duplicated handle, which shares -//! it. A thread camping in a blocking read would deadlock every request. So -//! the reader never blocks in `read`: it asks `PeekNamedPipe` how many bytes -//! are ready and only reads that fast path, sleeping a short tick otherwise. -//! Reads then always complete immediately, writes only ever wait out a quick -//! read, and the tick doubles as a prompt shutdown check. (The overlapped -//! alternative is a pile of unsafe I/O plumbing for the same result; the Go -//! side needs go-winio for exactly this reason.) +//! Reads and writes use separate OVERLAPPED operations on one pipe handle. +//! Cancellation is shared with WireClient, whose deadline includes queued writes. use std::fs::{File, OpenOptions}; use std::io::{self, Read, Write}; use std::os::windows::fs::OpenOptionsExt; -use std::os::windows::io::AsRawHandle; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; use std::sync::{Arc, Mutex}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; -use windows_sys::Win32::Foundation::{ - ERROR_BROKEN_PIPE, ERROR_FILE_NOT_FOUND, ERROR_NO_DATA, ERROR_PIPE_BUSY, - ERROR_PIPE_NOT_CONNECTED, +use super::pipe_io::{authenticate_service, PipeIo, CLIENT_ACCESS}; +use windows_sys::Win32::Foundation::{ERROR_FILE_NOT_FOUND, ERROR_PIPE_BUSY}; +use windows_sys::Win32::Storage::FileSystem::{ + FILE_FLAG_OVERLAPPED, SECURITY_IDENTIFICATION, SECURITY_SQOS_PRESENT, }; -use windows_sys::Win32::Storage::FileSystem::{SECURITY_IDENTIFICATION, SECURITY_SQOS_PRESENT}; -use windows_sys::Win32::System::Pipes::{PeekNamedPipe, WaitNamedPipeW, NMPWAIT_NOWAIT}; +#[cfg(test)] +use windows_sys::Win32::System::Pipes::{WaitNamedPipeW, NMPWAIT_NOWAIT}; use super::wire::{obj, read_loop, WireClient, WireSession}; use super::{ConnectionState, EventSink, State}; @@ -62,11 +51,6 @@ use super::{ConnectionState, EventSink, State}; /// The well-known control pipe, mirroring `control.PipeName` on the Go side. pub const PIPE_NAME: &str = r"\\.\pipe\tenebra"; -/// How often the reader re-peeks an idle pipe (and rechecks shutdown). Events -/// and responses arrive at most this much late — imperceptible next to the -/// commands' own latency — and an idle GUI costs one no-op syscall per tick. -const POLL_INTERVAL: Duration = Duration::from_millis(20); - /// Reconnect backoff: first retry comes quickly (the common loss is a service /// restart or a displaced session, both back within a second), then doubles to /// a ceiling so a stopped service is probed gently, not hammered. @@ -141,6 +125,7 @@ fn name_from(value: Option<&str>) -> Option { /// instance is momentarily taken — it is between accepting a client and creating /// the next instance — reads as absent. Callers should treat `false` as "not /// this instant" and look again, never as "there is no service on this machine". +#[cfg(test)] pub fn is_listening(name: &str) -> bool { let wide: Vec = name.encode_utf16().chain(std::iter::once(0)).collect(); // SAFETY: `wide` is a valid NUL-terminated wide string that outlives the @@ -152,6 +137,7 @@ pub fn is_listening(name: &str) -> bool { struct Conn { reader: Box, writer: Box, + cancel: Arc, } /// How the supervisor re-establishes a connection. The real implementation @@ -286,6 +272,7 @@ fn reconnecting_state() -> State { routing: None, // Unknown while the service is away; the UI's staleness detection only // trusts idle/connected snapshots, so this None cannot read as "old". + protection: None, daemon_version: None, split: None, split_apps: None, @@ -330,6 +317,7 @@ fn lost_state() -> State { profile: None, routing: None, // Unknown while the service is away (see reconnecting_state). + protection: None, daemon_version: None, split: None, split_apps: None, @@ -453,7 +441,7 @@ fn wait_until(stop_rx: &Receiver<()>, stop: &AtomicBool, until: Instant) -> bool /// re-sync, and wait the reader out. On return the session is already cleared, /// so the supervisor's loss report never races a command onto a dead client. fn serve_session(conn: Conn, shared: &Arc, sink: &Arc) { - let client = WireClient::new(conn.writer); + let client = WireClient::new_cancellable(conn.writer, conn.cancel); *shared.session.lock().unwrap() = Some(Arc::clone(&client)); let reader_client = Arc::clone(&client); @@ -506,15 +494,15 @@ impl Dial for PipeDialer { let absent_wait = std::mem::take(&mut self.absent_wait); let file = open_pipe(&self.name, &self.stop, absent_wait) .map_err(|e| format!("open {}: {e}", self.name))?; - let writer = file - .try_clone() - .map_err(|e| format!("clone the pipe handle: {e}"))?; + if self.name.eq_ignore_ascii_case(PIPE_NAME) { + authenticate_service(&file) + .map_err(|e| format!("authenticate Tenebra service: {e}"))?; + } + let (reader, writer, cancel) = PipeIo::pair(file, Arc::clone(&self.stop)); Ok(Conn { - reader: Box::new(PollReader { - file, - stop: Arc::clone(&self.stop), - }), + reader: Box::new(reader), writer: Box::new(writer), + cancel, }) } } @@ -529,16 +517,7 @@ impl Dial for PipeDialer { fn open_pipe(name: &str, stop: &Arc, absent_wait: Duration) -> io::Result { let started = Instant::now(); loop { - let attempt = OpenOptions::new() - .read(true) - .write(true) - // GENERIC_READ|WRITE matches the GRGW the pipe's DACL grants - // interactive users. The SQOS flags cap impersonation at - // identification: if something else ever squats an instance of the - // name (the DACL admits any interactive user), it may learn who we - // are but cannot act as us. - .custom_flags(SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION) - .open(name); + let attempt = open_once(name); match attempt { Err(e) if dial_wait_for(&e, absent_wait) @@ -552,6 +531,13 @@ fn open_pipe(name: &str, stop: &Arc, absent_wait: Duration) -> io::R } } +fn open_once(name: &str) -> io::Result { + OpenOptions::new() + .access_mode(CLIENT_ACCESS) + .custom_flags(FILE_FLAG_OVERLAPPED | SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION) + .open(name) +} + /// How long a dial keeps re-attempting after a failure like this one, or `None` /// when the failure is not one to wait out. Only the two transient shapes get a /// window: `ERROR_PIPE_BUSY` (every instance is taken this instant) and @@ -567,63 +553,53 @@ fn dial_wait_for(e: &io::Error, absent_wait: Duration) -> Option { } } -/// `Read` over the pipe that never parks in `ReadFile` — see the module docs -/// for why that would deadlock writes. EOF (`Ok(0)`) covers both the peer -/// closing the pipe and our own shutdown flag, which is exactly the signal -/// `read_loop` ends on. -struct PollReader { - file: File, - stop: Arc, -} - -impl Read for PollReader { - fn read(&mut self, buf: &mut [u8]) -> io::Result { - loop { - if self.stop.load(Ordering::SeqCst) { - return Ok(0); - } - match pipe_bytes_available(&self.file) { - // Data is ready, so this read returns immediately with some of - // it; the brief file-object lock is exactly what keeps writers - // safe alongside us. - Ok(n) if n > 0 => return self.file.read(buf), - Ok(_) => thread::sleep(POLL_INTERVAL), - Err(e) if pipe_is_gone(&e) => return Ok(0), - Err(e) => return Err(e), - } +/// Installer-only read-only handshake. No Tauri, backend supervisor, sidecar, +/// imports or user store access. Both connection and request share one budget. +pub fn check_service_readiness(expected: &str) -> Result<(), String> { + let deadline = Instant::now() + Duration::from_secs(30); + let mut last_error = "service has not answered".to_string(); + while Instant::now() < deadline { + let stop = Arc::new(AtomicBool::new(false)); + let result = (|| { + let file = open_once(PIPE_NAME).map_err(|e| e.to_string())?; + authenticate_service(&file).map_err(|e| e.to_string())?; + let (reader, writer, cancel) = PipeIo::pair(file, stop); + let client = WireClient::new_cancellable(writer, cancel); + let reader_client = Arc::clone(&client); + let reader = + thread::spawn(move || read_loop(reader, reader_client, Arc::new(QuietSink))); + let reply = client.request_with_timeout( + "status", + obj([]), + deadline + .saturating_duration_since(Instant::now()) + .min(Duration::from_secs(3)), + ); + client.close(); + let _ = reader.join(); + let state: State = serde_json::from_value(reply?) + .map_err(|e| format!("invalid service status: {e}"))?; + super::service_policy::verify_version(state.daemon_version.as_deref(), expected) + })(); + match result { + Ok(()) => return Ok(()), + Err(e) => last_error = e, } + thread::sleep(Duration::from_millis(100)); } + Err(format!( + "Tenebra service did not become ready: {last_error}" + )) } -/// How many bytes a read could take right now without blocking. -fn pipe_bytes_available(file: &File) -> io::Result { - let mut available: u32 = 0; - // SAFETY: the handle is owned by `file` and outlives the call; a null - // buffer with zero length is the documented way to only query availability. - let ok = unsafe { - PeekNamedPipe( - file.as_raw_handle(), - std::ptr::null_mut(), - 0, - std::ptr::null_mut(), - &mut available, - std::ptr::null_mut(), - ) - }; - if ok == 0 { - Err(io::Error::last_os_error()) - } else { - Ok(available) - } -} - -/// Whether an error from the pipe means the peer is gone (EOF for our -/// purposes) rather than something being wrong with the call itself. -fn pipe_is_gone(e: &io::Error) -> bool { - matches!( - e.raw_os_error().map(|code| code as u32), - Some(ERROR_BROKEN_PIPE) | Some(ERROR_PIPE_NOT_CONNECTED) | Some(ERROR_NO_DATA) - ) +struct QuietSink; +impl EventSink for QuietSink { + fn state(&self, _: &State) {} + fn traffic(&self, _: u64, _: u64, _: u64, _: u64) {} + fn log(&self, _: &str, _: &str) {} + fn profiles(&self) {} + fn attempts(&self, _: &super::AttemptsSnapshot) {} + fn pick_progress(&self, _: &super::PickProgress) {} } #[cfg(test)] @@ -687,6 +663,7 @@ mod tests { Conn { reader: Box::new(end.reader), writer: Box::new(end.writer), + cancel: Arc::new(|| {}), } } @@ -1382,6 +1359,7 @@ mod tests { /// that case (FILE_FLAG_FIRST_PIPE_INSTANCE) and the test surfaces it /// rather than silently driving the wrong daemon. #[test] + #[ignore = "requires disposable Windows service VM; never run against a desktop service"] fn real_core_serves_the_well_known_pipe() { let Some(program) = core_binary() else { eprintln!("SKIP: tenebra-core binary not built; see tests/sidecar_e2e.rs"); diff --git a/ui-desktop/src-tauri/src/backend/pipe_io.rs b/ui-desktop/src-tauri/src/backend/pipe_io.rs new file mode 100644 index 00000000..9baad162 --- /dev/null +++ b/ui-desktop/src-tauri/src/backend/pipe_io.rs @@ -0,0 +1,400 @@ +//! Cancellable Windows pipe I/O and service authentication. No GUI dependency. +use std::fs::File; +use std::io::{self, Read, Write}; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use windows_sys::Win32::Foundation::{ERROR_IO_PENDING, WAIT_OBJECT_0, WAIT_TIMEOUT}; +use windows_sys::Win32::Storage::FileSystem::{ReadFile, WriteFile}; +use windows_sys::Win32::System::Pipes::GetNamedPipeServerProcessId; +use windows_sys::Win32::System::Services::{ + CloseServiceHandle, OpenSCManagerW, OpenServiceW, QueryServiceConfigW, QueryServiceStatusEx, + QUERY_SERVICE_CONFIGW, SC_HANDLE, SC_MANAGER_CONNECT, SC_STATUS_PROCESS_INFO, + SERVICE_QUERY_CONFIG, SERVICE_QUERY_STATUS, SERVICE_RUNNING, SERVICE_STATUS_PROCESS, + SERVICE_WIN32_OWN_PROCESS, +}; +use windows_sys::Win32::System::Threading::{ + CreateEventW, OpenProcess, QueryFullProcessImageNameW, WaitForSingleObject, + PROCESS_QUERY_LIMITED_INFORMATION, +}; +use windows_sys::Win32::System::IO::{CancelIoEx, GetOverlappedResult, OVERLAPPED}; + +// Mirrored by core/control/pipe_windows.go. Generic write also grants 0x4, +// FILE_CREATE_PIPE_INSTANCE: an interactive client must never request that. +pub const CLIENT_ACCESS: u32 = 0x0012_0083; + +pub struct PipeIo { + file: Arc, + stop: Arc, + cancelled: Arc, +} + +impl PipeIo { + pub fn pair(file: File, stop: Arc) -> (Self, Self, Arc) { + let file = Arc::new(file); + let cancelled = Arc::new(AtomicBool::new(false)); + let cancel_flag = Arc::clone(&cancelled); + ( + Self { + file: Arc::clone(&file), + stop: Arc::clone(&stop), + cancelled: Arc::clone(&cancelled), + }, + Self { + file, + stop, + cancelled, + }, + Arc::new(move || { + cancel_flag.store(true, Ordering::SeqCst); + }), + ) + } + + fn cancelled(&self) -> bool { + self.stop.load(Ordering::SeqCst) || self.cancelled.load(Ordering::SeqCst) + } + + fn transfer(&self, buf: *mut u8, len: usize, writing: bool) -> io::Result { + if self.cancelled() { + return Err(io::Error::new( + io::ErrorKind::ConnectionAborted, + "pipe session cancelled", + )); + } + // Each concurrent operation owns its event and OVERLAPPED. The event, + // structure and caller buffer stay alive until completion is reaped, + // INCLUDING after CancelIoEx (cancellation alone is not completion). + unsafe { + let event = CreateEventW(std::ptr::null(), 1, 0, std::ptr::null()); + if event.is_null() { + return Err(io::Error::last_os_error()); + } + let event = OwnedHandle::from_raw_handle(event); + let mut op: OVERLAPPED = std::mem::zeroed(); + op.hEvent = event.as_raw_handle(); + let mut count = 0; + let length = len.min(u32::MAX as usize) as u32; + let handle = self.file.as_raw_handle(); + let ok = if writing { + WriteFile(handle, buf, length, &mut count, &mut op) + } else { + ReadFile(handle, buf, length, &mut count, &mut op) + }; + if ok != 0 { + return Ok(count as usize); + } + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(ERROR_IO_PENDING as i32) { + return Err(error); + } + loop { + if self.cancelled() { + CancelIoEx(handle, &op); + // A racing successful completion is fine, but the session + // is already cancelled and its reply must not be reused. + GetOverlappedResult(handle, &op, &mut count, 1); + return Err(io::Error::new( + io::ErrorKind::ConnectionAborted, + "pipe session cancelled", + )); + } + match WaitForSingleObject(event.as_raw_handle(), 20) { + WAIT_OBJECT_0 => { + return if GetOverlappedResult(handle, &op, &mut count, 0) != 0 { + Ok(count as usize) + } else { + Err(io::Error::last_os_error()) + }; + } + WAIT_TIMEOUT => continue, + _ => { + let error = io::Error::last_os_error(); + CancelIoEx(handle, &op); + GetOverlappedResult(handle, &op, &mut count, 1); + return Err(error); + } + } + } + } + } +} + +impl Read for PipeIo { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if buf.is_empty() || self.cancelled() { + return Ok(0); + } + self.transfer(buf.as_mut_ptr(), buf.len(), false) + } +} +impl Write for PipeIo { + fn write(&mut self, buf: &[u8]) -> io::Result { + if buf.is_empty() { + return Ok(0); + } + self.transfer(buf.as_ptr() as *mut u8, buf.len(), true) + } + // WriteFile completes transfer to the pipe buffer. FlushFileBuffers waits + // for the peer to read and is not cancellable; it must never be used here. + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +fn identity_matches(server_pid: u32, service_pid: u32, running: bool, local_system: bool) -> bool { + running && local_system && server_pid != 0 && server_pid == service_pid +} + +struct ServiceHandle(SC_HANDLE); +impl Drop for ServiceHandle { + fn drop(&mut self) { + unsafe { + CloseServiceHandle(self.0); + } + } +} + +unsafe fn wide_string(ptr: *const u16) -> String { + if ptr.is_null() { + return String::new(); + } + let mut length = 0; + while *ptr.add(length) != 0 { + length += 1; + } + String::from_utf16_lossy(std::slice::from_raw_parts(ptr, length)) +} + +/// Validate the connected kernel object's server, before sending any payload. +/// SCM configuration is admin protected. Match its LocalSystem own-process +/// service, PID and image; no process-token rights or GUI elevation are needed. +pub fn authenticate_service(file: &File) -> io::Result<()> { + unsafe { + let mut server_pid = 0; + if GetNamedPipeServerProcessId(file.as_raw_handle(), &mut server_pid) == 0 { + return Err(io::Error::last_os_error()); + } + let manager = OpenSCManagerW(std::ptr::null(), std::ptr::null(), SC_MANAGER_CONNECT); + if manager.is_null() { + return Err(io::Error::last_os_error()); + } + let name: Vec = "tenebra\0".encode_utf16().collect(); + let service = OpenServiceW( + manager, + name.as_ptr(), + SERVICE_QUERY_STATUS | SERVICE_QUERY_CONFIG, + ); + let open_error = io::Error::last_os_error(); + CloseServiceHandle(manager); + if service.is_null() { + return Err(open_error); + } + let service = ServiceHandle(service); + let mut status: SERVICE_STATUS_PROCESS = std::mem::zeroed(); + let mut needed = 0; + let ok = QueryServiceStatusEx( + service.0, + SC_STATUS_PROCESS_INFO, + &mut status as *mut _ as *mut u8, + std::mem::size_of_val(&status) as u32, + &mut needed, + ); + let query_error = io::Error::last_os_error(); + if ok == 0 { + return Err(query_error); + } + // QueryServiceConfig is readable by ordinary authenticated users. A + // LocalSystem token itself need not grant TOKEN_QUERY to those users. + let mut config_buffer = [0usize; 1024]; + let config_ptr = config_buffer.as_mut_ptr() as *mut QUERY_SERVICE_CONFIGW; + let ok = QueryServiceConfigW( + service.0, + config_ptr, + std::mem::size_of_val(&config_buffer) as u32, + &mut needed, + ); + let config_error = io::Error::last_os_error(); + if ok == 0 { + return Err(config_error); + } + let config = &*config_ptr; + let account = wide_string(config.lpServiceStartName); + let configured_image = wide_string(config.lpBinaryPathName); + let system = account.eq_ignore_ascii_case("LocalSystem") + && status.dwServiceType & SERVICE_WIN32_OWN_PROCESS != 0; + + if status.dwCurrentState != SERVICE_RUNNING + || status.dwProcessId != server_pid + || server_pid == 0 + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "pipe server is not the running Tenebra service", + )); + } + let process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, server_pid); + if process.is_null() { + return Err(io::Error::last_os_error()); + } + let process = OwnedHandle::from_raw_handle(process); + let mut image_path = vec![0u16; 32768]; + let mut length = image_path.len() as u32; + if QueryFullProcessImageNameW( + process.as_raw_handle(), + 0, + image_path.as_mut_ptr(), + &mut length, + ) == 0 + { + return Err(io::Error::last_os_error()); + } + let actual_image = String::from_utf16_lossy(&image_path[..length as usize]); + let registered = + super::service_policy::registered_image(&configured_image).ok_or_else(|| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "Tenebra service has an ambiguous executable path", + ) + })?; + if !actual_image.eq_ignore_ascii_case(registered) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Tenebra service image differs from its registered executable", + )); + } + // Re-read the connected object's PID while retaining the process + // handle, preventing PID reuse from validating a replacement process. + let mut final_status: SERVICE_STATUS_PROCESS = std::mem::zeroed(); + if QueryServiceStatusEx( + service.0, + SC_STATUS_PROCESS_INFO, + &mut final_status as *mut _ as *mut u8, + std::mem::size_of_val(&final_status) as u32, + &mut needed, + ) == 0 + { + return Err(io::Error::last_os_error()); + } + let mut final_pid = 0; + if GetNamedPipeServerProcessId(file.as_raw_handle(), &mut final_pid) == 0 + || final_status.dwProcessId != server_pid + || !identity_matches( + final_pid, + final_status.dwProcessId, + final_status.dwCurrentState == SERVICE_RUNNING, + system && final_status.dwServiceType & SERVICE_WIN32_OWN_PROCESS != 0, + ) + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Tenebra pipe server identity could not be verified", + )); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::OpenOptions; + use std::os::windows::fs::OpenOptionsExt; + use std::sync::mpsc; + use std::thread; + use std::time::{Duration, Instant}; + use windows_sys::Win32::Foundation::{ERROR_PIPE_CONNECTED, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_FLAG_FIRST_PIPE_INSTANCE, FILE_FLAG_OVERLAPPED, PIPE_ACCESS_DUPLEX, + }; + use windows_sys::Win32::System::Pipes::{ + ConnectNamedPipe, CreateNamedPipeW, PIPE_TYPE_BYTE, PIPE_WAIT, + }; + #[test] + fn only_running_registered_system_process_is_trusted() { + assert!(identity_matches(123, 123, true, true)); + assert!(!identity_matches(124, 123, true, true)); + assert!(!identity_matches(0, 0, true, true)); + assert!(!identity_matches(123, 123, false, true)); + assert!(!identity_matches(123, 123, true, false)); + } + #[test] + fn interactive_access_excludes_instance_creation_and_dacl_mutation() { + assert_eq!(CLIENT_ACCESS & (0x4 | 0x40000 | 0x80000), 0); + assert_eq!(CLIENT_ACCESS & 3, 3); + } + + // Isolated kernel pipe only. No service, real core, routes or privileged + // operations. Deliberately unread input fills the small server buffer. + fn blocked_operation_is_cancelled(writing: bool) { + let name = format!( + r"\\.\pipe\tenebra-cancel-test-{}-{}", + std::process::id(), + writing + ); + let server_name = name.clone(); + let (ready_tx, ready_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel::<()>(); + let server = thread::spawn(move || unsafe { + let wide: Vec = server_name.encode_utf16().chain(Some(0)).collect(); + let handle = CreateNamedPipeW( + wide.as_ptr(), + PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_BYTE | PIPE_WAIT, + 1, + 4096, + 4096, + 0, + std::ptr::null(), + ); + assert_ne!(handle, INVALID_HANDLE_VALUE); + let file = File::from_raw_handle(handle); + ready_tx.send(()).unwrap(); + if ConnectNamedPipe(file.as_raw_handle(), std::ptr::null_mut()) == 0 { + assert_eq!( + io::Error::last_os_error().raw_os_error(), + Some(ERROR_PIPE_CONNECTED as i32) + ); + } + let _ = release_rx.recv_timeout(Duration::from_secs(3)); + }); + ready_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + let file = OpenOptions::new() + .access_mode(CLIENT_ACCESS) + .custom_flags(FILE_FLAG_OVERLAPPED) + .open(name) + .unwrap(); + let (mut reader, mut writer, cancel) = PipeIo::pair(file, Arc::new(AtomicBool::new(false))); + let (done_tx, done_rx) = mpsc::channel(); + let caller = thread::spawn(move || { + let result = if writing { + writer.write_all(&vec![42; 1024 * 1024]) + } else { + reader.read(&mut [0u8; 1]).map(|_| ()) + }; + let _ = done_tx.send(result); + }); + assert!( + done_rx.recv_timeout(Duration::from_millis(40)).is_err(), + "I/O must actually be blocked before cancellation" + ); + let started = Instant::now(); + cancel(); + let result = done_rx.recv_timeout(Duration::from_secs(1)); + drop(release_tx); + server.join().unwrap(); + caller.join().unwrap(); + assert!(result.unwrap().is_err()); + assert!(started.elapsed() < Duration::from_secs(1)); + } + + #[test] + fn overlapped_backpressure_write_is_cancelled_and_reaped() { + blocked_operation_is_cancelled(true); + } + #[test] + fn overlapped_idle_read_is_cancelled_and_reaped() { + blocked_operation_is_cancelled(false); + } +} diff --git a/ui-desktop/src-tauri/src/backend/protection_relay_tests.rs b/ui-desktop/src-tauri/src/backend/protection_relay_tests.rs new file mode 100644 index 00000000..4654e6ca --- /dev/null +++ b/ui-desktop/src-tauri/src/backend/protection_relay_tests.rs @@ -0,0 +1,29 @@ +use super::*; +use serde_json::json; + +#[test] +fn protection_survives_state_deserialization_and_ui_serialization() { + for status in [ + "off", + "applying", + "active", + "blocked", + "error", + "unavailable", + ] { + let protection = json!({ "status": status, "enforced": status == "active", "persistent": true, "error": "diagnostic" }); + // This is the same State decode and sink serialization used by the + // status response and wire::forward_event -> TauriSink::state. + let incoming = json!({ "event": "state", "state": "connected", "protection": protection }); + let state: State = serde_json::from_value(incoming).unwrap(); + let outgoing = serde_json::to_value(state).unwrap(); + assert_eq!(outgoing.get("protection"), Some(&protection)); + } +} + +#[test] +fn old_core_without_protection_remains_unknown() { + let state: State = serde_json::from_value(json!({ "state": "connected" })).unwrap(); + let outgoing = serde_json::to_value(state).unwrap(); + assert!(outgoing.get("protection").is_none()); +} diff --git a/ui-desktop/src-tauri/src/backend/service_policy.rs b/ui-desktop/src-tauri/src/backend/service_policy.rs new file mode 100644 index 00000000..a0f52042 --- /dev/null +++ b/ui-desktop/src-tauri/src/backend/service_policy.rs @@ -0,0 +1,58 @@ +//! Pure readiness/transport policy, shared by the installer helper and GUI. +pub fn allow_windows_sidecar(debug_build: bool, pipe_override: Option<&str>) -> bool { + debug_build && matches!(pipe_override, Some("off" | "0")) +} + +pub fn verify_version(actual: Option<&str>, expected: &str) -> Result<(), String> { + if actual == Some(expected) { + return Ok(()); + } + Err(format!("Tenebra service version {} does not match app {expected}; rerun the installer to repair the service", actual.unwrap_or("unknown"))) +} + +// Installer registrations contain one quoted absolute executable and no args. +#[cfg(windows)] +pub fn registered_image(command: &str) -> Option<&str> { + let image = command.strip_prefix('"')?.strip_suffix('"')?; + if image.contains('"') || !std::path::Path::new(image).is_absolute() { + return None; + } + Some(image) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn installed_build_never_switches_profile_store() { + for value in [None, Some(""), Some("off"), Some("0"), Some("other-pipe")] { + assert!(!allow_windows_sidecar(false, value)); + } + assert!(!allow_windows_sidecar(true, None)); + assert!(allow_windows_sidecar(true, Some("off"))); + } + #[test] + fn readiness_requires_exact_known_version() { + assert!(verify_version(Some("0.5.11"), "0.5.11").is_ok()); + assert!(verify_version(Some("0.5.10"), "0.5.11").is_err()); + assert!(verify_version(None, "0.5.11").is_err()); + assert!(verify_version(Some("0.5.11-beta.1"), "0.5.11").is_err()); + } + + #[test] + #[cfg(windows)] + fn registered_image_rejects_ambiguous_or_relative_commands() { + assert_eq!( + registered_image(r#""C:\Program Files\Tenebra\tenebra-core.exe""#), + Some(r"C:\Program Files\Tenebra\tenebra-core.exe") + ); + for command in [ + r"C:\Program Files\Tenebra\tenebra-core.exe", + r#""relative.exe""#, + r#""C:\core.exe" --pipe"#, + "", + ] { + assert!(registered_image(command).is_none()); + } + } +} diff --git a/ui-desktop/src-tauri/src/backend/unix.rs b/ui-desktop/src-tauri/src/backend/unix.rs index 5b3fb5a8..7de07ffd 100644 --- a/ui-desktop/src-tauri/src/backend/unix.rs +++ b/ui-desktop/src-tauri/src/backend/unix.rs @@ -328,6 +328,7 @@ fn reconnecting_state() -> State { routing: None, // Unknown while the daemon is away; the UI's staleness detection only // trusts idle/connected snapshots, so this None cannot read as "old". + protection: None, daemon_version: None, split: None, split_apps: None, @@ -375,6 +376,7 @@ fn lost_state() -> State { profile: None, routing: None, // Unknown while the daemon is away (see reconnecting_state). + protection: None, daemon_version: None, split: None, split_apps: None, @@ -506,7 +508,24 @@ fn serve_session(conn: Conn, shared: &Arc, sink: &Arc writer, wake, } = conn; - let client = WireClient::new(writer); + let cancel_wake = match wake.as_ref().map(UnixStream::try_clone).transpose() { + Ok(wake) => wake, + Err(error) => { + sink.log( + "error", + &format!("cannot create socket cancellation handle: {error}"), + ); + return; + } + }; + let client = WireClient::new_cancellable( + writer, + Arc::new(move || { + if let Some(stream) = &cancel_wake { + let _ = stream.shutdown(Shutdown::Both); + } + }), + ); *shared.session.lock().unwrap() = Some(Arc::clone(&client)); *shared.wake.lock().unwrap() = wake; diff --git a/ui-desktop/src-tauri/src/backend/wire.rs b/ui-desktop/src-tauri/src/backend/wire.rs index 6f89b364..7b13e5cf 100644 --- a/ui-desktop/src-tauri/src/backend/wire.rs +++ b/ui-desktop/src-tauri/src/backend/wire.rs @@ -16,9 +16,11 @@ use std::collections::HashMap; use std::io::{BufRead, BufReader, Read, Write}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::mpsc::{self, Receiver, Sender}; +#[cfg(test)] +use std::sync::mpsc::Receiver; +use std::sync::mpsc::{self, Sender}; use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::time::{Duration, Instant}; use serde::de::DeserializeOwned; use serde_json::{json, Value}; @@ -42,13 +44,19 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); pub type ReplyResult = Result; type Pending = Arc>>>; +struct Outbound { + line: Vec, + deadline: Instant, +} + /// One live protocol session over some byte stream: the write half plus the /// request-correlation state the reader completes. Created per connection; a /// client that reconnects builds a fresh one per session. pub struct WireClient { - /// The stream's write half, guarded so concurrent command calls can't - /// interleave two half-written lines. - writer: Mutex>, + /// One worker owns the stream; callers never wait on its write lock. + writer: mpsc::SyncSender, + /// Wakes transport I/O without acquiring the writer's lock. + cancel: Arc, /// In-flight requests awaiting a response, keyed by request id. pending: Pending, /// Monotonic request-id source. Starts at 1 so ids match the protocol's @@ -58,7 +66,7 @@ pub struct WireClient { /// Set once the stream is gone (reader hit EOF/error, or the owner closed /// the session); further requests fail fast instead of blocking until the /// timeout. - closed: AtomicBool, + closed: Arc, } impl WireClient { @@ -66,11 +74,56 @@ impl WireClient { /// [`read_loop`] with the matching read half for responses and events to /// flow. pub fn new(writer: impl Write + Send + 'static) -> Arc { + Self::new_cancellable(writer, Arc::new(|| {})) + } + + pub fn new_cancellable( + mut writer: impl Write + Send + 'static, + cancel: Arc, + ) -> Arc { + // Bounded queue: a wedged peer cannot cause unbounded request buffers or + // one OS thread per caller. try_send never waits for queue capacity. + let (tx, rx) = mpsc::sync_channel::(32); + let pending: Pending = Arc::new(Mutex::new(HashMap::new())); + let closed = Arc::new(AtomicBool::new(false)); + let worker_pending = Arc::clone(&pending); + let worker_closed = Arc::clone(&closed); + let worker_cancel = Arc::clone(&cancel); + let spawned = std::thread::Builder::new() + .name("tenebra-wire-writer".into()) + .spawn(move || { + while let Ok(outbound) = rx.recv() { + if worker_closed.load(Ordering::SeqCst) { + break; + } + if Instant::now() >= outbound.deadline { + worker_closed.store(true, Ordering::SeqCst); + worker_cancel(); + fail_all_pending(&worker_pending); + break; + } + if writer + .write_all(&outbound.line) + .and_then(|_| writer.flush()) + .is_err() + { + worker_closed.store(true, Ordering::SeqCst); + worker_cancel(); + fail_all_pending(&worker_pending); + break; + } + } + }); + if spawned.is_err() { + closed.store(true, Ordering::SeqCst); + cancel(); + } Arc::new(Self { - writer: Mutex::new(Box::new(writer)), - pending: Arc::new(Mutex::new(HashMap::new())), + writer: tx, + pending, next_id: AtomicU64::new(1), - closed: AtomicBool::new(false), + closed, + cancel, }) } @@ -78,7 +131,9 @@ impl WireClient { /// Idempotent. The reader calls this on every exit path; owners call it /// when tearing a session down so no caller waits out the full timeout. pub fn close(&self) { - self.closed.store(true, Ordering::SeqCst); + if !self.closed.swap(true, Ordering::SeqCst) { + (self.cancel)(); + } fail_all_pending(&self.pending); } @@ -86,26 +141,38 @@ impl WireClient { /// must serialize to a JSON object; the `id` and `cmd` are spliced in. The /// returned value is the response's `data` payload (or `null`). pub fn request(&self, cmd: &str, params: Value) -> Result { - if self.closed.load(Ordering::SeqCst) { - return Err("the connection to tenebra-core is closed".into()); - } + self.request_with_timeout(cmd, params, REQUEST_TIMEOUT) + } + /// The deadline includes queueing, writing and response wait. A timeout + /// closes this session: a possibly partial frame cannot safely be reused. + pub fn request_with_timeout(&self, cmd: &str, params: Value, timeout: Duration) -> ReplyResult { + let deadline = Instant::now() + timeout; let id = self.next_id.fetch_add(1, Ordering::Relaxed); let line = build_request(id, cmd, params)?; - - let (tx, rx): (Sender, Receiver) = mpsc::channel(); - self.pending.lock().unwrap().insert(id, tx); - - if let Err(e) = self.write_line(&line) { + let (tx, rx) = mpsc::channel(); + { + // Serialize registration with close's drain. Either close sees the + // waiter or the waiter sees closed; no insert-after-drain race. + let mut pending = self.pending.lock().unwrap(); + if self.closed.load(Ordering::SeqCst) { + return Err("the connection to tenebra-core is closed".into()); + } + pending.insert(id, tx); + } + if self.writer.try_send(Outbound { line, deadline }).is_err() { self.pending.lock().unwrap().remove(&id); - return Err(e); + return Err( + "tenebra-core request queue is unavailable or full; retry the command".into(), + ); } - - match rx.recv_timeout(REQUEST_TIMEOUT) { + match rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) { Ok(reply) => reply, Err(mpsc::RecvTimeoutError::Timeout) => { - self.pending.lock().unwrap().remove(&id); - Err(format!("tenebra-core did not respond to {cmd} in time")) + self.close(); + Err(format!( + "tenebra-core did not respond to {cmd} in time; session closed" + )) } Err(mpsc::RecvTimeoutError::Disconnected) => { self.pending.lock().unwrap().remove(&id); @@ -120,13 +187,11 @@ impl WireClient { serde_json::from_value(data) .map_err(|e| format!("malformed {cmd} response from tenebra-core: {e}")) } +} - fn write_line(&self, line: &[u8]) -> Result<(), String> { - let mut writer = self.writer.lock().unwrap(); - writer - .write_all(line) - .and_then(|_| writer.flush()) - .map_err(|e| format!("failed to send request to tenebra-core: {e}")) +impl Drop for WireClient { + fn drop(&mut self) { + self.close(); } } @@ -1620,3 +1685,7 @@ Core version: 0.5.0 ); } } + +#[cfg(test)] +#[path = "wire_deadline_tests.rs"] +mod deadline_tests; diff --git a/ui-desktop/src-tauri/src/backend/wire_deadline_tests.rs b/ui-desktop/src-tauri/src/backend/wire_deadline_tests.rs new file mode 100644 index 00000000..87888be4 --- /dev/null +++ b/ui-desktop/src-tauri/src/backend/wire_deadline_tests.rs @@ -0,0 +1,128 @@ +use super::*; +use std::io; +use std::thread; + +struct BlockedWriter { + entered: Sender<()>, + release: Receiver<()>, +} + +impl Write for BlockedWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + let _ = self.entered.send(()); + let _ = self.release.recv(); + Ok(buf.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[test] +fn close_releases_caller_while_writer_is_blocked() { + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let client = WireClient::new(BlockedWriter { + entered: entered_tx, + release: release_rx, + }); + let request_client = Arc::clone(&client); + let (done_tx, done_rx) = mpsc::channel(); + let handle = thread::spawn(move || { + let _ = done_tx.send(request_client.request("status", json!({}))); + }); + entered_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + client.close(); + let result = done_rx.recv_timeout(Duration::from_millis(200)); + // Release the controlled writer even on RED: this test never leaks a thread. + drop(release_tx); + handle.join().unwrap(); + assert!( + result.is_ok(), + "close must release the request without waiting for the writer" + ); + assert!(result.unwrap().is_err()); +} + +#[test] +fn deadline_bounds_blocked_write_and_queued_commands() { + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let cancelled = Arc::new(AtomicBool::new(false)); + let cancel_flag = Arc::clone(&cancelled); + let client = WireClient::new_cancellable( + BlockedWriter { + entered: entered_tx, + release: release_rx, + }, + Arc::new(move || { + cancel_flag.store(true, Ordering::SeqCst); + }), + ); + let first = Arc::clone(&client); + let caller = thread::spawn(move || { + first.request_with_timeout("import_links", json!({}), Duration::from_millis(100)) + }); + entered_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + let started = Instant::now(); + let queued = client.request_with_timeout("disconnect", json!({}), Duration::from_millis(300)); + drop(release_tx); + assert!(caller.join().unwrap().unwrap_err().contains("in time")); + assert!(queued.is_err()); + assert!(started.elapsed() < Duration::from_secs(1)); + assert!(cancelled.load(Ordering::SeqCst)); + assert!(client.pending.lock().unwrap().is_empty()); +} + +#[test] +fn close_and_registration_race_never_strands_a_waiter() { + for _ in 0..100 { + let client = WireClient::new(io::sink()); + let other = Arc::clone(&client); + let caller = thread::spawn(move || { + other.request_with_timeout("status", json!({}), Duration::from_secs(2)) + }); + client.close(); + let started = Instant::now(); + assert!(caller.join().unwrap().is_err()); + assert!(started.elapsed() < Duration::from_millis(500)); + } +} + +#[test] +fn queued_frames_are_not_written_after_cancellation() { + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let client = WireClient::new(BlockedWriter { + entered: entered_tx, + release: release_rx, + }); + let first = Arc::clone(&client); + let caller = thread::spawn(move || first.request("status", json!({}))); + entered_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + client + .writer + .try_send(Outbound { + line: b"must not send\n".to_vec(), + deadline: Instant::now() + Duration::from_secs(5), + }) + .unwrap(); + client.close(); + drop(release_tx); + assert!(caller.join().unwrap().is_err()); + assert!(entered_rx.recv_timeout(Duration::from_millis(200)).is_err()); +} + +#[test] +fn expired_request_never_reaches_the_stream() { + let (entered_tx, entered_rx) = mpsc::channel(); + let (_release_tx, release_rx) = mpsc::channel(); + let client = WireClient::new(BlockedWriter { + entered: entered_tx, + release: release_rx, + }); + assert!(client + .request_with_timeout("connect", json!({}), Duration::ZERO) + .is_err()); + assert!(entered_rx.recv_timeout(Duration::from_millis(100)).is_err()); +} diff --git a/ui-desktop/src-tauri/src/lib.rs b/ui-desktop/src-tauri/src/lib.rs index 2678d717..27609bb8 100644 --- a/ui-desktop/src-tauri/src/lib.rs +++ b/ui-desktop/src-tauri/src/lib.rs @@ -13,7 +13,7 @@ mod tray; mod update_channel; use std::sync::{Arc, Mutex}; -#[cfg(any(windows, target_os = "linux"))] +#[cfg(any(target_os = "linux", all(windows, test)))] use std::time::{Duration, Instant}; use serde_json::json; @@ -162,52 +162,12 @@ impl EventSink for TauriSink { } // ============================================================================= -// Backend selection. -// -// The ONE place a transport is chosen, tried in order: -// -// 1. TENEBRA_MOCK=1 forces the in-process demo fake (UI work without the -// core, or when the sidecar binary isn't built). Read by value, so an -// explicit `0`/`off`/`false`/`no` — or an empty one — is not a request -// for it; see mock_requested. -// 2. On Windows, if a core is already listening on the control pipe (the -// installed service, or `tenebra-core --pipe` in a console), attach to it. -// The tunnel then outlives this process and the GUI needs no elevation. -// TENEBRA_PIPE renames the pipe or (`off`) skips it — see -// backend::pipe::configured_name. -// 2'. On macOS and Linux, the same probe over the daemon's unix socket -// (`/var/run/tenebra.sock` and `/run/tenebra.sock` respectively): if the -// root daemon — the macOS LaunchDaemon, the Linux systemd service — is -// listening, attach. TENEBRA_SOCKET renames the path or (`off`) skips it — -// see backend::unix::configured_path. -// 3. Otherwise spawn the `tenebra-core` sidecar and own it — today's default -// and the development path. -// -// If the sidecar cannot be located or will not spawn (e.g. the binary is -// missing), we log and fall back to `backend::unavailable`, which refuses every -// command with that reason. It used to fall back to the demo mock, and that was -// a lie the user had no way to see through: the window filled with invented -// profiles, a connect that "succeeded" on a timer, and a bypass reporting fake -// strategies — an app telling someone their traffic is protected while nothing -// at all is running. The refusal surfaces in the UI as "the core cannot be -// reached, retrying", which is what happened. Every choice implements the same -// `Backend` trait and is logged on the UI's own log channel, so nothing else in -// this file or the front end changes. -// -// The choice is made once and kept for the life of the process (the front end -// holds no notion of a transport, and a live sidecar tunnel cannot be handed to -// the service mid-run), which makes step 3 a consequential place to land by -// accident: an app-owned core keeps its profiles in the per-user store, so a -// user whose profiles live in the service's machine store sees an empty list -// and a Connect button that appears to do nothing. Two things guard against -// arriving there by mistake rather than by configuration: the dial itself waits -// out a service that is merely still starting (backend::pipe, and -// backend::unix where the platform warrants it), and the fallback is reported -// at warn with a plain description of what changed. Where a listener can be -// probed without displacing whoever holds it — Windows via WaitNamedPipeW, -// Linux via /proc/net/unix — we then keep watching for a while and say so if -// the service turns up late, so a user in that state is told a restart is all -// it takes. macOS has no such probe, so there the warning stands alone. +// Backend selection. Explicit mock mode is reserved for UI development. +// Windows release builds always attach to the authenticated machine service; +// debug builds can opt into their own sidecar with TENEBRA_PIPE=off. A failed +// service connection preserves the machine profile store and offers repair. +// Unix builds attach to their root daemon where available, with the existing +// explicit/logged development-sidecar path. Missing bundled binaries fail closed. // ============================================================================= fn make_backend(app: &AppHandle, sink: Arc) -> Arc { if mock_requested(std::env::var("TENEBRA_MOCK").ok().as_deref()) { @@ -215,31 +175,26 @@ fn make_backend(app: &AppHandle, sink: Arc) -> Arc { } #[cfg(windows)] - if let Some(name) = backend::pipe::configured_name() { - match backend::pipe::PipeBackend::connect(&name, Arc::clone(&sink)) { - Ok(backend) => { - sink.log( - "info", - &format!("attached to the Tenebra service on {name}"), - ); - return Arc::new(backend); - } - // Falling through to the sidecar is a working configuration (it is - // the development path), but on an installed machine it is a - // downgrade the user never asked for and cannot see from the UI, so - // it is reported as a warning that names the consequences rather - // than as a note about spawning a process. - Err(e) => { - sink.log( - "warn", - &format!( - "could not reach the Tenebra service on {name} ({e}); \ - running this app's own core instead — profiles saved by the service \ - are not visible here, and connecting in tun mode needs \ - administrator rights" - ), - ); - watch_for_a_late_service(name, Arc::clone(&sink)); + { + let override_value = std::env::var("TENEBRA_PIPE").ok(); + let explicit_sidecar = backend::service_policy::allow_windows_sidecar( + cfg!(debug_assertions), + override_value.as_deref(), + ); + if !explicit_sidecar { + // Release builds always use the authenticated machine service. + // Development may explicitly select an alternate pipe. + let name = if cfg!(debug_assertions) { + backend::pipe::configured_name().unwrap_or_else(|| backend::pipe::PIPE_NAME.into()) + } else { + backend::pipe::PIPE_NAME.into() + }; + match backend::pipe::PipeBackend::connect(&name, Arc::clone(&sink)) { + Ok(service) => return Arc::new(service), + Err(e) => return no_core(&sink, format!( + "Tenebra service is unavailable ({e}). Start the Tenebra service in Windows Services, \ + then restart the app. If that fails, rerun the Tenebra installer as administrator \ + and inspect %ProgramData%\\Tenebra\\service.log. Your service profiles remain in their original store.")), } } } @@ -308,49 +263,14 @@ fn no_core(sink: &Arc, reason: String) -> Arc { /// should not carry a polling thread for the life of the process. The tick is /// deliberately lazy; nothing here depends on catching the transition promptly, /// only on catching it at all. -#[cfg(any(windows, target_os = "linux"))] +#[cfg(target_os = "linux")] const LATE_SERVICE_WATCH: Duration = Duration::from_secs(60); -#[cfg(any(windows, target_os = "linux"))] +#[cfg(target_os = "linux")] const LATE_SERVICE_TICK: Duration = Duration::from_secs(2); -/// Watch for a service that comes up after this app already committed to its own -/// core, and say so once if it does. -/// -/// This app cannot promote itself onto the service mid-run: the sidecar it -/// spawned may be carrying a live tunnel, and dropping that to attach elsewhere -/// would take the user's connection down without being asked. What it can do is -/// stop the state from being silent — a relaunch is all it takes, and the user -/// has no way to know that from a UI that simply shows no profiles. The watch -/// lives on its own thread, ends with [`LATE_SERVICE_WATCH`], and probes without -/// dialing (see [`backend::pipe::is_listening`]) so it never displaces the -/// session of whatever client the service is actually serving. -#[cfg(windows)] -fn watch_for_a_late_service(name: String, sink: Arc) { - // A thread that cannot be spawned costs the user nothing but this notice. - let _ = std::thread::Builder::new() - .name("tenebra-service-watch".into()) - .spawn(move || { - let appeared = await_probe( - || backend::pipe::is_listening(&name), - LATE_SERVICE_TICK, - LATE_SERVICE_WATCH, - ); - if appeared { - sink.log( - "warn", - &format!( - "the Tenebra service is listening on {name} now, but this session is \ - already running the app's own core; restart Tenebra to control the \ - service and see the profiles saved there" - ), - ); - } - }); -} - /// Watch for a daemon that comes up after this app already committed to its own /// core, and say so once if it does. The Linux half of -/// [`watch_for_a_late_service`], for the same reason and with the same limits; +/// the Windows service startup check, for the same reason and with the same limits; /// it probes the kernel's socket table rather than dialing (see /// [`backend::unix::is_listening`]), so it never displaces the session of /// whatever client the daemon is actually serving. @@ -382,7 +302,7 @@ fn watch_for_a_late_daemon(path: String, sink: Arc) { /// reporting whether it ever did. Split out from the watch thread so its /// schedule — look first, then wait, and always look at least once — can be /// tested without a real pipe, a real socket, or real seconds. -#[cfg(any(windows, target_os = "linux"))] +#[cfg(any(target_os = "linux", all(windows, test)))] fn await_probe(mut probe: impl FnMut() -> bool, tick: Duration, window: Duration) -> bool { let deadline = Instant::now() + window; loop { @@ -1313,6 +1233,12 @@ fn update_notice(lang: Lang, version: &str) -> (&'static str, String) { } } +/// Called by the trusted installed executable before initializing Tauri. +#[cfg(windows)] +pub fn check_installed_service() -> Result<(), String> { + backend::pipe::check_service_readiness(env!("CARGO_PKG_VERSION")) +} + #[cfg(test)] mod tests { use super::*; @@ -1325,6 +1251,7 @@ mod tests { node: None, profile: None, routing: None, + protection: None, daemon_version: None, split: None, split_apps: None, diff --git a/ui-desktop/src-tauri/src/main.rs b/ui-desktop/src-tauri/src/main.rs index a79303b3..51164240 100644 --- a/ui-desktop/src-tauri/src/main.rs +++ b/ui-desktop/src-tauri/src/main.rs @@ -2,5 +2,16 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + #[cfg(windows)] + if std::env::args_os().nth(1).as_deref() == Some(std::ffi::OsStr::new("--service-check")) { + let code = match tenebra_desktop_lib::check_installed_service() { + Ok(()) => 0, + Err(error) => { + eprintln!("{error}"); + 1 + } + }; + std::process::exit(code); + } tenebra_desktop_lib::run(); } diff --git a/ui-desktop/src-tauri/src/tray.rs b/ui-desktop/src-tauri/src/tray.rs index b2147ef7..c8e5bb95 100644 --- a/ui-desktop/src-tauri/src/tray.rs +++ b/ui-desktop/src-tauri/src/tray.rs @@ -456,6 +456,7 @@ mod tests { node: None, profile: active_profile.map(Into::into), routing: None, + protection: None, daemon_version: None, split: None, split_apps: None, diff --git a/ui-desktop/src-tauri/src/update_channel.rs b/ui-desktop/src-tauri/src/update_channel.rs index 977bf0f5..e87446aa 100644 --- a/ui-desktop/src-tauri/src/update_channel.rs +++ b/ui-desktop/src-tauri/src/update_channel.rs @@ -14,9 +14,9 @@ use tauri::{AppHandle, Manager, ResourceId, Webview}; use tauri_plugin_updater::UpdaterExt; use url::Url; -/// Base location the signed channel manifests are published to. Both manifests -/// live beside the installer on the latest GitHub release, so the stable one -/// keeps the exact URL that installed 0.3.0 clients already poll. +/// Beta is one file on an atomic Git ref; stable keeps its existing release URL. +const BETA_MANIFEST: &str = + "https://raw.githubusercontent.com/Divaaaan/tenebra/update-channels/beta.json"; const MANIFEST_BASE: &str = "https://github.com/Divaaaan/tenebra/releases/latest/download"; /// The manifest URL for a release channel. `beta` resolves to `beta.json`; @@ -24,12 +24,19 @@ const MANIFEST_BASE: &str = "https://github.com/Divaaaan/tenebra/releases/latest /// `latest.json`, so a stale or malformed channel can only ever fall back to /// the safe stable manifest, never to an unintended endpoint. fn manifest_url(channel: &str) -> String { - let file = if channel == "beta" { - "beta.json" + if channel == "beta" { + BETA_MANIFEST.to_string() } else { - "latest.json" - }; - format!("{MANIFEST_BASE}/{file}") + format!("{MANIFEST_BASE}/latest.json") + } +} + +fn manifest_urls(channel: &str) -> Vec { + let mut urls = vec![manifest_url(channel)]; + if channel == "beta" { + urls.push(manifest_url("stable")); + } + urls } /// The `Update` fields the front end needs to rebuild a handle. Mirrors the @@ -58,10 +65,14 @@ pub async fn check_update_for_channel( webview: Webview, channel: String, ) -> Result, String> { - let endpoint = Url::parse(&manifest_url(&channel)).map_err(|e| e.to_string())?; + let endpoints = manifest_urls(&channel) + .iter() + .map(|url| Url::parse(url)) + .collect::, _>>() + .map_err(|e| e.to_string())?; let updater = webview .updater_builder() - .endpoints(vec![endpoint]) + .endpoints(endpoints) .map_err(|e| e.to_string())? .build() .map_err(|e| e.to_string())?; @@ -117,11 +128,20 @@ mod tests { const LATEST: &str = "https://github.com/Divaaaan/tenebra/releases/latest/download/latest.json"; + #[test] + fn beta_keeps_stable_as_network_failure_fallback() { + assert_eq!( + super::manifest_urls("beta"), + vec![super::BETA_MANIFEST.to_string(), LATEST.to_string()] + ); + assert_eq!(super::manifest_urls("stable"), vec![LATEST.to_string()]); + } + #[test] fn beta_resolves_to_the_beta_manifest() { assert_eq!( manifest_url("beta"), - "https://github.com/Divaaaan/tenebra/releases/latest/download/beta.json" + "https://raw.githubusercontent.com/Divaaaan/tenebra/update-channels/beta.json" ); } diff --git a/ui-desktop/src-tauri/tauri.conf.json b/ui-desktop/src-tauri/tauri.conf.json index 2959ef05..8401c66d 100644 --- a/ui-desktop/src-tauri/tauri.conf.json +++ b/ui-desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Tenebra", - "version": "0.5.11", + "version": "0.6.0", "identifier": "com.tenebra.desktop", "build": { "frontendDist": "../dist", diff --git a/ui-desktop/src/App.audit.test.tsx b/ui-desktop/src/App.audit.test.tsx new file mode 100644 index 00000000..6480ed26 --- /dev/null +++ b/ui-desktop/src/App.audit.test.tsx @@ -0,0 +1,184 @@ +import type { DeepLinkAction, PingResult, State } from "./api"; +import { createElement } from 'react'; +import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { App } from './App.tsx'; +import { renderWithProviders } from './test/renderWithProviders.tsx'; + +const m = vi.hoisted(() => ({ + ready: true, coreError: null as string | null, + checkNodes: vi.fn(), importSubscription: vi.fn(), refreshProfiles: vi.fn(), + updateAvailable: null as string | null, updateConfirm: false, confirmUpdate: vi.fn(), + connect: vi.fn(), disconnect: vi.fn(), onDeepLink: vi.fn(), deep: null as ((e: DeepLinkAction) => void) | null, pings: new Map(), + profiles: [ + { id: 'p1', name: 'Profile A', source: 'manual', nodes: [{id:'n1',name:'Node A',protocol:'vless',server:'198.51.100.10',port:443}], updatedAt:'2026-01-01T00:00:00Z' }, + { id: 'p2', name: 'Profile B', source: 'manual', nodes: [{id:'n2',name:'Node B',protocol:'vless',server:'198.51.100.11',port:443}], updatedAt:'2026-01-01T00:00:00Z' }, + ] +})); +vi.mock('./state/useTenebra.ts', () => ({ + useTenebra: () => ({ ready: m.ready, coreError: m.coreError, state: {state:'idle',daemon_version:'0.5.11',crash_reports_asked:true} as State, profiles: m.profiles, + traffic: {up:0,down:0,upRate:0,downRate:0},logs:[],attempts:null,pickProgress:null, + connect:m.connect,disconnect:m.disconnect,refreshProfiles:m.refreshProfiles }), +})); +vi.mock('./api/index.ts', () => ({ + api: {checkNodes:m.checkNodes, importSubscription:m.importSubscription}, + onDeepLink: m.onDeepLink, + onTrayConnect: vi.fn(async () => () => {}), onTrayShow: vi.fn(async () => () => {}), + takeLaunchDeepLinks: vi.fn(async () => []), +})); +vi.mock('./lib/useNodePings.ts', () => ({ + useNodePings: () => ({results:m.pings,pinging:false,refresh:()=>{}}), +})); +vi.mock('./lib/useUpdateCheck.ts', () => ({ + useUpdateCheck: () => ({available:m.updateAvailable,stalled:false,confirming:m.updateConfirm,installing:false,deferred:false,progress:null,install:vi.fn(),dismiss:vi.fn(),cancelInstall:vi.fn(),confirmInstall:m.confirmUpdate}), +})); +beforeEach(() => { + localStorage.clear(); + m.ready = true; m.coreError = null; + m.deep = null; + m.updateAvailable = null; m.updateConfirm = false; + m.checkNodes.mockResolvedValue({best:"",results:[]}); + m.importSubscription.mockResolvedValue({name:"Imported profile"}); + m.refreshProfiles.mockResolvedValue(undefined); + m.pings = new Map(); + m.onDeepLink.mockImplementation(async (handler) => { m.deep = handler; return () => {}; }); + m.connect.mockResolvedValue({state:'connecting'}); +}); + +it('keeps failed ping unknown and permits a deliberate manual selection', async () => { + m.pings.set('n1',{node:'n1',ok:false,rttMs:0}); + renderWithProviders(createElement(App)); + await screen.findAllByText('Node A'); + const row=screen.getByText('Node A',{selector:'.srv-node-code'}).closest('.srv-row')!; + expect(row).toHaveAttribute('tabindex','0'); + expect(row).toHaveClass('is-dead'); + expect(document.querySelector('.cur-rtt')).toBeNull(); + expect(document.querySelectorAll('.cur-meta .ping-scale-bar.on.good')).toHaveLength(0); +}); + +it.each([['0', false, null], ['0', true, 'service lost'], ['1', false, null], ['1', true, 'service lost']] as const)( + 'blocks keyboard Connect as well as the button in mode %s with ready=%s error=%s', async (mode, ready, error) => { + localStorage.setItem('tenebra.simpleMode', mode); + m.ready = ready; m.coreError = error; + renderWithProviders(createElement(App)); + await screen.findAllByText('Node A'); + expect(screen.getByRole('button', {name: /^(▶\s*)?Connect$/})).toBeDisabled(); + await act(async () => { fireEvent.keyDown(document.body, {key:' ',code:'Space'}); }); + expect(m.checkNodes).not.toHaveBeenCalled(); + expect(m.connect).not.toHaveBeenCalled(); + }, +); + +it.each(['0','1'])('blocks keyboard Connect for a saved subscription without nodes in mode %s', async (mode) => { + localStorage.setItem('tenebra.simpleMode',mode); + const saved = m.profiles; + m.profiles = saved.map(p => ({...p,nodes:[]})); + try { + renderWithProviders(createElement(App)); + await act(async () => {}); + expect(screen.getByRole('button',{name:/^(▶\s*)?Connect$/})).toBeDisabled(); + await act(async () => { fireEvent.keyDown(document.body,{key:' ',code:'Space'}); }); + expect(m.checkNodes).not.toHaveBeenCalled(); + expect(m.connect).not.toHaveBeenCalled(); + } finally { m.profiles = saved; } +}); + +it('waits for the service before asking a new full-mode user to import', async () => { + const saved = m.profiles; + m.profiles = []; m.ready = false; + try { + renderWithProviders(createElement(App)); + expect(screen.getByRole('heading',{name:'Starting Tenebra…'})).toBeInTheDocument(); + expect(screen.queryByRole('textbox',{name:/subscription link/i})).toBeNull(); + } finally { m.profiles = saved; } +}); +afterEach(() => cleanup()); + +it.each(['0', '1'])('keeps first launch focused on a single subscription task in mode %s', async (mode) => { + localStorage.setItem('tenebra.simpleMode', mode); + const saved = m.profiles; + m.profiles = []; + try { + renderWithProviders(createElement(App)); + await act(async () => {}); + expect(screen.getByRole('textbox', {name:/subscription link/i})).toBeInTheDocument(); + expect(document.querySelector('.srv-add')).toBeNull(); + expect(document.querySelector('.connect-btn')).toBeNull(); + expect(document.querySelector('.simple-btn')).toBeNull(); + } finally { m.profiles = saved; } +}); + +it('offers the same TUN conflict confirmation from a profile card', async () => { + m.connect.mockRejectedValue(new Error('another VPN owns the default route')); + renderWithProviders(createElement(App)); + await screen.findAllByText('Node A'); + fireEvent.click(document.querySelector('.srv-add')!); + const card = screen.getByRole('heading', {name:'Profile A'}).closest('li')!; + fireEvent.click(within(card).getByRole('button', {name:/nodes/i})); + fireEvent.click(within(card).getByRole('button', {name:'Connect'})); + const prompt = await screen.findByRole('alertdialog'); + await act(async () => fireEvent.click(within(prompt).getByRole('button', {name:/cancel/i}))); + expect(m.connect).toHaveBeenCalledTimes(1); +}); + +it('clears profile A node when selecting profile B from its card', async () => { + renderWithProviders(createElement(App)); + await screen.findAllByText('Node A'); + fireEvent.click(screen.getByText('Node A', {selector:'.srv-node-code'})); + const add = document.querySelector('.srv-add'); + if (add) fireEvent.click(add); else fireEvent.click(screen.getByRole('button', {name:/subscription/i})); + const cardB = screen.getByRole('heading', {name:'Profile B'}).closest('li')!; + fireEvent.click(within(cardB).getByRole('button', {name:/set active/i})); + fireEvent.click(document.querySelector('.overlay-close')!); + fireEvent.click(document.querySelector('.connect-btn')!); + await waitFor(() => expect(m.connect).toHaveBeenCalledTimes(1)); + expect(m.connect.mock.calls[0].slice(0,2)).toEqual(['p2',undefined]); +}); + +it('shows and cancels a connect deep link in simple mode', async () => { + localStorage.setItem('tenebra.simpleMode','1'); + renderWithProviders(createElement(App)); + await waitFor(() => expect(m.deep).toBeTypeOf('function')); + act(() => m.deep!({action:'connect',profile:'p1'})); + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + expect(m.connect).not.toHaveBeenCalled(); + fireEvent.click(within(screen.getByRole('alertdialog')).getByRole('button', {name: /not now/i})); + expect(m.connect).not.toHaveBeenCalled(); +}); + + + +it('imports a received subscription link without leaving simple mode', async () => { + localStorage.setItem('tenebra.simpleMode','1'); + renderWithProviders(createElement(App)); + await waitFor(() => expect(m.deep).toBeTypeOf('function')); + act(() => m.deep!({action:'import',url:'https://example.invalid/sub'})); + const modal = await screen.findByRole('dialog', {name:'Import'}); + expect(within(modal).getByDisplayValue('https://example.invalid/sub')).toBeInTheDocument(); + fireEvent.change(within(modal).getByRole('textbox', {name:'Name'}), {target:{value:'Imported profile'}}); + fireEvent.click(within(modal).getByRole('button', {name:'Import'})); + await waitFor(() => expect(m.refreshProfiles).toHaveBeenCalledTimes(1)); + expect(m.importSubscription).toHaveBeenCalledWith('https://example.invalid/sub','Imported profile'); + expect(document.querySelector('.app--simple')).toBeInTheDocument(); +}); + +it('shows the update confirmation and acts only on its explicit approval in simple mode', async () => { + localStorage.setItem('tenebra.simpleMode','1'); + m.updateAvailable = '9.9.9'; m.updateConfirm = true; + renderWithProviders(createElement(App)); + const modal = await screen.findByRole('alertdialog'); + expect(document.querySelector('.update-banner')).toBeInTheDocument(); + expect(m.confirmUpdate).not.toHaveBeenCalled(); + fireEvent.click(within(modal).getByRole('button', {name:/install now/i})); + expect(m.confirmUpdate).toHaveBeenCalledTimes(1); +}); + +it('distinguishes a failed prober from no usable node and still tries the connection', async () => { + m.checkNodes.mockRejectedValue(new Error('probe process unavailable')); + renderWithProviders(createElement(App)); + await screen.findAllByText('Node A'); + fireEvent.click(document.querySelector('.connect-btn')!); + await waitFor(() => expect(m.connect).toHaveBeenCalledTimes(1)); + expect(screen.getByText(/The node check could not run/)).toBeInTheDocument(); + expect(screen.queryByText(/No node carried traffic/)).toBeNull(); +}); diff --git a/ui-desktop/src/App.bootstrap.test.tsx b/ui-desktop/src/App.bootstrap.test.tsx index fe6ee7fb..1ae1a410 100644 --- a/ui-desktop/src/App.bootstrap.test.tsx +++ b/ui-desktop/src/App.bootstrap.test.tsx @@ -189,13 +189,13 @@ describe("App bootstrap", () => { }); describe("primary button", () => { - it("is disabled while there is no profile to connect to", async () => { + it("shows the import task before offering a connection", async () => { renderWithProviders(); // handlePrimary has no branch for a null profile, so a live-looking // button here is a button that silently eats the click. SimpleView // already disables its own for exactly this reason. - await waitFor(() => expect(primaryButton()).toBeDisabled()); + await waitFor(() => expect(screen.queryByRole("button", { name: /^(▶\s*)?Connect$/ })).toBeNull()); }); it("is live once a profile has loaded", async () => { diff --git a/ui-desktop/src/App.deeplink.test.tsx b/ui-desktop/src/App.deeplink.test.tsx index 17f4e9c0..06ec57b6 100644 --- a/ui-desktop/src/App.deeplink.test.tsx +++ b/ui-desktop/src/App.deeplink.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; -import { screen, fireEvent, waitFor, act } from "@testing-library/react"; +import { screen, fireEvent, waitFor, act, within } from "@testing-library/react"; import type { DeepLinkAction } from "./api"; import { App } from "./App"; @@ -133,9 +133,9 @@ describe("App deep-link connect gate", () => { it("connects only after the user approves", async () => { await mountAndDeliverConnect(); - await screen.findByRole("alertdialog"); + const dialog = await screen.findByRole("alertdialog"); - fireEvent.click(screen.getByRole("button", { name: "Connect" })); + fireEvent.click(within(dialog).getByRole("button", { name: "Connect" })); // Now — and only now — the backend connect fires, for the named profile. await waitFor(() => expect(mocks.connect).toHaveBeenCalledTimes(1)); diff --git a/ui-desktop/src/App.protection.test.tsx b/ui-desktop/src/App.protection.test.tsx new file mode 100644 index 00000000..317f7bf0 --- /dev/null +++ b/ui-desktop/src/App.protection.test.tsx @@ -0,0 +1,78 @@ +import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +import type { State } from "./api"; +import { App } from "./App"; +import { makeTenebra } from "./test/fixtures"; +import { renderWithProviders } from "./test/renderWithProviders"; + +const m = vi.hoisted(() => ({ + state: { state: "connected", kill_switch: true } as State, + coreError: null as string | null, + setKillSwitch: vi.fn(), disconnect: vi.fn(), + pings: new Map(), +})); +vi.mock("./state/useTenebra", () => ({ useTenebra: () => makeTenebra({ state: m.state, coreError: m.coreError, setKillSwitch: m.setKillSwitch, disconnect: m.disconnect }) })); +vi.mock("./api", () => ({ + api: { checkServices: vi.fn(async () => ({checks:[]})) }, + onTrayConnect: vi.fn(async () => () => {}), onTrayShow: vi.fn(async () => () => {}), + onDeepLink: vi.fn(async () => () => {}), takeLaunchDeepLinks: vi.fn(async () => []), +})); +vi.mock("./lib/useNodePings", () => ({ useNodePings: () => ({ results: m.pings, pinging: false }) })); +vi.mock("./lib/useUpdateCheck", () => ({ useUpdateCheck: () => ({ available: null, stalled: false, confirming: false }) })); +beforeEach(() => { + localStorage.clear(); + m.coreError = null; + m.setKillSwitch.mockResolvedValue(undefined); + m.disconnect.mockResolvedValue(undefined); +}); + +it("does not promise persistent protection from an old daemon's preference alone", async () => { + m.state = { state: "connected", kill_switch: true }; + renderWithProviders(); + await act(async () => {}); + expect(screen.queryByText(/traffic blocked if the tunnel drops/i)).toBeNull(); + expect(screen.getByText(/does not confirm persistent protection/i)).toBeInTheDocument(); + expect(document.querySelector('[data-protection="active"]')).toBeNull(); +}); + +it("shows blocked traffic and lets the user explicitly disconnect to release it in simple mode", async () => { + localStorage.setItem("tenebra.simpleMode", "1"); + m.state = { state: "error", kill_switch: true, protection: { status: "blocked", enforced: true, persistent: true } }; + renderWithProviders(); + expect(screen.getByText(/Internet traffic is blocked/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /disconnect and allow traffic/i })); + await waitFor(() => expect(m.disconnect).toHaveBeenCalledTimes(1)); +}); + +it("retries a failed guard or turns it off only after an explicit action", async () => { + m.state = { state: "error", kill_switch: true, protection: { status: "error", enforced: true, persistent: true, error: "WFP remove failed: access denied" } }; + renderWithProviders(); + const banner = screen.getByRole("alert"); + expect(banner).toHaveTextContent("WFP remove failed: access denied"); + expect(m.setKillSwitch).not.toHaveBeenCalled(); + fireEvent.click(within(banner).getByRole("button", { name: /retry protection/i })); + await waitFor(() => expect(m.setKillSwitch).toHaveBeenCalledWith(true)); + fireEvent.click(within(banner).getByRole("button", { name: /turn protection off/i })); + await waitFor(() => expect(m.setKillSwitch).toHaveBeenCalledWith(false)); +}); + +it("stops showing active protection when the service connection is lost", async () => { + m.state = { state: "connected", kill_switch: true, protection: { status: "active", enforced: true, persistent: true } }; + const { rerender } = renderWithProviders(); + expect(document.querySelector('[data-protection="active"]')).toBeInTheDocument(); + m.coreError = "Lost the connection to the Tenebra service"; + m.state = { ...m.state, state: "connecting" }; + rerender(); + expect(document.querySelector('[data-protection="active"]')).toBeNull(); + expect(screen.getByText(/last confirmed guard/i)).toBeInTheDocument(); + await act(async () => {}); +}); + +it.each(["off", "applying", "unavailable"] as const)("never claims active protection from status %s", async (status) => { + m.state = { state: "connected", kill_switch: true, protection: { status, enforced: false, persistent: false } }; + renderWithProviders(); + expect(document.querySelector(`[data-protection="${status}"]`)).toBeInTheDocument(); + expect(document.querySelector('[data-protection="active"]')).toBeNull(); + expect(m.setKillSwitch).not.toHaveBeenCalled(); + await act(async () => {}); +}); diff --git a/ui-desktop/src/App.simple-onboarding.test.tsx b/ui-desktop/src/App.simple-onboarding.test.tsx new file mode 100644 index 00000000..579512ff --- /dev/null +++ b/ui-desktop/src/App.simple-onboarding.test.tsx @@ -0,0 +1,138 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; + +import { App } from "./App"; +import { makeNode, makeProfile } from "./test/fixtures"; +import { renderWithProviders } from "./test/renderWithProviders"; + +const mocks = vi.hoisted(() => ({ + status: vi.fn(), + listProfiles: vi.fn(), + importSubscription: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + ping: vi.fn(), + checkNodes: vi.fn(), + checkCrashReport: vi.fn(), + onState: vi.fn(), + onTraffic: vi.fn(), + onLog: vi.fn(), + onProfilesChanged: vi.fn(), + onAttempts: vi.fn(), + onPickProgress: vi.fn(), + onTrayConnect: vi.fn(), + onTrayShow: vi.fn(), + onDeepLink: vi.fn(), + takeLaunchDeepLinks: vi.fn(), +})); + +vi.mock("./api", () => ({ + api: { + status: mocks.status, + listProfiles: mocks.listProfiles, + importSubscription: mocks.importSubscription, + connect: mocks.connect, + disconnect: mocks.disconnect, + ping: mocks.ping, + checkNodes: mocks.checkNodes, + checkCrashReport: mocks.checkCrashReport, + }, + onState: mocks.onState, + onTraffic: mocks.onTraffic, + onLog: mocks.onLog, + onProfilesChanged: mocks.onProfilesChanged, + onAttempts: mocks.onAttempts, + onPickProgress: mocks.onPickProgress, + onTrayConnect: mocks.onTrayConnect, + onTrayShow: mocks.onTrayShow, + onDeepLink: mocks.onDeepLink, + takeLaunchDeepLinks: mocks.takeLaunchDeepLinks, +})); + +vi.mock("./lib/updates", () => ({ + checkForUpdate: vi.fn().mockResolvedValue(null), + inAppUpdatesSupported: vi.fn().mockResolvedValue(true), + installUpdate: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(() => { + localStorage.clear(); + localStorage.setItem("tenebra.simpleMode", "1"); + for (const listener of [ + mocks.onState, + mocks.onTraffic, + mocks.onLog, + mocks.onProfilesChanged, + mocks.onAttempts, + mocks.onPickProgress, + mocks.onTrayConnect, + mocks.onTrayShow, + mocks.onDeepLink, + ]) { + // Register without delivering any event, including a profiles notification. + listener.mockResolvedValue(() => {}); + } + mocks.takeLaunchDeepLinks.mockResolvedValue([]); + mocks.status.mockResolvedValue({ state: "idle", crash_reports_asked: true }); + mocks.ping.mockResolvedValue([]); + mocks.checkCrashReport.mockResolvedValue(null); + mocks.connect.mockResolvedValue({ state: "connecting" }); + mocks.disconnect.mockResolvedValue({ state: "idle" }); +}); + +it("retries a failed list refresh without importing the same subscription twice", async () => { + const profile = makeProfile({ id: "saved", name: "Saved subscription", nodes: [makeNode({ name: "Saved server" })] }); + mocks.listProfiles.mockResolvedValueOnce([]).mockRejectedValueOnce(new Error("list timeout")).mockResolvedValue([profile]); + mocks.importSubscription.mockResolvedValue(profile); + renderWithProviders(); + const input = await screen.findByRole("textbox", { name: /subscription link/i }); + fireEvent.change(input, { target: { value: "https://example.invalid/retry" } }); + fireEvent.click(screen.getByRole("button", { name: "Import" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Your subscription was saved"); + fireEvent.click(screen.getByRole("button", { name: "Import" })); + expect(await screen.findByRole("option", { name: "Saved server" })).toBeInTheDocument(); + expect(mocks.importSubscription).toHaveBeenCalledTimes(1); + expect(mocks.listProfiles).toHaveBeenCalledTimes(3); +}); + +it("refreshes after the first inline import without a profile event and makes the imported server connectable", async () => { + const url = "https://subscription.example.invalid/demo"; + const node = makeNode({ id: "imported-node", name: "Imported Amsterdam" }); + const profile = makeProfile({ + id: "imported-profile", + name: "subscription.example.invalid", + url, + nodes: [node], + }); + + // Keep the real useTenebra hook. Its first list is empty; only a subsequent + // explicit refresh can expose the stored profile. The import reply itself + // neither changes the hook's state nor invokes an event listener. + mocks.listProfiles.mockResolvedValueOnce([]).mockResolvedValue([profile]); + mocks.importSubscription.mockResolvedValue(profile); + mocks.checkNodes.mockResolvedValue({ best: node.id, results: [] }); + + renderWithProviders(); + await waitFor(() => expect(mocks.onProfilesChanged).toHaveBeenCalledTimes(1)); + expect(mocks.listProfiles).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("button", { name: "Connect" })).toBeNull(); + + fireEvent.change(screen.getByRole("textbox", { name: /subscription link/i }), { + target: { value: url }, + }); + fireEvent.click(screen.getByRole("button", { name: "Import" })); + + await waitFor(() => expect(mocks.listProfiles).toHaveBeenCalledTimes(2)); + expect(mocks.importSubscription).toHaveBeenCalledWith(url, profile.name); + expect(await screen.findByRole("option", { name: node.name })).toBeInTheDocument(); + const connect = await screen.findByRole("button", { name: "Connect" }); + expect(connect).toBeEnabled(); + expect(screen.queryByRole("textbox", { name: /subscription link/i })).toBeNull(); + expect(mocks.connect).not.toHaveBeenCalled(); + + // Import grants no implicit permission to connect. The existing primary + // action must use the imported profile only after an explicit user click. + fireEvent.click(connect); + await waitFor(() => expect(mocks.connect).toHaveBeenCalledTimes(1)); + expect(mocks.connect.mock.calls[0].slice(0, 2)).toEqual([profile.id, node.id]); +}); diff --git a/ui-desktop/src/App.simple.test.tsx b/ui-desktop/src/App.simple.test.tsx index 2226ce34..4839bb68 100644 --- a/ui-desktop/src/App.simple.test.tsx +++ b/ui-desktop/src/App.simple.test.tsx @@ -163,7 +163,7 @@ describe("App simple mode", () => { ).not.toBeInTheDocument(); // The minimal picker and its automatic option are present. expect( - screen.getByRole("option", { name: "Automatic — fastest" }), + screen.getByRole("option", { name: "Automatic selection" }), ).toBeInTheDocument(); }); @@ -227,10 +227,10 @@ describe("App simple mode", () => { // instantly and then carries nothing is exactly what auto-select used to pick. it("connects to the node the check picked, not to auto", async () => { mocks.checkNodes.mockResolvedValue({ - best: "n-alive", + best: "n1", results: [ { - node: "n-alive", + node: "n1", targets: [ { target: "https://a.example/204", stage: "ok", rttMs: 120 }, ], @@ -244,7 +244,7 @@ describe("App simple mode", () => { await waitFor(() => expect(mocks.connect).toHaveBeenCalledTimes(1)); expect(mocks.checkNodes).toHaveBeenCalledWith("p1"); - expect(mocks.connect.mock.calls[0][1]).toBe("n-alive"); + expect(mocks.connect.mock.calls[0][1]).toBe("n1"); }); // With nothing usable the connect must still be attempted — the core's diff --git a/ui-desktop/src/App.tsx b/ui-desktop/src/App.tsx index fa549c4a..94aabe2f 100644 --- a/ui-desktop/src/App.tsx +++ b/ui-desktop/src/App.tsx @@ -1,3 +1,6 @@ +import { ModalLayer } from "./components/ModalLayer"; +import { ConnectionError } from "./components/ConnectionError"; +import { ProtectionStatus } from "./components/ProtectionStatus"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { UnlistenFn } from "@tauri-apps/api/event"; @@ -27,7 +30,7 @@ import { useTenebra } from "./state/useTenebra"; import { useI18n } from "./i18n/I18nContext"; import { describeCoreError, isTunConflict } from "./i18n/strings"; import { pushToast } from "./lib/toast"; -import type { RoutingMode } from "./api"; +import type { Profile, RoutingMode, State } from "./api"; import { api, onDeepLink, @@ -85,6 +88,8 @@ export function App() { const [query, setQuery] = useState(""); const [overlay, setOverlay] = useState(null); const [busy, setBusy] = useState(false); + const [connectError, setConnectError] = useState(null); + const connectingRef = useRef(false); /** * The bypass, as the core reports it — never as this session remembers it. @@ -111,6 +116,7 @@ export function App() { * instead of an untitled entry: they pasted a link, not a name, and asking * for one would be a step for nothing. */ + const pendingSimpleImport = useRef<{ url: string; profile: Profile } | null>(null); const handleSimpleSubscribe = useCallback(async (url: string) => { let name = "VPN"; try { @@ -119,8 +125,22 @@ export function App() { // Not a URL the parser likes — the core will reject it with a better // message than anything guessed here. } - await api.importSubscription(url, name); - }, []); + // Import does not emit a profiles event. Retain its result when the refresh + // fails, so retrying the same link does not create a second subscription. + const imported = pendingSimpleImport.current?.url === url + ? pendingSimpleImport.current.profile + : await api.importSubscription(url, name); + pendingSimpleImport.current = { url, profile: imported }; + try { + await tenebra.refreshProfiles(); + } catch { + throw new Error("subscription_refresh_pending"); + } + setSelectedProfileId(imported.id); + setSelectedNodeId(""); + pendingSimpleImport.current = null; + pushToast(t.toast.profileImported.replace("{name}", imported.name)); + }, [tenebra.refreshProfiles, t]); // Simple mode: the Settings toggle writes `tenebra.simpleMode`; we mirror it here // and swap the whole shell for SimpleView when it's on. A cross-window write @@ -194,7 +214,7 @@ export function App() { // state (the live `phase`): a relaunch would drop an active VPN, so // auto-install waits for the tunnel to go down and a manual install while it // is up asks first. - const update = useUpdateCheck(phase); + const update = useUpdateCheck(phase, tenebra.ready && !tenebra.coreError); // Daemon build vs app build, latched from state snapshots. A skew means the // privileged daemon predates this UI — nothing the app installs itself @@ -217,7 +237,9 @@ export function App() { // nothing. The crash path above needs both a consent and a crash file, which // together describe almost none of the ways this app actually disappoints // someone — a bypass that stopped carrying video leaves neither. - const problem = useProblemReport(state.daemon_version, tenebra.logs); + const problem = useProblemReport(state.daemon_version, connectError + ? [...tenebra.logs, { id: -1, at: new Date(), level: "error", msg: `connect request: ${connectError}` }] + : tenebra.logs); // The core-owned controls the shell drives directly. Their drawn position is // the state the daemon echoes back, so a refused command leaves the control @@ -256,6 +278,10 @@ export function App() { () => profiles.find((p) => p.id === selectedProfileId) ?? null, [profiles, selectedProfileId], ); + useEffect(() => { + if (selectedNodeId && !selectedProfile?.nodes.some((n) => n.id === selectedNodeId)) setSelectedNodeId(""); + }, [selectedProfile, selectedNodeId]); + const connectedProfile = useMemo( () => profiles.find((p) => p.id === state.profile) ?? null, [profiles, state.profile], @@ -270,7 +296,7 @@ export function App() { const nodeCheck = useNodeCheck(); // And, once connected, whether the three things the user came for actually // work: video, voice, game latency. - const services = useServiceChecks(phase); + const services = useServiceChecks(phase, `${state.profile ?? ""}:${state.node ?? ""}`); // The one thing this app says first. Video failing its check twice running is // worth interrupting over: it is what most people connected for, and the last // time it broke for everyone nobody said a word for four days. @@ -291,12 +317,13 @@ export function App() { city: loc.label, region: loc.region, protocol: n.protocol, - rttMs: probe ? probe.rttMs : null, - dead: probe ? !probe.ok : false, + rttMs: probe?.ok && !pings.stale ? probe.rttMs : null, + stale: !!probe && pings.stale, + dead: probe && !pings.stale ? !probe.ok : false, insecure: n.insecure ?? false, }; }), - [nodes, pings.results], + [nodes, pings.results, pings.stale], ); // Lowest-ping live node, used as the auto target and the idle "current node". @@ -319,7 +346,8 @@ export function App() { : (selectedProfile?.nodes.find((n) => n.id === targetNodeId) ?? null); const liveNodeId = connected ? state.node : targetNodeId; - const livePing = liveNodeId ? pings.results.get(liveNodeId)?.rttMs : undefined; + const liveProbe = liveNodeId ? pings.results.get(liveNodeId) : undefined; + const livePing = liveProbe?.ok && !pings.stale ? liveProbe.rttMs : undefined; // Confirm the App-level actions the user takes (reaching connected, arming the // kill switch, changing routing) with a toast. The initial status load is @@ -340,96 +368,74 @@ export function App() { // unreachable, which leaves the list empty) was swallowed in silence. // Disabling it is the smallest honest fix and matches SimpleView, which has // always gated its own button on having a profile. - const canPrimary = + const canPrimary = !busy && !nodeCheck.checking && ( connected || phase === "connecting" || phase === "health_reconnecting" || - selectedProfileId !== null; + (tenebra.ready && !tenebra.coreError && selectedProfileId !== null && nodes.length > 0)); + + // All entrances share validation, refusal reporting and the one override prompt. + const selectionLocked = !tenebra.ready || !!tenebra.coreError || busy || nodeCheck.checking || phase === "connecting" || phase === "health_reconnecting"; + const connectSafely = useCallback(async (profileId: string, node?: string, auto?: boolean): Promise => { + if (connectingRef.current) return null; + connectingRef.current = true; + setBusy(true); + setConnectError(null); + try { + const profile = profiles.find((p) => p.id === profileId); + if (!profile) throw new Error("profile not found"); + if (node && !profile.nodes.some((n) => n.id === node)) throw new Error("node not found in profile"); + try { + return await tenebra.connect(profileId, node, auto); + } catch (e) { + if (!isTunConflict(e)) throw e; + pushToast(describeCoreError(e, t)); + if (!(await askTunOverride())) return null; + return await tenebra.connect(profileId, node, auto, true); + } + } catch (e) { + setConnectError(e instanceof Error ? e.message : String(e)); + pushToast(describeCoreError(e, t)); + return null; + } finally { + connectingRef.current = false; + setBusy(false); + } + }, [profiles, tenebra, askTunOverride, t]); const handlePrimary = useCallback(() => { - if (busy) return; + if (!canPrimary) return; setBusy(true); + setConnectError(null); void (async () => { try { - if ( - connected || - phase === "connecting" || - phase === "health_reconnecting" - ) { - // A click during an auto-recovery aborts it too, rather than racing a - // fresh connect against the watchdog's in-flight reconnect. + if (connected || phase === "connecting" || phase === "health_reconnecting") { await tenebra.disconnect(); } else if (selectedProfileId) { - // No explicit node → let the core choose. The persisted "auto-select - // fastest" preference decides between ping-ranked and protocol-fallback - // order; it is read fresh (like autoconnect) so a Settings toggle takes - // effect on the next connect without prop-threading. When a node is - // selected, auto is moot — the core honours the explicit exit. - let node = selectedNodeId || undefined; + // Validate against the current profile even if a refresh removed the pin. + let node = nodes.some((n) => n.id === selectedNodeId) ? selectedNodeId : undefined; let auto = node ? undefined : getAutoFastest(); - - // Before letting latency decide, find out what actually carries - // traffic. A node whose proxy handshake has stopped answering still - // completes a TCP dial instantly, so it reads as the *fastest* node and - // wins a latency-ranked pick while every request through it hangs — - // which is precisely how a working-looking connect left the user with - // no internet. Measuring first costs seconds; picking blind costs the - // session. if (!node) { - const best = await nodeCheck.run(selectedProfileId); - if (best) { - node = best; + const outcome = await nodeCheck.run(selectedProfileId); + if (outcome.kind === "checked" && outcome.best) { + node = outcome.best; auto = undefined; } else { - // Nothing passed. Say so — and still try: the core's fallback walk - // tries nodes in turn and may get through where a one-shot probe - // did not, and refusing to connect at all would be a worse answer - // than a slow one. - pushToast(t.servers.noneUsable); + pushToast(outcome.kind === "failed" ? t.errors.probeFailed : t.servers.noneUsable); } } - try { - await tenebra.connect(selectedProfileId, node, auto); - } catch (e) { - // The guard refuses to raise our tun while another VPN owns the - // default route. That refusal is correct by default — two tunnels - // routing everything leave the machine offline — but it must not be - // a dead end: the user is the only one who knows whether the other - // tunnel overlaps, so ask, and honour the answer for this connect - // only. - if (!isTunConflict(e)) throw e; - // Name the refusal before asking: the prompt is a yes/no, this line - // is the reason and the fix (turn the other tunnel off). - pushToast(describeCoreError(e, t)); - // Declining is an answer, not a second failure. Rethrowing here sent - // the same error to the outer catch, which said the very same line - // again — one refusal, two identical toasts. - if (!(await askTunOverride())) return; - await tenebra.connect(selectedProfileId, node, auto, true); - } + await connectSafely(selectedProfileId, node, auto); } } catch (e) { - // Say why nothing happened. A refused connect leaves the button exactly - // where it was, and swallowing the reason (the old behaviour) turned - // every refusal — a guard, a vanished node, a core that will not answer — - // into "the button does not work", which is unanswerable from the outside. + setConnectError(e instanceof Error ? e.message : String(e)); pushToast(describeCoreError(e, t)); - } finally { - setBusy(false); - } + } finally { setBusy(false); } })(); - }, [ - busy, - connected, - phase, - tenebra, - selectedProfileId, - selectedNodeId, - askTunOverride, - ]); + }, [canPrimary, connected, phase, tenebra, selectedProfileId, selectedNodeId, nodes, nodeCheck, connectSafely, t]); const handleSelectNode = useCallback( (id: string) => { + if (selectionLocked) return; setSelectedNodeId(id); if (!connected || !selectedProfileId) return; // Change the exit on a live tunnel. The core steers the running sing-box @@ -439,9 +445,9 @@ export function App() { // nothing was reconnected, `connecting` means it is coming back up. Say so, // rather than showing the same "reconnecting" for both and teaching the user // that changing exits costs them their session. - void tenebra - .connect(selectedProfileId, id) + void connectSafely(selectedProfileId, id) .then((st) => { + if (!st) return; const name = selectedProfile?.nodes.find((n) => n.id === id)?.name ?? id; pushToast( @@ -453,7 +459,7 @@ export function App() { }) .catch(() => {}); }, - [connected, selectedProfileId, selectedProfile, tenebra, t], + [selectionLocked, connected, selectedProfileId, selectedProfile, connectSafely, t], ); const handleSelectProfile = useCallback((id: string) => { @@ -465,13 +471,13 @@ export function App() { // already connected, re-handshake straight away onto the fastest node, the // node-click counterpart for auto. const handleSelectAuto = useCallback(() => { + if (selectionLocked) return; setSelectedNodeId(""); if (connected && selectedProfileId) { - void tenebra - .connect(selectedProfileId, undefined, getAutoFastest()) + void connectSafely(selectedProfileId, undefined, getAutoFastest()) .catch(() => {}); } - }, [connected, selectedProfileId, tenebra]); + }, [selectionLocked, connected, selectedProfileId, connectSafely]); const handleSetRouting = useCallback( (mode: RoutingMode) => { @@ -632,13 +638,12 @@ export function App() { // profile appears, honouring the fastest-node preference like a manual connect. useEffect(() => { if (!pendingConnect || !tenebra.ready) return; - if (!profiles.some((p) => p.id === pendingConnect)) return; const id = pendingConnect; setPendingConnect(null); setSelectedProfileId(id); setSelectedNodeId(""); - void tenebra.connect(id, undefined, getAutoFastest()).catch(() => {}); - }, [pendingConnect, tenebra, profiles]); + void connectSafely(id, undefined, getAutoFastest()); + }, [pendingConnect, tenebra, profiles, connectSafely]); // Deep links (tenebra://). Links the app was launched with (cold start) are // drained once on mount; links opened while it runs arrive as events. Both go @@ -712,50 +717,19 @@ export function App() { // Simple mode: one calm screen instead of the full shell. It reads the same // connection state and shares the same actions, so the two never disagree. The // eclipse easter egg still rides along; the console/toast layers do too. - if (simpleMode) { - return ( -
- - {tunConflictPrompt} - {problemReport} - - -
- ); - } - return ( -
- - - {connected && killSwitch && ( -
- ⚠ {t.bottom.killBanner} -
- )} - - {tenebra.coreError && ( +
+ {!simpleMode && { + localStorage.setItem(SIMPLE_MODE_KEY, "true"); + window.dispatchEvent(new CustomEvent("tenebra:simple-mode")); + }} />} + + tenebra.setKillSwitch(true)} onDisable={() => tenebra.setKillSwitch(false)} + onDisconnect={tenebra.disconnect} onError={reportRefusal} /> + + {tenebra.coreError && !simpleMode && ( // The core never answered, so nothing on this screen is backed by // anything: no profiles, no real state, every action doomed. Say it in // the banner strip the update and skew notices already use (no new @@ -771,6 +745,7 @@ export function App() { installing={update.installing} deferred={update.deferred} progress={update.progress} + waitingForStatus={!tenebra.ready || !!tenebra.coreError} onInstall={update.install} onDismiss={update.dismiss} /> @@ -813,6 +788,36 @@ export function App() { /> )} + {(connectError || (phase === "error" && state.error)) && } + {simpleMode ? ( + setOverlay("profiles")} + onSettings={() => setOverlay("settings")} + reportNudge={nudge} + /> + ) : (<> {nudge} {/* The one setup step lives on the main screen, not behind a menu: what a @@ -820,13 +825,20 @@ export function App() { somewhere else. The strip removes itself the moment it is done, so it costs a returning user nothing. */} 0} + hasProfile={profiles.length > 0 || !tenebra.ready || !!tenebra.coreError} onSubscribe={handleSimpleSubscribe} /> + {profiles.length === 0 && (!tenebra.ready || tenebra.coreError) &&
+

{tenebra.coreError ? t.simple.serviceUnavailable : t.simple.serviceStarting}

+

{t.simple.serviceHelp}

+
} -
+ {(profiles.length > 0 || connected || phase === "connecting" || phase === "health_reconnecting") &&
setOverlay("profiles")} pinging={pings.pinging} + disabled={selectionLocked} /> -
+
} setOverlay("logs")} onSettings={() => setOverlay("settings")} onReportProblem={problem.open} - bypassInstalled={bypassInstalled} + bypassInstalled={tenebra.ready && !tenebra.coreError && bypassInstalled} bypassOn={bypassOn} bypassStrategy={bypassStrategy} /> + )} + {overlayShown.value && ( -
setOverlay(null)} className={`overlay${overlayShown.leaving ? " is-leaving" : ""}`} role="dialog" aria-modal="true" + aria-label={overlayShown.value === "profiles" ? t.profiles.title : overlayShown.value === "settings" ? t.settings.title : t.logs.title} onClick={(e) => { if (e.target === e.currentTarget) setOverlay(null); }} @@ -908,7 +924,8 @@ export function App() { setOverlay(null)} @@ -920,7 +937,7 @@ export function App() { {overlayShown.value === "logs" && }
-
+ )} {connectRequestShown.value && ( diff --git a/ui-desktop/src/App.tunconflict.test.tsx b/ui-desktop/src/App.tunconflict.test.tsx index 1017106e..2188b8ed 100644 --- a/ui-desktop/src/App.tunconflict.test.tsx +++ b/ui-desktop/src/App.tunconflict.test.tsx @@ -186,7 +186,7 @@ describe("App tun-conflict override", () => { fireEvent.click(primaryButton()); await screen.findByRole("alertdialog"); // Mid-question the button is held down — that part was never the bug. - expect(primaryButton()).toBeDisabled(); + expect(screen.getByRole("button", { name: en.simple.preparing })).toBeDisabled(); fireEvent.click( screen.getByRole("button", { name: en.daemon.tunConflictOverrideCancel }), diff --git a/ui-desktop/src/api/types.ts b/ui-desktop/src/api/types.ts index 6364891e..68b68344 100644 --- a/ui-desktop/src/api/types.ts +++ b/ui-desktop/src/api/types.ts @@ -33,6 +33,14 @@ export type ConnectionMode = "tun" | "system-proxy"; export type NodeProtocol = "vless" | "hysteria2" | "amneziawg" | "shadowsocks" | "trojan" | "vmess"; +/** Native guard evidence; the requested kill_switch preference is separate. */ +export interface Protection { + status: "off" | "applying" | "blocked" | "active" | "error" | "unavailable"; + enforced: boolean; + persistent: boolean; + error?: string; +} + export interface State { state: ConnectionState; node?: string; @@ -51,8 +59,10 @@ export interface State { split?: SplitMode; /** Normalized executable names the split applies to; omitted when off. */ split_apps?: string[]; - /** Whether the kill switch is armed; omitted (treated as off) when it isn't. */ + /** Requested preference, not proof of installed or active protection. */ kill_switch?: boolean; + /** Omitted by older cores, which cannot confirm persistent protection. */ + protection?: Protection; /** * Whether forced TLS ClientHello fragmentation is armed (the DPI-obfuscation * override); omitted (treated as off) when it isn't. @@ -376,6 +386,7 @@ export interface StateEvent { state: ConnectionState; node?: string; error?: string; + protection?: Protection; } export interface TrafficEvent { diff --git a/ui-desktop/src/components/BottomBar.tsx b/ui-desktop/src/components/BottomBar.tsx index bcaa0d42..7f2fd611 100644 --- a/ui-desktop/src/components/BottomBar.tsx +++ b/ui-desktop/src/components/BottomBar.tsx @@ -102,13 +102,13 @@ export function BottomBar({ unless it had crashed *and* the user had opted into crash reports, which is not how most things break. */} diff --git a/ui-desktop/src/components/ConnectionError.test.tsx b/ui-desktop/src/components/ConnectionError.test.tsx new file mode 100644 index 00000000..84181fb8 --- /dev/null +++ b/ui-desktop/src/components/ConnectionError.test.tsx @@ -0,0 +1,23 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { expect, it, vi } from "vitest"; +import { ConnectionError } from "./ConnectionError"; +import { renderWithProviders } from "../test/renderWithProviders"; + +it("explains protocol failure in Russian and preserves the technical detail before reporting", () => { + const report = vi.fn(); + const error = "all protocols failed: vless handshake rejected"; + renderWithProviders(, { lang: "ru" }); + expect(screen.getByRole("alert")).toHaveTextContent("Обновите подписку"); + expect(screen.getByText(error)).toBeInTheDocument(); + expect(report).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button")); + expect(report).toHaveBeenCalledTimes(1); +}); + +it.each(["dial tcp 192.0.2.1:443: i/o timeout", "engine executable not found"])( + "does not blame the service or selected profile without evidence: %s", (error) => { + renderWithProviders( {}} />); + expect(screen.getByRole("alert")).toHaveTextContent("Connection failed. Try another node"); + expect(screen.getByText(error)).toBeInTheDocument(); + }, +); diff --git a/ui-desktop/src/components/ConnectionError.tsx b/ui-desktop/src/components/ConnectionError.tsx new file mode 100644 index 00000000..62e6eae1 --- /dev/null +++ b/ui-desktop/src/components/ConnectionError.tsx @@ -0,0 +1,20 @@ +import { useI18n } from "../i18n/I18nContext"; +import { describeCoreError } from "../i18n/strings"; + +export function ConnectionError({ error, onReport }: { error: string; onReport: () => void }) { + const { t } = useI18n(); + const lower = error.toLowerCase(); + const explanation = lower.includes("all protocols failed") || lower.includes("handshake") + ? t.errors.protocolFailed + : /\b(profile|node)\b.*(not found|missing|no longer)|no (usable )?nodes/.test(lower) + ? t.errors.selectionFailed + : /\bpipe\b|\bipc\b|tenebra[- ](core|service)|background service|core.*(down|unreachable)/.test(lower) + ? t.errors.serviceFailed + : describeCoreError(error, t) !== t.daemon.commandFailed + ? describeCoreError(error, t) : t.errors.connectFailed; + return
+

{explanation}

+
{t.errors.details}
{error}
+ +
; +} diff --git a/ui-desktop/src/components/ConnectionPanel.test.tsx b/ui-desktop/src/components/ConnectionPanel.test.tsx index 8c91e188..833cef7f 100644 --- a/ui-desktop/src/components/ConnectionPanel.test.tsx +++ b/ui-desktop/src/components/ConnectionPanel.test.tsx @@ -27,6 +27,21 @@ function baseProps(overrides: Partial[0]> = { } describe("ConnectionPanel", () => { + it("clears stale connected information when the service is unavailable but preserves Disconnect", () => { + const {container} = renderWithProviders(); + expect(screen.getByRole("heading",{name:"Service unavailable"})).toBeInTheDocument(); + expect(screen.queryByText("203.0.113.7")).toBeNull(); + expect(container.querySelector(".cur-rtt")).toBeNull(); + expect(screen.getByRole("button",{name:/Disconnect/})).toBeEnabled(); + }); + + it("keeps Abort available during service loss and shows a confirmed block as its own state", () => { + const view = renderWithProviders(); + expect(screen.getByRole("button",{name:/ABORT/})).toBeEnabled(); + view.rerender(); + expect(screen.getByRole("heading",{name:"Internet traffic blocked"})).toBeInTheDocument(); + expect(screen.queryByText("tunnel disconnected · select a node and connect")).toBeNull(); + }); describe("idle", () => { it("shows the disconnected status, a Connect label and dimmed stats", () => { renderWithProviders(); @@ -41,7 +56,7 @@ describe("ConnectionPanel", () => { expect(screen.getByText("—")).toBeInTheDocument(); // The off sub-line copy. expect( - screen.getByText("traffic unprotected · select a node and connect"), + screen.getByText("tunnel disconnected · select a node and connect"), ).toBeInTheDocument(); }); }); @@ -322,6 +337,15 @@ describe("ConnectionPanel", () => { ], }; + it("removes the successful fallback as soon as the service becomes unavailable", () => { + const ok: AttemptsEvent = {outcome:"ok",items:[{seq:1,protocol:"vless",node:"n1",status:"ok",last_good:false}]}; + const view = renderWithProviders(); + expect(screen.getByText("Protocol fallback")).toBeInTheDocument(); + view.rerender(); + expect(screen.queryByText("Protocol fallback")).toBeNull(); + expect(screen.getByRole("heading",{name:"Service unavailable"})).toBeInTheDocument(); + }); + it("replaces the node card with the fallback walk while connecting", () => { renderWithProviders( , diff --git a/ui-desktop/src/components/ConnectionPanel.tsx b/ui-desktop/src/components/ConnectionPanel.tsx index eb1f9d1f..ef8c02ed 100644 --- a/ui-desktop/src/components/ConnectionPanel.tsx +++ b/ui-desktop/src/components/ConnectionPanel.tsx @@ -3,7 +3,6 @@ import { useEffect, useState } from "react"; import type { AttemptsEvent, ConnectionState, RoutingMode } from "../api"; import { useI18n } from "../i18n/I18nContext"; import { formatBytes } from "../lib/format"; -import { useScrambledText } from "../lib/useScrambledText"; import type { TrafficHistory } from "../lib/useTrafficHistory"; import { FallbackPanel } from "./FallbackPanel"; import { PingScale } from "./PingScale"; @@ -11,6 +10,9 @@ import { TrafficChart } from "./TrafficChart"; interface ConnectionPanelProps { phase: ConnectionState; + ready?: boolean; + coreUnreachable?: boolean; + protectionBlocked?: boolean; /** Active/selected node display code (the node's own name). */ nodeCode: string; /** Derived location subtitle; "" hides the line. */ @@ -114,6 +116,9 @@ function useOkHold(attempts: AttemptsEvent | null | undefined): boolean { export function ConnectionPanel({ phase, + ready = true, + coreUnreachable = false, + protectionBlocked = false, nodeCode, nodeCity, exitServer, @@ -135,7 +140,8 @@ export function ConnectionPanel({ onChange, }: ConnectionPanelProps) { const { t } = useI18n(); - const connected = phase === "connected"; + const unavailable = !ready || coreUnreachable; + const connected = phase === "connected" && !unavailable; const pending = phase === "connecting"; // The automatic health-failover recovery: the core is reconnecting to a // healthy node on its own after the active one degraded. It runs the same @@ -152,18 +158,19 @@ export function ConnectionPanel({ // A pseudo-phase: it drives the status-word class and the rail exactly the way // a real phase does, so measuring is one more stop on the same road rather // than a separate widget bolted beside it. - const displayPhase = measuring ? "checking" : phase; + const displayPhase = unavailable ? "unavailable" : protectionBlocked && !inFlight && !measuring ? "blocked" : measuring ? "checking" : phase; const working = measuring || inFlight; - const word = measuring ? t.conn.wordChecking : t.state[phase]; - const displayWord = useScrambledText(word); - const buttonLabel = connected - ? `▢ ${t.home.disconnect}` + const word = coreUnreachable ? t.simple.serviceUnavailable : !ready ? t.simple.serviceStarting + : protectionBlocked && !inFlight && !measuring ? t.simple.trafficBlocked + : measuring ? t.conn.wordChecking : t.state[phase]; + const buttonLabel = phase === "connected" + ? t.home.disconnect : inFlight - ? `· · · ${t.conn.abort}` + ? t.conn.abort : measuring - ? `· · · ${t.conn.measuring}` - : `▶ ${t.home.connect}`; + ? t.conn.measuring + : t.home.connect; const routeName = routing === "global" @@ -174,10 +181,10 @@ export function ConnectionPanel({ // A bare integer ping feeds the strength meter; "—" (no probe) shows neither // the meter nor a value — honest over decorative. - const pingValue = /^\d+$/.test(ping) ? Number(ping) : null; + const pingValue = !unavailable && /^\d+$/.test(ping) ? Number(ping) : null; const okHold = useOkHold(attempts); - const showFallback = shouldShowFallback(attempts, phase, okHold); + const showFallback = !unavailable && shouldShowFallback(attempts, phase, okHold); // The "change" affordance broadcasts a focus-search intent the server-list pane // listens for, so the two panes stay decoupled; the onChange callback is kept @@ -187,7 +194,11 @@ export function ConnectionPanel({ onChange(); }; - const subLine = measuring ? ( + const subLine = unavailable ? ( + {t.simple.serviceHelp} + ) : protectionBlocked && !inFlight && !measuring ? ( + {t.simple.blockedHint} + ) : measuring ? ( // Say what the seconds are being spent on. "Connecting…" would be a lie — // nothing is being connected yet — and silence was what made the wait read // as a hang. @@ -227,12 +238,12 @@ export function ConnectionPanel({ )} -
+

+ {/* Always mounted: the track is a hairline rule, and only the runner comes and goes. Mounting the rail with the work shifted everything under it by 4px at the exact moment the status changed. */} @@ -256,9 +267,12 @@ export function ConnectionPanel({ type="button" className={`connect-btn${connected ? " on" : ""}${pending ? " pending" : ""}${reconnecting ? " reconnecting" : ""}${measuring ? " checking" : ""}`} onClick={onPrimary} - disabled={disabled} + disabled={disabled || (unavailable && phase !== "connected" && !inFlight)} aria-busy={working || undefined} > + {buttonLabel}