diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d9157e..6efb604 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,91 @@ jobs: with: name: tenebra-windows-installer path: ui-desktop/src-tauri/target/release/bundle/nsis/*-setup.exe + linux: + # Proves the Linux desktop shell builds: the unix-socket backend that drives + # the systemd daemon, and the deb bundle itself. The `core` job above already + # runs the Go suite on Linux — including the linux-tagged control socket and + # the SO_PEERCRED peer check — so this job covers the Rust and bundler half. + # + # Pinned to 22.04 rather than ubuntu-latest deliberately. The bundled + # binaries link against the runner's glibc, and anything built on 24.04 + # refuses to start on an older distribution; the rolling Arch target would + # not care, but the deb is precisely what Debian-family users install. + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: '1.26' + cache: false + - uses: actions/setup-node@v6 + with: + node-version: 24 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - name: Install the GTK and WebKit build dependencies + # Tauri v2 links against webkit2gtk-4.1; the appindicator package backs + # the tray, and patchelf is what the bundler uses to rewrite rpaths. + 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: Check Rust formatting + working-directory: ui-desktop/src-tauri + run: cargo fmt --check + - name: Fetch sing-box and the rule-sets + run: bash scripts/fetch-resources.sh --arch amd64 + - name: Build core sidecar + # tauri-build validates the externalBin at compile time, so the sidecar + # has to exist under its target triple before cargo runs at all. + run: go build -o ui-desktop/src-tauri/binaries/tenebra-core-x86_64-unknown-linux-gnu ./cmd/tenebra-core + - name: Lint the Rust backend + working-directory: ui-desktop/src-tauri + run: cargo clippy --all-targets -- -D warnings + - name: Test the Rust backend + working-directory: ui-desktop/src-tauri + run: cargo test --all-targets + - name: Install front-end dependencies + working-directory: ui-desktop + run: npm ci + - name: Build the Debian package + working-directory: ui-desktop + # Same reasoning as the Windows job: CI only proves the bundle builds, so + # it overrides the config to skip updater artifacts and never touches the + # signing key. AppImage is left to the release workflow — it downloads a + # linuxdeploy toolchain on every run and would double this job's time for + # no extra coverage of our own code. + run: npm run tauri build -- --bundles deb --config src-tauri/tauri.ci.conf.json + - uses: actions/upload-artifact@v6 + with: + name: tenebra-linux-deb + path: ui-desktop/src-tauri/target/release/bundle/deb/*.deb + arch-package: + # Lints the Arch packaging on every push. It stops short of a full makepkg on + # purpose: the PKGBUILD takes its source from a git tag, so building it only + # works on a tagged commit — that is the release workflow's job. What is + # cheap to catch here is the rest: broken shell, a malformed PKGBUILD, and + # the namcap complaints that would otherwise surface only at release time. + runs-on: ubuntu-latest + container: archlinux:base-devel + steps: + - name: Install the packaging tools + run: pacman -Syu --noconfirm --needed namcap git + - uses: actions/checkout@v6 + - name: Parse the PKGBUILD + # makepkg refuses to run as root, so the checks run as a normal user. + # --printsrcinfo parses the whole file without fetching any source. + run: | + useradd -m builder + chown -R builder . + su builder -c 'cd packaging/arch && makepkg --printsrcinfo' > /tmp/SRCINFO + head -30 /tmp/SRCINFO + - name: Lint the PKGBUILD + run: | + cd packaging/arch + namcap PKGBUILD macos: # Proves the Go core plus the darwin-tagged macOS adapter (adapters/macos) # compile and pass their tests on a real Apple Silicon runner — the macOS diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28f0786..f82382d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,7 +73,7 @@ jobs: # resolving new versions, so a signed release always matches the lockfile. args: --bundles nsis -- --locked releaseBody: | - Desktop builds of Tenebra for Windows and macOS. + Desktop builds of Tenebra for Windows, macOS and Linux. **Windows** is user-ready: run the installer (SmartScreen warns on an unsigned build -> More info -> Run anyway); it sets up the background @@ -87,6 +87,16 @@ jobs: the "macOS note" in the README before downloading. A signed, click-to-run macOS build is planned. + **Linux**: the Arch package (.pkg.tar.zst) is the click-to-run one + — `pacman -U` installs the app, the core and the systemd unit, and + `systemctl enable --now tenebra` starts the tunnel service. The .deb + and the AppImage install only the app and the core: the privileged + service still has to be set up once by hand with + scripts/linux/install-daemon.sh, because the Debian bundle cannot run + a post-install script. Updates for a packaged install come from your + package manager, not the in-app updater, which only works for the + AppImage. + 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). @@ -169,7 +179,7 @@ jobs: includeUpdaterJson: true args: --target universal-apple-darwin --bundles app,dmg -- --locked releaseBody: | - Desktop builds of Tenebra for Windows and macOS. + Desktop builds of Tenebra for Windows, macOS and Linux. **Windows** is user-ready: run the installer (SmartScreen warns on an unsigned build -> More info -> Run anyway); it sets up the background @@ -183,6 +193,16 @@ jobs: the "macOS note" in the README before downloading. A signed, click-to-run macOS build is planned. + **Linux**: the Arch package (.pkg.tar.zst) is the click-to-run one + — `pacman -U` installs the app, the core and the systemd unit, and + `systemctl enable --now tenebra` starts the tunnel service. The .deb + and the AppImage install only the app and the core: the privileged + service still has to be set up once by hand with + scripts/linux/install-daemon.sh, because the Debian bundle cannot run + a post-install script. Updates for a packaged install come from your + package manager, not the in-app updater, which only works for the + AppImage. + 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). @@ -194,3 +214,144 @@ jobs: # 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 + # that release's latest.json, so running them in sequence keeps the + # read-modify-write deterministic. A failure here leaves the Windows and + # macOS assets standing; Linux follows in a fix-forward tag. + # + # 22.04 rather than ubuntu-latest: the AppImage and the deb link against the + # runner's glibc, and a 24.04 build refuses to start on anything older. + needs: [ci, version-check, macos] + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: '1.26' + cache: false + - uses: actions/setup-node@v6 + with: + node-version: 24 + - uses: dtolnay/rust-toolchain@stable + - name: Install the GTK and WebKit build 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 sing-box and the rule-sets + 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: Install front-end dependencies + working-directory: ui-desktop + run: npm ci + - name: Resolve the release channel from the tag + id: channel + shell: bash + run: | + # Same rule as the other two jobs: a SemVer prerelease suffix marks the + # GitHub release prerelease, keeping /releases/latest/ on the last full + # release so stable clients are unaffected. + if [[ "$GITHUB_REF_NAME" == *-* ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + - name: Build, sign, and publish the release + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + with: + projectPath: ui-desktop + tagName: ${{ github.ref_name }} + releaseName: Tenebra ${{ github.ref_name }} + releaseDraft: false + prerelease: ${{ steps.channel.outputs.prerelease }} + # Only the AppImage carries an updater artifact — the Tauri updater + # cannot replace a package-managed install, which is why the app hides + # its update controls when it was installed from a package. + includeUpdaterJson: true + args: --bundles deb,appimage -- --locked + releaseBody: | + Desktop builds of Tenebra for Windows, macOS and Linux. + + **Windows** is user-ready: run the installer (SmartScreen warns on an + unsigned build -> More info -> Run anyway); it sets up the background + service, and the in-app updater keeps both app and service current. + + **macOS is for advanced users, not click-to-run yet.** The DMG alone + will NOT give you a working tunnel: macOS needs a root helper daemon + that is currently installed by hand with a sudo script + (scripts/macos/install-daemon.sh), and the build is unsigned (first + launch: System Settings -> Privacy & Security -> Open Anyway). Read + the "macOS note" in the README before downloading. A signed, + click-to-run macOS build is planned. + + **Linux**: the Arch package (.pkg.tar.zst) is the click-to-run one + — `pacman -U` installs the app, the core and the systemd unit, and + `systemctl enable --now tenebra` starts the tunnel service. The .deb + and the AppImage install only the app and the core: the privileged + service still has to be set up once by hand with + scripts/linux/install-daemon.sh, because the Debian bundle cannot run + a post-install script. Updates for a packaged install come from your + package manager, not the in-app updater, which only works for the + AppImage. + + 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 + # because it builds inside an Arch container: the package links against + # Arch's own glibc and webkit, which is the whole point — a deb built on + # Ubuntu would drag the wrong library names along. + # + # The PKGBUILD takes its source from the git tag, so this can only run once + # the tag exists; it also means the package is built from exactly what was + # tagged rather than from the runner's working copy. + needs: [ci, version-check, linux] + runs-on: ubuntu-latest + container: archlinux:base-devel + steps: + - name: Install the packaging tools + run: pacman -Syu --noconfirm --needed git github-cli + - uses: actions/checkout@v6 + - name: Build the package + # The revision comes from the PKGBUILD's own pkgver, which + # scripts/set-version.mjs keeps in step with the tag; version-check has + # already asserted they agree before this job runs. + run: | + set -euo pipefail + # makepkg refuses to run as root, so build as an unprivileged user that + # is allowed to install the build dependencies it resolves. + 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' + ls -la packaging/arch/*.pkg.tar.zst + - name: Verify the package installs + run: | + set -euo pipefail + pacman -U --noconfirm packaging/arch/*.pkg.tar.zst + # Prove the layout the daemon and the UI resolve against is really there. + test -x /usr/bin/tenebra-core + test -x /usr/lib/tenebra/sing-box + test -f /usr/lib/systemd/system/tenebra.service + - name: Attach the package to the release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + run: gh release upload "$TAG" packaging/arch/*.pkg.tar.zst --clobber diff --git a/.gitignore b/.gitignore index 941da90..bd64e38 100644 --- a/.gitignore +++ b/.gitignore @@ -17,14 +17,27 @@ target/ /ui-desktop/src-tauri/target/ /ui-desktop/src-tauri/gen/ +# makepkg builds in place, so running packaging/arch/PKGBUILD from a checkout +# drops a source clone, a build tree and the finished package next to it +/packaging/arch/src/ +/packaging/arch/pkg/ +/packaging/arch/tenebra/ +/packaging/arch/*.pkg.tar.* +/packaging/arch/*.srs +/packaging/arch/sing-box-*.tar.gz + # sing-box and tunnel binaries are fetched at build time, never committed /bin/ sing-box sing-box.exe wintun.dll -# gomobile bind leftover: the ARM core binary produced during Android bind work -tenebra-core +# gomobile bind leftover: the ARM core binary produced during Android bind work. +# Anchored to the repository root on purpose — an unanchored pattern also matches +# the cmd/tenebra-core directory, and git then silently refuses to add anything +# new inside it (existing files stay tracked, so the hole is invisible until a +# fresh source file goes missing from a commit). +/tenebra-core # sing-box runtime cache, written next to wherever the core runs cache.db diff --git a/CHANGELOG.md b/CHANGELOG.md index bec8df0..5492625 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,45 @@ All notable changes to Tenebra are documented here. The format follows > [project status](README.md#project-status). Expect breaking changes between > 0.x releases. +## [0.4.6] - 2026-07-27 + +### Added + +- **Linux is a supported desktop platform.** Until now Linux only compiled: the + core was built and tested in CI, but it could not host a daemon there — the + socket transport was rejected off macOS — and no installable artifact was + produced. It now runs the same way Windows and macOS do: one privileged core + under systemd owns the tunnel and serves the control protocol on + `/run/tenebra.sock`, and the unprivileged desktop app attaches to it. The + connecting user is authenticated from the kernel's own socket credentials, + and the profile store under `/var/lib/tenebra/data` is created root-only and + re-checked on every start. +- **An Arch package.** `packaging/arch/PKGBUILD` builds the core, the desktop + app and the systemd unit from source; the release workflow builds it in an + Arch container, verifies it installs, and attaches the resulting + `.pkg.tar.zst` to the release. sing-box is bundled with a pinned digest + rather than declared as a dependency: there is no such package in Arch's own + repositories, only in the AUR, and it goes into a private directory so an + existing AUR install is untouched. +- **Debian and AppImage bundles** for everything else, plus install and + uninstall scripts for setting the service up by hand. Only the Arch package + installs the systemd unit for you; the Debian bundle cannot run a + post-install script, so there the service is one manual step. + +### Fixed + +- **Linux: the crash and core logs no longer live in `/tmp`.** They now follow + the XDG data directory. `/tmp` is shared and world-writable, so a symlink + planted at the old path could have redirected an append-only writer. +- **Linux: `tenebra://` links now work in a release build.** Deep-link + registration was compiled in only for development builds, and an AppImage has + no installer to claim the scheme on its behalf, so a released build never + registered as a handler at all. +- **Linux: the app no longer offers an update it cannot install.** The bundled + updater can only replace an AppImage, so an install that came from a package + manager now says where its updates come from instead of showing a button that + would fail. + ## [0.4.5] - 2026-07-25 ### Fixed @@ -568,7 +607,8 @@ Initial tagged release. first run. Updates delivered in-app are minisign-verified against the bundled key; only the initial download is unsigned. -[Unreleased]: https://github.com/Divaaaan/tenebra/compare/v0.4.5...HEAD +[Unreleased]: https://github.com/Divaaaan/tenebra/compare/v0.4.6...HEAD +[0.4.6]: https://github.com/Divaaaan/tenebra/compare/v0.4.5...v0.4.6 [0.4.5]: https://github.com/Divaaaan/tenebra/compare/v0.4.4...v0.4.5 [0.4.4]: https://github.com/Divaaaan/tenebra/compare/v0.4.3...v0.4.4 [0.4.3]: https://github.com/Divaaaan/tenebra/compare/v0.4.2...v0.4.3 diff --git a/README.md b/README.md index 2f22a15..73047ed 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,10 @@ [![CI](https://github.com/Divaaaan/tenebra/actions/workflows/ci.yml/badge.svg)](https://github.com/Divaaaan/tenebra/actions/workflows/ci.yml) [![License: GPL v3](https://img.shields.io/badge/license-GPLv3-ff3d00.svg)](https://www.gnu.org/licenses/gpl-3.0) [![Latest release](https://img.shields.io/github/v/release/Divaaaan/tenebra?color=ff3d00&label=release)](https://github.com/Divaaaan/tenebra/releases/latest) -[![Platform](https://img.shields.io/badge/platform-Windows_%7C_macOS-0e0e0e.svg)](#project-status) +[![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 ships but is for advanced users (see below). A shared Go core is meant to extend to Linux, Android and iOS. +Desktop first — Windows is user-ready; macOS and Linux ship but are for advanced users (see below). A shared Go core is meant to extend to Android and iOS. A total eclipse: intercepted noise enters the dark, one clean signal leaves it. In tenebris lux. @@ -95,7 +95,8 @@ get one: | 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 | | 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 | -| Linux / Android / iOS | Planned — the core is shared and platform-agnostic | +| 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 / iOS | Planned — the core is shared and platform-agnostic | | Release pipeline | Tag-triggered `release` workflow builds the Windows and macOS bundles, minisign-signs the in-app updater artifacts, and publishes a GitHub release | | Code-signing | Not set up — the Windows installer is Authenticode-unsigned (SmartScreen warns) and the macOS build is unsigned/un-notarized (Gatekeeper needs a manual "Open Anyway") | @@ -124,6 +125,44 @@ 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. +### Linux note — the tunnel needs a root service + +Linux is the same shape as macOS: only a privileged process may open +`/dev/net/tun` and install routes, so the app talks to a small root **systemd +service** that owns the tunnel and serves the control protocol on +`/run/tenebra.sock`. The app alone cannot connect. Two ways to set it up: + +- **Arch Linux — build the package.** [`packaging/arch/PKGBUILD`](packaging/arch/PKGBUILD) + builds the core, the desktop app and the unit from source and installs them + with `pacman`: + + ``` + cd packaging/arch && makepkg -si + sudo systemctl enable --now tenebra.service + ``` + + Updates come from `pacman`, not the in-app updater — it can only replace an + AppImage, never files a package manager owns. + +- **Any other distribution — the install script.** Fetch the bundled resources, + then install the daemon from your checkout: + + ``` + bash scripts/fetch-resources.sh + sudo bash scripts/linux/install-daemon.sh --dev + ``` + + It is safe to re-run to upgrade, rolls back if an upgrade fails, and + [`scripts/linux/uninstall-daemon.sh`](scripts/linux/uninstall-daemon.sh) + removes it. The GUI is a separate `.deb`/AppImage build. + +Two limits worth knowing before you install: **system-proxy mode does nothing on +Linux** (it needs per-desktop settings a root daemon cannot reach, so it stays +quietly disarmed — tun mode, the default, is unaffected), and the bundled +sing-box binaries are **glibc-linked**, so musl distributions need their own. +Full detail, including the systemd sandbox and what is deliberately left out of +it, is in [docs/porting/linux.md](docs/porting/linux.md). + If you want to help close the gap, the macOS `SMAppService` path and the non-desktop adapters are the highest-leverage places — see [CONTRIBUTING.md](CONTRIBUTING.md). @@ -145,8 +184,14 @@ tenebra/ ├── cmd/ │ └── tenebra-core/ The sidecar entry point (talks the protocol on stdin/stdout). ├── ui-desktop/ Tauri 2 app: Rust shell (src-tauri) + React/TS front end (src). +├── deploy/ The privileged daemon's service definitions per platform. +├── packaging/ +│ └── arch/ PKGBUILD building the whole thing for Arch Linux. ├── scripts/ -│ └── fetch-resources.ps1 Download pinned sing-box + wintun for bundling. +│ ├── fetch-resources.ps1 Download pinned sing-box + wintun for bundling (Windows). +│ ├── fetch-resources.sh The same for macOS and Linux. +│ ├── macos/ Install/remove the root LaunchDaemon. +│ └── linux/ Install/remove the root systemd service. └── docs/ Architecture, control protocol, and the dev guide. ``` @@ -177,6 +222,24 @@ npm install npm run tauri build ``` +Desktop app (Linux): + +``` +# fetch the sing-box binary and the rule-sets into src-tauri/resources +bash scripts/fetch-resources.sh + +# build the core sidecar where Tauri bundles it +go build -o ui-desktop/src-tauri/binaries/tenebra-core-x86_64-unknown-linux-gnu ./cmd/tenebra-core + +# build the .deb and AppImage +cd ui-desktop +npm install +npm run tauri build +``` + +On Arch, `cd packaging/arch && makepkg -si` does all of the above and installs +the result — see the [Linux note](#linux-note--the-tunnel-needs-a-root-service). + ## Documentation - [docs/](docs/) — documentation index. diff --git a/adapters/linux/runner.go b/adapters/linux/runner.go new file mode 100644 index 0000000..b499d93 --- /dev/null +++ b/adapters/linux/runner.go @@ -0,0 +1,662 @@ +//go:build linux + +// Package linux runs and supervises the sing-box process that backs the tunnel +// on Linux. It satisfies control.Runner: the control daemon hands it a config, +// it spawns sing-box, exposes traffic counters from the clash API, and reports +// the process exit. +// +// This is the Linux analog of adapters/windows and adapters/macos and is a +// near-copy of the latter on purpose: the clash API client, the log ring buffer, +// and the process supervisor are identical across the three desktop targets. It +// lives in its own linux-tagged package rather than reusing the windows adapter +// — which is deliberately build-tag-free and does compile and run here — because +// every diagnostic that adapter emits is prefixed "windows:", and a Linux user +// reading "windows: start sing-box: permission denied" in the app's log is being +// told something false about their own machine. Lifting the common half of the +// three into a shared package is a follow-up worth doing on its own; duplicating +// platform code behind an honest comment is a pattern the codebase already uses +// (see adapters/macos and control.sanitizeTag). +// +// The real differences from Windows are the device and the privilege. There is +// no wintun to place beside the binary: sing-box opens /dev/net/tun, which the +// kernel only hands out to a process with CAP_NET_ADMIN, and auto_route (plus +// strict_route, the kill switch) then installs routing rules and a firewall +// policy that need the same capability. So this adapter has no dll handling and +// instead records, before each launch, why an unprivileged run or a host without +// the tun module will not be able to bring the tunnel up. +package linux + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "sync" + "time" +) + +// defaultClashPort matches singbox.TunOptions' default external controller port, +// where the config tells sing-box to expose the clash API. +const defaultClashPort = 9090 + +// singboxEnv overrides binary resolution when set, so an operator (or a test, or +// the packaged service unit) can point at a specific sing-box build. +const singboxEnv = "TENEBRA_SINGBOX" + +// logRingSize bounds the in-memory tail of sing-box output kept for diagnostics. +const logRingSize = 200 + +// statsTimeout keeps a clash API poll from blocking the traffic loop if the API +// is slow or not yet listening. +const statsTimeout = 2 * time.Second + +// probeURL is the target the clash API delay test fetches through the outbound. +// A 204 means real traffic reached the internet through that proxy; it is the +// canonical reachability check sing-box's own clash API exposes. HTTPS is used +// over plaintext http so the reachability check isn't a cleartext beacon on the +// wire: the clash delay test runs a full request through the outbound and times +// the response either way, so the TLS handshake changes nothing but the measured +// path, and gstatic serves the same 204 over https. +const probeURL = "https://www.gstatic.com/generate_204" + +// probeTimeoutMs is the server-side timeout (in milliseconds) handed to the +// clash API delay test, matching the connect loop's per-attempt budget. The +// HTTP request itself is given a little more headroom so the local call doesn't +// time out before the API has a chance to answer with its own timeout verdict. +const probeTimeoutMs = 5000 + +// tunDevice is the character device sing-box opens to create the tun interface. +// It is provided by the tun kernel module; on a host where the module was never +// loaded, or in a container started without access to it, the node is simply +// absent and no amount of privilege conjures it. +const tunDevice = "/dev/net/tun" + +// blocked is returned by Done before the first Start. It has no sender and is +// never closed, so a receive on it blocks forever — exactly the "Done must block +// before any Start" contract, without allocating a fresh channel per caller. +var blocked = make(chan error) + +// Runner spawns and supervises one sing-box process at a time. The zero value is +// not usable; build it with New. It is safe for concurrent use: Stats and Done +// may be called while Start or Stop runs. +type Runner struct { + // binOverride, when non-empty, is the sing-box path to run instead of the + // one resolved next to the executable. New seeds it from TENEBRA_SINGBOX. + binOverride string + // ClashPort is the clash API port Stats queries; 0 means defaultClashPort. + ClashPort int + + mu sync.Mutex + cmd *exec.Cmd + cancel context.CancelFunc + done chan error + cfgPath string // temp config file for the running process, removed on stop + ring *ringBuffer + // clashSecret is the clash API token baked into the running process's config. + // Stats and Probe send it as a bearer so the authenticated external controller + // answers them; set on each Start, read under mu. + clashSecret string +} + +// New builds a Runner with defaults: the sing-box binary is resolved from +// TENEBRA_SINGBOX or located next to the current executable, and the clash API +// is polled on port 9090. Both can be overridden afterwards via the exported +// field or by setting the environment before New. +func New() *Runner { + return &Runner{ + binOverride: os.Getenv(singboxEnv), + ClashPort: defaultClashPort, + ring: newRingBuffer(logRingSize), + } +} + +// Start launches sing-box with configJSON. It returns once the process is +// 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 { + bin, err := r.resolveSingbox() + if err != nil { + return err + } + + // Which sing-box is about to run is worth a log line here and nowhere else: + // it can come from the override, from beside the core, from a distribution's + // private helper directory, or from PATH (see SingboxCandidates), and "which + // one did it pick" is the first question any report about a version-specific + // failure raises. + r.ring.add("linux: running sing-box from " + bin) + + // The two ways a tun-mode connect fails before sing-box has said anything + // useful are recorded here rather than turned into errors. Neither is fatal + // to a Start: system-proxy mode needs no tun device and no capability at all, + // so refusing to spawn would break the one mode that still works on a + // locked-down host. When the config does ask for a tun, sing-box fails on its + // own and that failure surfaces honestly through Done — these lines are what + // let a reader of the log see why. + for _, hint := range []string{elevationHint(), tunDeviceHint()} { + if hint != "" { + r.ring.add(hint) + } + } + + cfgPath, err := writeConfig(configJSON) + if err != nil { + return err + } + + // The clash API secret travels inside the config we were handed; read it back + // so Stats/Probe authenticate to the same controller this process exposes. + secret := clashSecretFromConfig(configJSON) + + r.mu.Lock() + defer r.mu.Unlock() + if r.cmd != nil { + os.Remove(cfgPath) + return errors.New("linux: sing-box already running") + } + + runCtx, cancel := context.WithCancel(ctx) + cmd := exec.CommandContext(runCtx, bin, "run", "-c", cfgPath) + + stdout, err := cmd.StdoutPipe() + if err != nil { + cancel() + os.Remove(cfgPath) + return fmt.Errorf("linux: stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + cancel() + os.Remove(cfgPath) + return fmt.Errorf("linux: stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + cancel() + os.Remove(cfgPath) + return fmt.Errorf("linux: start sing-box: %w", err) + } + + done := make(chan error, 1) + r.cmd = cmd + r.cancel = cancel + r.done = done + r.cfgPath = cfgPath + r.clashSecret = secret + + // Drain both streams into the ring buffer; the goroutines end when the pipes + // close on process exit. + go r.scan(stdout) + go r.scan(stderr) + + // One watcher owns Wait. It publishes the exit on done, closes it, and clears + // the running state so the Runner can be started again. + go func() { + werr := cmd.Wait() + cancel() + os.Remove(cfgPath) + + r.mu.Lock() + if r.cmd == cmd { // still the current process, not superseded + r.cmd = nil + r.cancel = nil + r.cfgPath = "" + } + r.mu.Unlock() + + done <- werr + close(done) + }() + + return nil +} + +// Stop terminates the running process and waits for it to exit. It is +// idempotent: with nothing running it returns nil. +func (r *Runner) Stop() error { + r.mu.Lock() + cancel := r.cancel + done := r.done + running := r.cmd != nil + r.mu.Unlock() + + if !running { + return nil + } + if cancel != nil { + cancel() // signals exec to kill the process group + } + // Wait for the watcher to observe the exit so Stop doesn't race ahead of + // cleanup. done is buffered and always closed by the watcher. + if done != nil { + <-done + } + return nil +} + +// Done reports the next process exit. Before any Start it blocks forever; after +// a Start it returns that run's channel, which delivers the exit error once and +// is then closed. +func (r *Runner) Done() <-chan error { + r.mu.Lock() + defer r.mu.Unlock() + if r.done == nil { + return blocked + } + return r.done +} + +// Stats fetches cumulative byte counters from the clash API. The API only +// listens once sing-box has started, so an error here is expected during +// startup and treated as non-fatal by the caller. +func (r *Runner) Stats() (up, down int64, err error) { + port := r.ClashPort + if port == 0 { + port = defaultClashPort + } + url := fmt.Sprintf("http://127.0.0.1:%d/connections", port) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return 0, 0, fmt.Errorf("linux: clash stats: %w", err) + } + setClashAuth(req, r.clashAuth()) + + client := &http.Client{Timeout: statsTimeout} + resp, err := client.Do(req) + if err != nil { + return 0, 0, fmt.Errorf("linux: clash stats: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return 0, 0, fmt.Errorf("linux: clash stats: status %s", resp.Status) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return 0, 0, fmt.Errorf("linux: clash stats: read: %w", err) + } + return parseConnections(body) +} + +// Probe asks the clash API to run a delay test through the named outbound: it +// fetches a known 204 endpoint via that proxy and reports the round-trip in +// milliseconds. A successful test is honest proof that traffic actually flows +// through tag — unlike "the process stayed up", it fails when the protocol is +// blocked, the handshake never completes, or the upstream is dead. A non-200 +// from the API (which includes its own timeout, surfaced as a 408/504) or any +// transport error means the outbound is not usable. +// +// ctx bounds the whole call so the connect loop can abandon a probe when the +// connection is superseded; the clash API is also told its own timeout so it +// stops testing rather than holding the request open. +func (r *Runner) Probe(ctx context.Context, tag string) (delayMs int, err error) { + port := r.ClashPort + if port == 0 { + port = defaultClashPort + } + endpoint := delayURL(port, tag) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return 0, fmt.Errorf("linux: clash delay request: %w", err) + } + setClashAuth(req, r.clashAuth()) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, fmt.Errorf("linux: clash delay: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + if resp.StatusCode != http.StatusOK { + // The API returns a message body (e.g. {"message":"An error occurred..."}) + // on a failed test; include a trimmed form so logs show why it failed. + return 0, fmt.Errorf("linux: clash delay: status %s: %s", resp.Status, trimBody(body)) + } + return parseDelay(body) +} + +// delayURL builds the clash API delay-test endpoint for the outbound named tag. +// The tag is path-escaped and the probe target query-escaped so a name or URL +// with reserved characters can't corrupt the request. It is split out from Probe +// so the URL shape can be asserted without a live API. +func delayURL(port int, tag string) string { + return fmt.Sprintf("http://127.0.0.1:%d/proxies/%s/delay?timeout=%d&url=%s", + port, url.PathEscape(tag), probeTimeoutMs, url.QueryEscape(probeURL)) +} + +// parseDelay reads the {"delay":N} body the clash API returns from a successful +// delay test and yields the round-trip in milliseconds. It is split out from +// Probe so the parse can be tested without spawning sing-box or making an HTTP +// call. +func parseDelay(body []byte) (delayMs int, err error) { + var out struct { + Delay int `json:"delay"` + } + if err := json.Unmarshal(body, &out); err != nil { + return 0, fmt.Errorf("linux: parse clash delay: %w", err) + } + return out.Delay, nil +} + +// trimBody renders a short, single-line form of an API error body for logs. +func trimBody(b []byte) string { + const max = 200 + s := string(b) + if len(s) > max { + s = s[:max] + } + return s +} + +// clashAuth returns the clash API bearer secret for the running process, or "" +// when the config carried none (an unauthenticated API). +func (r *Runner) clashAuth() string { + r.mu.Lock() + defer r.mu.Unlock() + return r.clashSecret +} + +// setClashAuth attaches the clash API secret as a bearer token when one is set. +// sing-box's external controller answers 401 to any request missing it once a +// secret is configured, which is what keeps other local processes off the +// tunnel's control surface. +func setClashAuth(req *http.Request, secret string) { + if secret != "" { + req.Header.Set("Authorization", "Bearer "+secret) + } +} + +// clashSecretFromConfig extracts the clash API secret from a sing-box config so +// Stats and Probe can authenticate to the external controller the running +// process exposes. It returns "" for a config without one (or an unparseable +// config), in which case the callers send no Authorization header. +func clashSecretFromConfig(configJSON []byte) string { + var cfg struct { + Experimental struct { + ClashAPI struct { + Secret string `json:"secret"` + } `json:"clash_api"` + } `json:"experimental"` + } + if err := json.Unmarshal(configJSON, &cfg); err != nil { + return "" + } + return cfg.Experimental.ClashAPI.Secret +} + +// Logs returns a copy of the most recent sing-box output lines, newest last, for +// diagnostics. +func (r *Runner) Logs() []string { + r.mu.Lock() + ring := r.ring + r.mu.Unlock() + if ring == nil { + return nil + } + return ring.snapshot() +} + +// scan copies a process stream line by line into the ring buffer. +func (r *Runner) scan(rc io.ReadCloser) { + defer rc.Close() + sc := bufio.NewScanner(rc) + sc.Buffer(make([]byte, 0, 64*1024), 1<<20) + for sc.Scan() { + r.ring.add(sc.Text()) + } +} + +// resolveSingbox returns the sing-box binary path: the override (env or field) +// if set, otherwise the first hit of the platform search order (FindSingbox). +// When nothing is found it still returns the neighbour path rather than an +// error, so the spawn fails naming a concrete location instead of an empty one — +// the caller reports that at connect time, which is where the user can act on it. +func (r *Runner) resolveSingbox() (string, error) { + if r.binOverride != "" { + return r.binOverride, nil + } + exe, err := os.Executable() + if err != nil { + return "", fmt.Errorf("linux: locate executable: %w", err) + } + exeDir := filepath.Dir(exe) + if bin := FindSingbox(exeDir); bin != "" { + return bin, nil + } + return filepath.Join(exeDir, singboxBinaryName()), nil +} + +// singboxBinaryName is the expected sing-box filename on Linux. The release +// ships the binary without an extension, unlike the Windows sing-box.exe. +func singboxBinaryName() string { + return "sing-box" +} + +// InstallDirs lists, in probe order, the directories a Tenebra install may keep +// its private files in on Linux — the sing-box binary and the .srs rule-sets +// alike. It is exported because two callers must agree on it: this adapter, +// which resolves the binary, and the core's startup, which pins TENEBRA_SINGBOX +// and locates the rule-sets. Two independently maintained lists would drift, and +// a rule-set directory that disagrees with the binary's is a slow, silent +// failure (a ten-second remote download at every connect). +// +// Unlike Windows and macOS, "next to the executable" is not the answer here. +// Those two ship one self-contained directory — an installation folder, an .app +// bundle — while a Linux distribution package spreads the same payload across +// the filesystem hierarchy: the launcher lands in /bin and its private +// helpers in a per-package directory whose name is a matter of distribution +// policy. So the order is: +// +// - exeDir, which covers a dev checkout, an unpacked AppImage, and a +// self-contained /opt-style install where everything sits together; +// - /lib/tenebra, /libexec/tenebra and /share/tenebra +// derived from exeDir's parent, which covers a package installed under any +// prefix (/usr, /usr/local, /opt/tenebra) without hard-coding one. lib and +// libexec are the two conventions distributions split on for private +// helpers; share is where architecture-independent data like the .srs +// belongs, and is searched for the binary too rather than refusing to find +// one a packager put there; +// - the same three under /usr as an absolute backstop, for a core executable +// that is not under /bin at all — a wrapper's temp copy, or a +// locally built binary run on a machine that has the package installed. +// +// The per-package directory is probed under both spellings, each with and +// without a resources/ subdirectory, because the two Linux packages we ship do +// not agree on it. The Arch PKGBUILD lays the payload out by hand and uses the +// lowercase package name (/usr/lib/tenebra/sing-box), while the .deb is written +// by Tauri's bundler, which derives the directory from the product name and +// nests bundled resources one level deeper (/usr/lib/Tenebra/resources/ +// sing-box). Probing only the lowercase form was a real failure and not a +// cosmetic one: the GUI resolves resources through Tauri and would still find +// them, so a .deb install would look fine until the daemon — which has no such +// help — silently failed to find sing-box at connect time. +// +// Duplicates are dropped, so the list reads as an honest search path in a log. +func InstallDirs(exeDir string) []string { + prefix := filepath.Dir(exeDir) + dirs := []string{exeDir, filepath.Join(exeDir, "resources")} + for _, base := range []string{prefix, "/usr"} { + for _, private := range []string{"lib", "libexec", "share"} { + for _, name := range []string{"tenebra", "Tenebra"} { + dir := filepath.Join(base, private, name) + dirs = append(dirs, dir, filepath.Join(dir, "resources")) + } + } + } + return dedupe(dirs) +} + +// SingboxCandidates lists, in probe order, every path sing-box may live at on +// Linux: one per install directory, then whatever `sing-box` resolves to on +// PATH. The PATH entry last is what makes the distribution-dependency layout +// work — on a system where sing-box is a packaged dependency in /usr/bin rather +// than a binary Tenebra ships, it is the only place it will ever be found — and +// it is last so a version Tenebra installed alongside itself always wins over +// whatever else happens to be on PATH. +// +// An explicit TENEBRA_SINGBOX is not part of this list: it is an override that +// short-circuits the search entirely, before any candidate is probed. +func SingboxCandidates(exeDir string) []string { + dirs := InstallDirs(exeDir) + out := make([]string, 0, len(dirs)+1) + for _, dir := range dirs { + out = append(out, filepath.Join(dir, singboxBinaryName())) + } + if p, err := exec.LookPath(singboxBinaryName()); err == nil { + out = append(out, p) + } + return dedupe(out) +} + +// FindSingbox returns the first candidate that exists, or "" when none does. +// The caller decides what a miss means: the runner falls back to the neighbour +// path so its spawn error names one, and the daemon's startup leaves +// TENEBRA_SINGBOX unset so the runner gets to make that decision at connect time. +func FindSingbox(exeDir string) string { + for _, p := range SingboxCandidates(exeDir) { + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + +// dedupe removes repeated paths while preserving order. The install prefix +// derived from exeDir is very often /usr itself, which would otherwise make the +// absolute backstop a verbatim repeat of the entries just before it. +func dedupe(paths []string) []string { + seen := make(map[string]bool, len(paths)) + out := paths[:0:0] + for _, p := range paths { + if seen[p] { + continue + } + seen[p] = true + out = append(out, p) + } + return out +} + +// elevationHint returns a short diagnostic note when the current process lacks +// the privilege the tun path needs, or "" when it is already root. It never +// blocks and never fakes a launch — it only explains, in the logs, why a +// non-elevated run will not be able to open the tunnel. +func elevationHint() string { + return elevationHintFor(os.Geteuid()) +} + +// elevationHintFor is the pure core of elevationHint with the effective UID +// passed in, so the mapping can be tested without a privileged process. +// +// The euid test is a deliberate simplification: what the tun path actually needs +// is CAP_NET_ADMIN, which a file capability or an ambient set can grant to a +// non-root process. Reading the capability set would need a syscall for a +// message, and a false hint costs nothing — the line is advisory, sing-box still +// runs and still reports its own verdict — while missing the hint for the +// overwhelmingly common "user ran it unprivileged" case would cost the user the +// explanation. Root is the shipping arrangement: the daemon that serves the +// control socket runs as root and drives this runner. +func elevationHintFor(euid int) string { + if euid == 0 { + return "" + } + return "linux: sing-box needs CAP_NET_ADMIN (in practice, root) to open " + tunDevice + + " and install auto_route's routing rules; start the tenebra.service systemd unit and let the GUI attach to its control socket" +} + +// tunDeviceHint returns a diagnostic note when the tun device node is missing, +// or "" when it is present. Unlike the privilege hint this is not about the +// current process at all: /dev/net/tun is created by the tun kernel module, so +// its absence means the module is not loaded or the container was started +// without access to it, and no tun-mode connect can succeed until that is fixed. +func tunDeviceHint() string { + return tunDeviceHintFor(tunDevice) +} + +// tunDeviceHintFor is the injectable core of tunDeviceHint, so both outcomes can +// be tested without depending on how the host or CI container is configured. +func tunDeviceHintFor(path string) string { + if _, err := os.Stat(path); err == nil { + return "" + } + return "linux: " + path + " is missing; load the tun kernel module (modprobe tun) or grant the container access to it before connecting in tun mode" +} + +// writeConfig writes config JSON to a temp file and returns its path. The caller +// (the watcher) removes it when the process exits. +func writeConfig(configJSON []byte) (string, error) { + f, err := os.CreateTemp("", "tenebra-singbox-*.json") + if err != nil { + return "", fmt.Errorf("linux: create config file: %w", err) + } + if _, err := f.Write(configJSON); err != nil { + f.Close() + os.Remove(f.Name()) + return "", fmt.Errorf("linux: write config file: %w", err) + } + if err := f.Close(); err != nil { + os.Remove(f.Name()) + return "", fmt.Errorf("linux: close config file: %w", err) + } + return f.Name(), nil +} + +// connections is the subset of the clash API /connections payload we read. +type connections struct { + DownloadTotal int64 `json:"downloadTotal"` + UploadTotal int64 `json:"uploadTotal"` +} + +// parseConnections extracts cumulative upload/download totals from a clash API +// /connections body. +func parseConnections(body []byte) (up, down int64, err error) { + var c connections + if err := json.Unmarshal(body, &c); err != nil { + return 0, 0, fmt.Errorf("linux: parse clash stats: %w", err) + } + return c.UploadTotal, c.DownloadTotal, nil +} + +// ringBuffer is a fixed-capacity FIFO of recent log lines, safe for concurrent +// writes from the two stream scanners and reads from Logs. +type ringBuffer struct { + mu sync.Mutex + lines []string + cap int +} + +// newRingBuffer returns an empty ring that retains the most recent capacity +// lines. +func newRingBuffer(capacity int) *ringBuffer { + return &ringBuffer{cap: capacity} +} + +// add appends a line, dropping the oldest once the buffer is at capacity. +func (b *ringBuffer) add(line string) { + b.mu.Lock() + defer b.mu.Unlock() + if len(b.lines) == b.cap { + copy(b.lines, b.lines[1:]) + b.lines[len(b.lines)-1] = line + return + } + b.lines = append(b.lines, line) +} + +// snapshot returns a copy of the buffered lines, oldest first, so callers can +// read them without holding the lock or aliasing the backing array. +func (b *ringBuffer) snapshot() []string { + b.mu.Lock() + defer b.mu.Unlock() + out := make([]string, len(b.lines)) + copy(out, b.lines) + return out +} diff --git a/adapters/linux/runner_clash_test.go b/adapters/linux/runner_clash_test.go new file mode 100644 index 0000000..4a0565e --- /dev/null +++ b/adapters/linux/runner_clash_test.go @@ -0,0 +1,131 @@ +//go:build linux + +package linux + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" +) + +// clashPortFromURL parses the port out of an httptest server URL so a Runner can +// be pointed at it. +func clashPortFromURL(t *testing.T, raw string) int { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse test server url %q: %v", raw, err) + } + port, err := strconv.Atoi(u.Port()) + if err != nil { + t.Fatalf("test server url %q has no numeric port: %v", raw, err) + } + return port +} + +func TestClashSecretFromConfig(t *testing.T) { + tests := []struct { + name string + cfg string + want string + }{ + { + name: "present", + cfg: `{"experimental":{"clash_api":{"external_controller":"127.0.0.1:9090","secret":"abc123"}}}`, + want: "abc123", + }, + { + name: "absent", + cfg: `{"experimental":{"clash_api":{"external_controller":"127.0.0.1:9090"}}}`, + want: "", + }, + {name: "no clash block", cfg: `{"experimental":{}}`, want: ""}, + {name: "empty object", cfg: `{}`, want: ""}, + {name: "malformed", cfg: `{not json`, want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := clashSecretFromConfig([]byte(tt.cfg)); got != tt.want { + t.Errorf("clashSecretFromConfig(%s) = %q, want %q", tt.cfg, got, tt.want) + } + }) + } +} + +// TestStatsSendsClashSecret: when a secret is set, Stats must present it as a +// bearer token so the authenticated external controller answers. +func TestStatsSendsClashSecret(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + gotAuth = req.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"downloadTotal":51200,"uploadTotal":10240}`)) + })) + defer srv.Close() + + r := New() + r.ClashPort = clashPortFromURL(t, srv.URL) + r.clashSecret = "s3cr3t" + + up, down, err := r.Stats() + if err != nil { + t.Fatalf("Stats: %v", err) + } + if up != 10240 || down != 51200 { + t.Errorf("up,down = %d,%d, want 10240,51200", up, down) + } + if gotAuth != "Bearer s3cr3t" { + t.Errorf("Authorization = %q, want %q", gotAuth, "Bearer s3cr3t") + } +} + +// TestProbeSendsClashSecret mirrors TestStatsSendsClashSecret for the delay +// probe: it too must carry the bearer token. +func TestProbeSendsClashSecret(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + gotAuth = req.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"delay":42}`)) + })) + defer srv.Close() + + r := New() + r.ClashPort = clashPortFromURL(t, srv.URL) + r.clashSecret = "tok" + + delay, err := r.Probe(context.Background(), "proxy") + if err != nil { + t.Fatalf("Probe: %v", err) + } + if delay != 42 { + t.Errorf("delay = %d, want 42", delay) + } + if gotAuth != "Bearer tok" { + t.Errorf("Authorization = %q, want %q", gotAuth, "Bearer tok") + } +} + +// TestStatsOmitsAuthWhenNoSecret guards that a secretless config (the token is +// unguessable-random in production, but a test or older path may omit it) sends +// no Authorization header rather than an empty bearer. +func TestStatsOmitsAuthWhenNoSecret(t *testing.T) { + var hadAuth bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + _, hadAuth = req.Header["Authorization"] + _, _ = w.Write([]byte(`{"downloadTotal":0,"uploadTotal":0}`)) + })) + defer srv.Close() + + r := New() + r.ClashPort = clashPortFromURL(t, srv.URL) + // clashSecret deliberately left empty. + + if _, _, err := r.Stats(); err != nil { + t.Fatalf("Stats: %v", err) + } + if hadAuth { + t.Error("no Authorization header should be sent when the config carries no secret") + } +} diff --git a/adapters/linux/runner_probe_test.go b/adapters/linux/runner_probe_test.go new file mode 100644 index 0000000..581b33a --- /dev/null +++ b/adapters/linux/runner_probe_test.go @@ -0,0 +1,158 @@ +//go:build linux + +package linux + +import ( + "context" + "net/url" + "strconv" + "strings" + "testing" +) + +func TestDelayURL(t *testing.T) { + got := delayURL(9090, "proxy") + + u, err := url.Parse(got) + if err != nil { + t.Fatalf("delayURL produced unparseable URL %q: %v", got, err) + } + if u.Scheme != "http" || u.Host != "127.0.0.1:9090" { + t.Errorf("scheme/host = %q/%q, want http/127.0.0.1:9090", u.Scheme, u.Host) + } + if u.Path != "/proxies/proxy/delay" { + t.Errorf("path = %q, want /proxies/proxy/delay", u.Path) + } + q := u.Query() + if q.Get("timeout") != strconv.Itoa(probeTimeoutMs) { + t.Errorf("timeout = %q, want %d", q.Get("timeout"), probeTimeoutMs) + } + if q.Get("url") != probeURL { + t.Errorf("url param = %q, want %q", q.Get("url"), probeURL) + } +} + +func TestDelayURLPort(t *testing.T) { + got := delayURL(7777, "proxy") + if !strings.Contains(got, "127.0.0.1:7777") { + t.Errorf("delayURL(7777, ...) = %q, want it to target port 7777", got) + } +} + +// TestDelayURLEscapesTag guards against a selector name with reserved characters +// breaking out of the path segment or smuggling extra query params. +func TestDelayURLEscapesTag(t *testing.T) { + got := delayURL(9090, "weird name/with?reserved&chars") + + u, err := url.Parse(got) + if err != nil { + t.Fatalf("delayURL with reserved tag produced unparseable URL %q: %v", got, err) + } + // The escaped tag must round-trip back to the original through the path. + wantPath := "/proxies/weird name/with?reserved&chars/delay" + if u.Path != wantPath { + t.Errorf("decoded path = %q, want %q", u.Path, wantPath) + } + // The tag's '&' must not have injected an extra query key. + if u.Query().Get("reserved") != "" { + t.Error("reserved character in tag leaked into the query string") + } +} + +func TestParseDelay(t *testing.T) { + tests := []struct { + name string + body string + want int + wantErr bool + }{ + {name: "typical", body: `{"delay":123}`, want: 123}, + {name: "zero", body: `{"delay":0}`, want: 0}, + {name: "extra fields ignored", body: `{"delay":42,"mean_delay":40}`, want: 42}, + {name: "missing field defaults zero", body: `{"mean_delay":40}`, want: 0}, + {name: "malformed", body: `{not json`, wantErr: true}, + {name: "empty", body: ``, wantErr: true}, + {name: "wrong type", body: `{"delay":"slow"}`, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseDelay([]byte(tt.body)) + if tt.wantErr { + if err == nil { + t.Fatalf("parseDelay(%q): want error, got nil", tt.body) + } + return + } + if err != nil { + t.Fatalf("parseDelay(%q): %v", tt.body, err) + } + if got != tt.want { + t.Errorf("parseDelay(%q) = %d, want %d", tt.body, got, tt.want) + } + }) + } +} + +func TestTrimBody(t *testing.T) { + if got := trimBody([]byte("short")); got != "short" { + t.Errorf("trimBody(short) = %q, want short", got) + } + long := strings.Repeat("x", 500) + got := trimBody([]byte(long)) + if len(got) != 200 { + t.Errorf("trimBody truncated to %d bytes, want 200", len(got)) + } +} + +func TestProbeAgainstDeadPort(t *testing.T) { + // Nothing listens on port 1; Probe must return a transport error, not panic. + r := New() + r.ClashPort = 1 + if _, err := r.Probe(context.Background(), "proxy"); err == nil { + t.Error("Probe against a dead port should error") + } +} + +func TestProbeContextCancelled(t *testing.T) { + // A cancelled context fails the request before it leaves; Probe surfaces it. + r := New() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := r.Probe(ctx, "proxy"); err == nil { + t.Error("Probe with a cancelled context should error") + } +} + +// TestErrorsAreNotAttributedToAnotherPlatform: every diagnostic this adapter +// emits is read by a Linux user. The generic supervisor it was copied from +// prefixes its messages "windows:", which is exactly the confusion this package +// exists to avoid, so the prefix is asserted rather than left to a reviewer. +func TestErrorsAreNotAttributedToAnotherPlatform(t *testing.T) { + r := New() + r.ClashPort = 1 + + _, _, statsErr := r.Stats() + if statsErr == nil { + t.Fatal("Stats against a dead port should error") + } + _, probeErr := r.Probe(context.Background(), "proxy") + if probeErr == nil { + t.Fatal("Probe against a dead port should error") + } + _, parseErr := parseDelay([]byte(`{not json`)) + if parseErr == nil { + t.Fatal("parseDelay on garbage should error") + } + + for _, err := range []error{statsErr, probeErr, parseErr} { + msg := err.Error() + if !strings.HasPrefix(msg, "linux: ") { + t.Errorf("error %q is not prefixed for this platform", msg) + } + for _, foreign := range []string{"windows", "macos", "wintun", "utun"} { + if strings.Contains(strings.ToLower(msg), foreign) { + t.Errorf("error %q names %q, which means nothing on Linux", msg, foreign) + } + } + } +} diff --git a/adapters/linux/runner_test.go b/adapters/linux/runner_test.go new file mode 100644 index 0000000..9b0617f --- /dev/null +++ b/adapters/linux/runner_test.go @@ -0,0 +1,456 @@ +//go:build linux + +package linux + +import ( + "bytes" + "os" + "path/filepath" + "slices" + "testing" + "time" + + "github.com/Divaaaan/tenebra/core/control" +) + +// Runner must satisfy the control.Runner contract; this fails to compile if the +// interface drifts. +var _ control.Runner = (*Runner)(nil) + +// emptyPATH points PATH at an empty directory for the duration of a test, so the +// last step of the search order is decided by the test rather than by whatever +// the machine running it happens to have installed. +func emptyPATH(t *testing.T) { + t.Helper() + t.Setenv("PATH", t.TempDir()) +} + +func TestResolveSingboxEnvOverride(t *testing.T) { + want := filepath.Join(t.TempDir(), "custom-sing-box") + t.Setenv(singboxEnv, want) + + r := New() + got, err := r.resolveSingbox() + if err != nil { + t.Fatalf("resolveSingbox: %v", err) + } + if got != want { + t.Errorf("override path = %q, want %q", got, want) + } +} + +// TestResolveSingboxOverrideSkipsSearch: the override names a path that does not +// exist. It must still be returned verbatim — an operator pointing at a specific +// build wants that build's absence reported, not a silent substitution from +// PATH. +func TestResolveSingboxOverrideSkipsSearch(t *testing.T) { + pathDir := t.TempDir() + if err := os.WriteFile(filepath.Join(pathDir, "sing-box"), []byte("fake"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", pathDir) + + want := filepath.Join(t.TempDir(), "absent-sing-box") + t.Setenv(singboxEnv, want) + + r := New() + got, err := r.resolveSingbox() + if err != nil { + t.Fatalf("resolveSingbox: %v", err) + } + if got != want { + t.Errorf("resolved path = %q, want the override %q", got, want) + } +} + +// TestResolveSingboxFallsBackToNeighbour: with no override and nothing found +// anywhere, the resolver still names the path next to the executable so the +// spawn error points somewhere concrete. +func TestResolveSingboxFallsBackToNeighbour(t *testing.T) { + t.Setenv(singboxEnv, "") + emptyPATH(t) + + r := New() + got, err := r.resolveSingbox() + if err != nil { + t.Fatalf("resolveSingbox: %v", err) + } + + exe, err := os.Executable() + if err != nil { + t.Fatalf("Executable: %v", err) + } + want := filepath.Join(filepath.Dir(exe), singboxBinaryName()) + if got != want { + t.Errorf("resolved path = %q, want %q", got, want) + } +} + +// TestInstallDirsOrder pins the search order: the executable's own directory +// first (a self-contained install, an AppImage, a dev checkout), then the +// prefix-relative private directories a distribution package uses, then the +// absolute /usr backstop for a core that is not under /bin. +func TestInstallDirsOrder(t *testing.T) { + got := InstallDirs("/opt/tenebra/bin") + want := []string{ + "/opt/tenebra/bin", + "/opt/tenebra/bin/resources", + "/opt/tenebra/lib/tenebra", + "/opt/tenebra/lib/tenebra/resources", + "/opt/tenebra/lib/Tenebra", + "/opt/tenebra/lib/Tenebra/resources", + "/opt/tenebra/libexec/tenebra", + "/opt/tenebra/libexec/tenebra/resources", + "/opt/tenebra/libexec/Tenebra", + "/opt/tenebra/libexec/Tenebra/resources", + "/opt/tenebra/share/tenebra", + "/opt/tenebra/share/tenebra/resources", + "/opt/tenebra/share/Tenebra", + "/opt/tenebra/share/Tenebra/resources", + "/usr/lib/tenebra", + "/usr/lib/tenebra/resources", + "/usr/lib/Tenebra", + "/usr/lib/Tenebra/resources", + "/usr/libexec/tenebra", + "/usr/libexec/tenebra/resources", + "/usr/libexec/Tenebra", + "/usr/libexec/Tenebra/resources", + "/usr/share/tenebra", + "/usr/share/tenebra/resources", + "/usr/share/Tenebra", + "/usr/share/Tenebra/resources", + } + if !slices.Equal(got, want) { + t.Errorf("InstallDirs = %v, want %v", got, want) + } +} + +// TestInstallDirsCoversTheDebianLayout pins the one path the .deb actually uses. +// Tauri's bundler names that directory after the product, not the package, and +// nests resources one level down; a search that only knew the hand-written Arch +// layout would leave a .deb install with a daemon that cannot find sing-box — +// while the GUI, which resolves through Tauri, kept working and hid the fault. +func TestInstallDirsCoversTheDebianLayout(t *testing.T) { + if !slices.Contains(InstallDirs("/usr/bin"), "/usr/lib/Tenebra/resources") { + t.Error("InstallDirs does not probe /usr/lib/Tenebra/resources, where the .deb puts sing-box") + } +} + +// TestInstallDirsDedupes: the overwhelmingly common install has the core in +// /usr/bin, where the prefix-relative directories and the absolute backstop are +// the same paths. They must appear once, so the search log reads as a list of +// distinct places rather than a repeat. +func TestInstallDirsDedupes(t *testing.T) { + got := InstallDirs("/usr/bin") + want := []string{ + "/usr/bin", + "/usr/bin/resources", + "/usr/lib/tenebra", + "/usr/lib/tenebra/resources", + "/usr/lib/Tenebra", + "/usr/lib/Tenebra/resources", + "/usr/libexec/tenebra", + "/usr/libexec/tenebra/resources", + "/usr/libexec/Tenebra", + "/usr/libexec/Tenebra/resources", + "/usr/share/tenebra", + "/usr/share/tenebra/resources", + "/usr/share/Tenebra", + "/usr/share/Tenebra/resources", + } + if !slices.Equal(got, want) { + t.Errorf("InstallDirs = %v, want %v", got, want) + } +} + +// TestSingboxCandidatesEndWithPATH: PATH is consulted, and last. That ordering +// is the whole contract with a distribution that ships sing-box as its own +// package — it is found — while a copy installed with Tenebra still wins. +func TestSingboxCandidatesEndWithPATH(t *testing.T) { + pathDir := t.TempDir() + onPath := filepath.Join(pathDir, "sing-box") + if err := os.WriteFile(onPath, []byte("fake"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", pathDir) + + got := SingboxCandidates("/opt/tenebra/bin") + if len(got) == 0 { + t.Fatal("SingboxCandidates returned nothing") + } + if got[0] != "/opt/tenebra/bin/sing-box" { + t.Errorf("first candidate = %q, want the neighbour binary", got[0]) + } + if last := got[len(got)-1]; last != onPath { + t.Errorf("last candidate = %q, want the PATH hit %q", last, onPath) + } + if slices.Contains(got[:len(got)-1], onPath) { + t.Errorf("the PATH hit appears before the install locations: %v", got) + } +} + +// TestSingboxCandidatesWithoutPATH: nothing on PATH simply shortens the list; it +// is not an error and must not drop the install locations. The expectation is +// derived from InstallDirs rather than spelled out, so adding a packaging layout +// to the search path stays a one-line change there — TestInstallDirsOrder is +// where the literal list is pinned. +func TestSingboxCandidatesWithoutPATH(t *testing.T) { + emptyPATH(t) + + dirs := InstallDirs("/opt/tenebra/bin") + want := make([]string, 0, len(dirs)) + for _, dir := range dirs { + want = append(want, filepath.Join(dir, "sing-box")) + } + got := SingboxCandidates("/opt/tenebra/bin") + if !slices.Equal(got, want) { + t.Errorf("SingboxCandidates = %v, want %v", got, want) + } +} + +// TestFindSingboxPrefersEarlierCandidate: with a binary both next to the core +// and in a shared helper directory, the neighbour — the one installed with this +// build — is the answer. +func TestFindSingboxPrefersEarlierCandidate(t *testing.T) { + emptyPATH(t) + prefix := t.TempDir() + binDir := filepath.Join(prefix, "bin") + helperDir := filepath.Join(prefix, "lib", "tenebra") + for _, d := range []string{binDir, helperDir} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + want := filepath.Join(binDir, "sing-box") + for _, p := range []string{want, filepath.Join(helperDir, "sing-box")} { + if err := os.WriteFile(p, []byte("fake"), 0o644); err != nil { + t.Fatal(err) + } + } + + if got := FindSingbox(binDir); got != want { + t.Errorf("FindSingbox = %q, want %q", got, want) + } +} + +func TestFindSingboxMissing(t *testing.T) { + emptyPATH(t) + // The absolute backstop in the search order is not injectable — that is the + // point of it — so a machine with Tenebra genuinely installed system-wide + // answers this correctly and has no miss left to observe. + for _, dir := range []string{"/usr/lib/tenebra", "/usr/libexec/tenebra", "/usr/share/tenebra"} { + p := filepath.Join(dir, "sing-box") + if _, err := os.Stat(p); err == nil { + t.Skipf("a system-wide sing-box at %s leaves no miss to observe", p) + } + } + if got := FindSingbox(t.TempDir()); got != "" { + t.Errorf("FindSingbox with nothing installed = %q, want empty", got) + } +} + +func TestSingboxBinaryName(t *testing.T) { + // The Linux release ships the binary without an extension. + if name := singboxBinaryName(); name != "sing-box" { + t.Errorf("binary = %q, want sing-box", name) + } +} + +func TestWriteConfig(t *testing.T) { + cfg := []byte(`{"log":{"level":"info"},"outbounds":[]}`) + path, err := writeConfig(cfg) + if err != nil { + t.Fatalf("writeConfig: %v", err) + } + defer os.Remove(path) + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read config: %v", err) + } + if !bytes.Equal(got, cfg) { + t.Errorf("config bytes = %q, want %q", got, cfg) + } +} + +func TestWriteConfigBadTempDir(t *testing.T) { + // Point the temp dir at a path that isn't a directory; CreateTemp must fail + // and writeConfig must wrap the error rather than return a usable path. + dir := t.TempDir() + notADir := filepath.Join(dir, "file") + if err := os.WriteFile(notADir, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("TMPDIR", notADir) + + if _, err := writeConfig([]byte(`{}`)); err == nil { + t.Error("writeConfig with a bogus temp dir = nil, want error") + } +} + +func TestDoneBlocksBeforeStart(t *testing.T) { + r := New() + select { + case <-r.Done(): + t.Fatal("Done fired before any Start") + case <-time.After(50 * time.Millisecond): + // expected: nothing to receive + } +} + +func TestStopWithoutStartIsNil(t *testing.T) { + r := New() + if err := r.Stop(); err != nil { + t.Errorf("Stop on idle runner = %v, want nil", err) + } + // Idempotent second call. + if err := r.Stop(); err != nil { + t.Errorf("second Stop = %v, want nil", err) + } +} + +func TestParseConnections(t *testing.T) { + tests := []struct { + name string + body string + wantUp int64 + wantDown int64 + wantErr bool + }{ + { + name: "typical", + body: `{"downloadTotal":51200,"uploadTotal":10240,"connections":[]}`, + wantUp: 10240, + wantDown: 51200, + }, + { + name: "zero", + body: `{"downloadTotal":0,"uploadTotal":0,"connections":null}`, + wantUp: 0, + wantDown: 0, + }, + { + name: "extra fields ignored", + body: `{"downloadTotal":7,"uploadTotal":3,"memory":123,"foo":"bar"}`, + wantUp: 3, + wantDown: 7, + }, + { + name: "malformed", + body: `{not json`, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + up, down, err := parseConnections([]byte(tt.body)) + if tt.wantErr { + if err == nil { + t.Fatal("want error, got nil") + } + return + } + if err != nil { + t.Fatalf("parseConnections: %v", err) + } + if up != tt.wantUp || down != tt.wantDown { + t.Errorf("up,down = %d,%d, want %d,%d", up, down, tt.wantUp, tt.wantDown) + } + }) + } +} + +func TestRingBuffer(t *testing.T) { + b := newRingBuffer(3) + if got := b.snapshot(); len(got) != 0 { + t.Errorf("fresh ring = %v, want empty", got) + } + for _, s := range []string{"a", "b", "c", "d", "e"} { + b.add(s) + } + got := b.snapshot() + want := []string{"c", "d", "e"} + if !slices.Equal(got, want) { + t.Errorf("ring = %v, want %v", got, want) + } +} + +func TestStatsErrorWhenAPIDown(t *testing.T) { + // Point at a port nothing listens on; Stats must error, not panic, and the + // error is the caller's signal that the API isn't up yet. + r := New() + r.ClashPort = 1 // privileged, unused by us in tests + if _, _, err := r.Stats(); err == nil { + t.Error("Stats against a dead port should error") + } +} + +func TestLogs(t *testing.T) { + r := New() + if got := r.Logs(); len(got) != 0 { + t.Errorf("Logs on a fresh runner = %v, want empty", got) + } + + // Seed the ring the way the stream scanners would and confirm Logs returns a + // newest-last copy. + r.ring.add("first") + r.ring.add("second") + got := r.Logs() + if len(got) != 2 || got[0] != "first" || got[1] != "second" { + t.Fatalf("Logs = %v, want [first second]", got) + } + + // The returned slice must be a copy: mutating it must not corrupt the ring. + got[0] = "tampered" + if again := r.Logs(); again[0] != "first" { + t.Errorf("Logs returned an aliased slice; ring corrupted to %v", again) + } +} + +func TestLogsNilRing(t *testing.T) { + // A zero-value Runner has no ring; Logs must report nil, not panic. + var r Runner + if got := r.Logs(); got != nil { + t.Errorf("Logs on zero-value runner = %v, want nil", got) + } +} + +func TestElevationHintFor(t *testing.T) { + // root (euid 0) needs no hint; anything else gets the diagnostic note that + // explains why the tun device cannot be opened. + if got := elevationHintFor(0); got != "" { + t.Errorf("elevationHintFor(0) = %q, want empty", got) + } + got := elevationHintFor(1000) + if got == "" { + t.Fatal("elevationHintFor(1000) = empty, want a diagnostic hint") + } + // The hint has to name the thing a Linux user can act on. A message about a + // Windows service or a macOS helper would be worse than none. + for _, want := range []string{"CAP_NET_ADMIN", tunDevice, "tenebra.service"} { + if !bytes.Contains([]byte(got), []byte(want)) { + t.Errorf("elevation hint %q does not mention %q", got, want) + } + } +} + +func TestTunDeviceHintFor(t *testing.T) { + // A present device node needs no hint: any regular file stands in for it, + // since the check is only "does this path exist". + present := filepath.Join(t.TempDir(), "tun") + if err := os.WriteFile(present, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if got := tunDeviceHintFor(present); got != "" { + t.Errorf("tunDeviceHintFor(existing) = %q, want empty", got) + } + + missing := filepath.Join(t.TempDir(), "net", "tun") + got := tunDeviceHintFor(missing) + if got == "" { + t.Fatal("tunDeviceHintFor(missing) = empty, want a diagnostic hint") + } + if !bytes.Contains([]byte(got), []byte(missing)) { + t.Errorf("hint %q does not name the missing device %q", got, missing) + } +} diff --git a/cmd/tenebra-core/main.go b/cmd/tenebra-core/main.go index a18ab11..3ac308e 100644 --- a/cmd/tenebra-core/main.go +++ b/cmd/tenebra-core/main.go @@ -3,9 +3,9 @@ // of three transports: by default the core is a sidecar speaking on stdin/stdout // (stdout carries protocol traffic only and every diagnostic goes to stderr); // on Windows it can instead serve the protocol on a named pipe — as a Windows -// service, or in a console with --pipe — and on macOS on a unix domain socket -// (a root LaunchDaemon, or a console with --socket), so the tunnel can outlive -// any one UI process. See docs/control-protocol.md for the transports. +// service, or in a console with --pipe — and on macOS and Linux on a unix domain +// socket (the privileged daemon, or a console with --socket), so the tunnel can +// outlive any one UI process. See docs/control-protocol.md for the transports. package main import ( @@ -17,6 +17,7 @@ import ( "os" "os/signal" "path/filepath" + "strings" "syscall" "github.com/Divaaaan/tenebra/core/control" @@ -29,9 +30,9 @@ import ( var pipeMode = flag.Bool("pipe", false, "serve the control protocol on the named pipe instead of stdin/stdout (Windows only)") // socketMode switches the console process from stdin/stdout to the unix-socket -// transport (macOS only): the development way to exercise the LaunchDaemon's -// transport without installing a daemon, and what that daemon itself runs with. -var socketMode = flag.Bool("socket", false, "serve the control protocol on a unix domain socket instead of stdin/stdout (macOS only)") +// transport (macOS and Linux): the development way to exercise the privileged +// 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)") func main() { flag.Parse() @@ -65,10 +66,11 @@ func run(usePipe, useSocket bool) error { return errors.New("choose one transport: --pipe or --socket, not both") } - // A root LaunchDaemon serving --socket needs the machine-scoped store and - // bundled sing-box pinned before buildDaemon reads them — the same ordering - // the Windows service uses for configureServicePaths. This is a no-op for a - // non-root --socket dev run and off macOS (where serveSocket rejects --socket). + // A root daemon serving --socket needs the machine-scoped store and bundled + // sing-box pinned before buildDaemon reads them — the same ordering the + // Windows service uses for configureServicePaths. This is a no-op for a + // non-root --socket dev run and on platforms where serveSocket rejects + // --socket outright. if useSocket { if err := configureSocketPaths(); err != nil { return err @@ -118,7 +120,7 @@ func buildDaemon() (*control.Daemon, error) { } // newRunner is chosen at build time per platform (runner_darwin.go for macOS, - // runner_other.go for Windows and the Linux CI build). + // runner_linux.go for Linux, runner_other.go for Windows). runner := newRunner() daemon := control.NewDaemon(store, runner) // Persist last-good per profile next to the store so the node that last @@ -182,23 +184,39 @@ func buildDaemon() (*control.Daemon, error) { var ruleSetFiles = []string{"geoip-ru.srs", "geosite-ru.srs", "geosite-ads.srs"} // ruleSetDir returns the directory to load the RU rule-sets from, or "" to keep -// the remote-download fallback. The resources directory is the one holding the -// sing-box binary (TENEBRA_SINGBOX); the .srs ship alongside it. It returns a -// path only when every required rule-set file is actually present there, so a -// dev build or an incomplete install transparently falls back to remote instead -// of pointing sing-box at a missing path (which would FATAL). +// the remote-download fallback. It walks the platform's resource directories in +// order (ruleSetCandidates) and takes the first that holds every required +// rule-set file, so a dev build or an incomplete install transparently falls +// back to remote instead of pointing sing-box at a missing path (which would +// FATAL). A miss names everywhere it looked: the alternative — one line saying +// only that connects will now stall on a download — leaves the operator with +// nowhere to put the files. func ruleSetDir() string { - bin := os.Getenv("TENEBRA_SINGBOX") - if bin == "" { - return "" + candidates := ruleSetCandidates() + for _, dir := range candidates { + if hasRuleSets(dir) { + return dir + } + } + if len(candidates) > 0 { + log.Printf("tenebra-core: no complete RU rule-set bundle in any of: %s", strings.Join(candidates, ", ")) + } + return "" +} + +// hasRuleSets reports whether dir holds every file in ruleSetFiles. The empty +// directory is never a hit, so a candidate list carrying one (an unset +// TENEBRA_SINGBOX) cannot resolve to the process's working directory. +func hasRuleSets(dir string) bool { + if dir == "" { + return false } - dir := filepath.Dir(bin) for _, f := range ruleSetFiles { if _, err := os.Stat(filepath.Join(dir, f)); err != nil { - return "" + return false } } - return dir + return true } // configDir returns the directory the profile store lives in. It prefers the diff --git a/cmd/tenebra-core/main_test.go b/cmd/tenebra-core/main_test.go index bfbecb3..6ae573c 100644 --- a/cmd/tenebra-core/main_test.go +++ b/cmd/tenebra-core/main_test.go @@ -73,9 +73,19 @@ func TestRuleSetDirRequiresAllFiles(t *testing.T) { } // TestRuleSetDirNoEnv: with TENEBRA_SINGBOX unset there is no resources dir to -// probe, so ruleSetDir declines. +// probe, so ruleSetDir declines rather than falling back to the working +// directory. func TestRuleSetDirNoEnv(t *testing.T) { t.Setenv("TENEBRA_SINGBOX", "") + // On a platform whose search reaches system install directories (Linux), a + // machine that already has Tenebra installed holds a complete bundle in one + // of them, and finding it is the correct answer — there is no "nothing to + // probe" case left to assert. Say so rather than fail on a real install. + for _, dir := range ruleSetCandidates() { + if hasRuleSets(dir) { + t.Skipf("a system-wide rule-set bundle at %s leaves no empty case to observe", dir) + } + } if got := ruleSetDir(); got != "" { t.Errorf("ruleSetDir with no env = %q, want empty", got) } diff --git a/cmd/tenebra-core/ruleset_linux.go b/cmd/tenebra-core/ruleset_linux.go new file mode 100644 index 0000000..3b7a172 --- /dev/null +++ b/cmd/tenebra-core/ruleset_linux.go @@ -0,0 +1,47 @@ +//go:build linux + +package main + +import ( + "os" + "path/filepath" + + "github.com/Divaaaan/tenebra/adapters/linux" +) + +// ruleSetCandidates lists the directories the bundled RU rule-sets may live in, +// in probe order. On Linux the .srs cannot be assumed to sit beside the sing-box +// binary the way they do inside a Windows install folder or a macOS app bundle: +// a distribution package puts architecture-independent data under +// /share, and sing-box itself may be a packaged dependency in /usr/bin +// that knows nothing about Tenebra's rule-sets. So the order is: +// +// - the directory holding TENEBRA_SINGBOX, which keeps the self-contained +// layout (dev checkout, AppImage, /opt install) working exactly as before +// and lets an operator override the whole search by pointing at a directory +// that carries both; +// - the platform's install directories (adapters/linux.InstallDirs) — the same +// list, in the same order, the sing-box resolution walks, so the two can +// never disagree about where a Tenebra install keeps its files. +// +// A miss here is not fatal: ruleSetDir logs everywhere it looked and the routing +// layer falls back to downloading the sets at connect time. +func ruleSetCandidates() []string { + var out []string + if bin := os.Getenv("TENEBRA_SINGBOX"); bin != "" { + out = append(out, filepath.Dir(bin)) + } + exe, err := os.Executable() + if err != nil { + // Without the executable's location only the override is knowable. That + // is still a usable answer, so return it rather than give up entirely. + return out + } + for _, dir := range linux.InstallDirs(filepath.Dir(exe)) { + if len(out) > 0 && dir == out[0] { + continue + } + out = append(out, dir) + } + return out +} diff --git a/cmd/tenebra-core/ruleset_other.go b/cmd/tenebra-core/ruleset_other.go new file mode 100644 index 0000000..74bfc5b --- /dev/null +++ b/cmd/tenebra-core/ruleset_other.go @@ -0,0 +1,25 @@ +//go:build !linux + +package main + +import ( + "os" + "path/filepath" +) + +// ruleSetCandidates lists the directories the bundled RU rule-sets may live in, +// in probe order. Off Linux there is exactly one: the directory holding the +// sing-box the core was pointed at. Both shipping layouts — the Windows install +// folder and the macOS app bundle — stage the .srs beside the binary as declared +// resources, so if they are not there they are nowhere. +// +// With TENEBRA_SINGBOX unset there is no resources directory to probe and the +// list is empty, which leaves the remote-download fallback in place rather than +// probing the process's working directory. +func ruleSetCandidates() []string { + bin := os.Getenv("TENEBRA_SINGBOX") + if bin == "" { + return nil + } + return []string{filepath.Dir(bin)} +} diff --git a/cmd/tenebra-core/runner_linux.go b/cmd/tenebra-core/runner_linux.go new file mode 100644 index 0000000..081a9a9 --- /dev/null +++ b/cmd/tenebra-core/runner_linux.go @@ -0,0 +1,17 @@ +//go:build linux + +package main + +import ( + "github.com/Divaaaan/tenebra/adapters/linux" + "github.com/Divaaaan/tenebra/core/control" +) + +// newRunner builds the tunnel supervisor for Linux. Linux gets its own adapter +// (rather than reusing the generic one in runner_other.go) because the two +// things that go wrong there are Linux-specific and worth naming in the logs the +// user sees: the tun device needs privilege the Windows wintun path does not, +// and /dev/net/tun may not exist at all. See adapters/linux. +func newRunner() control.Runner { + return linux.New() +} diff --git a/cmd/tenebra-core/runner_other.go b/cmd/tenebra-core/runner_other.go index 34d461a..8464dcf 100644 --- a/cmd/tenebra-core/runner_other.go +++ b/cmd/tenebra-core/runner_other.go @@ -1,4 +1,4 @@ -//go:build !darwin +//go:build !darwin && !linux package main @@ -7,11 +7,12 @@ import ( "github.com/Divaaaan/tenebra/core/control" ) -// newRunner builds the tunnel supervisor for every non-macOS target the core -// builds for. The windows adapter is the generic sidecar supervisor — it only -// touches wintun when the OS is actually Windows — so it backs Windows (the -// shipped desktop target) as well as the Linux build the CI compiles and tests. -// macOS overrides this in runner_darwin.go. +// newRunner builds the tunnel supervisor for Windows, the shipped desktop target +// this adapter is named for, and for any other platform the core still compiles +// on: the windows adapter uses nothing OS-specific at the source level and only +// touches wintun when the OS is actually Windows. macOS and Linux, which need +// their own privilege notes and diagnostics, override this in runner_darwin.go +// and runner_linux.go. func newRunner() control.Runner { return windows.New() } diff --git a/cmd/tenebra-core/socket_darwin.go b/cmd/tenebra-core/socket_darwin.go index 9be1b78..f4bed92 100644 --- a/cmd/tenebra-core/socket_darwin.go +++ b/cmd/tenebra-core/socket_darwin.go @@ -2,17 +2,7 @@ package main -import ( - "context" - "errors" - "fmt" - "log" - "os" - "path/filepath" - "syscall" - - "github.com/Divaaaan/tenebra/core/control" -) +import "path/filepath" // rootDataDir is the machine-scoped profile store for the root LaunchDaemon — // the darwin analog of the Windows service's %ProgramData%\Tenebra\data. It sits @@ -26,214 +16,17 @@ const rootDataDir = "/Library/Application Support/Tenebra/data" // unlike the Windows sing-box.exe (matching adapters/macos's own resolution). const singboxBinaryName = "sing-box" -// serveSocket serves the control protocol on the unix domain socket from a -// console or a root LaunchDaemon — the macOS counterpart of servePipe, so the -// socket path can be exercised with `tenebra-core --socket` without installing a -// daemon. The transport can be disabled with TENEBRA_SOCKET=off; asking to serve -// on a disabled socket is a caller error, so report it rather than fall through -// to a stdio a LaunchDaemon does not have. -func serveSocket(ctx context.Context, d *control.Daemon) error { - path, enabled := socketPath() - if !enabled { - return errors.New("--socket: TENEBRA_SOCKET=off disables the unix-socket transport; unset it or give a path to serve") - } - l, err := control.ListenSocket(path) - if err != nil { - return err - } - log.Printf("tenebra-core: serving the control protocol on %s", path) - return control.ServeListener(ctx, d, l) -} - -// socketPath resolves the unix domain socket the protocol is served on and -// whether the socket transport is enabled at all. It mirrors the desktop UI's -// backend::pipe::configured_name so the two ends agree: TENEBRA_SOCKET unset or -// empty selects the well-known DefaultSocketPath, `off`/`0` disables the -// transport, and any other value names an alternate path (a test points it at a -// temp dir; an operator can relocate it). The pure mapping is split into -// socketPathFrom so it can be tested without touching the environment. -func socketPath() (path string, enabled bool) { - return socketPathFrom(os.Getenv("TENEBRA_SOCKET")) -} - -func socketPathFrom(value string) (path string, enabled bool) { - switch value { - case "": - return control.DefaultSocketPath, true - case "off", "0": - return "", false - default: - return value, true - } -} - -// configureSocketPaths pins the machine-scoped locations before buildDaemon -// reads them when the core serves the socket as root — the darwin analog of the -// Windows service's configureServicePaths. A root LaunchDaemon owns the tunnel -// while an unprivileged GUI drives it over the socket, so the per-user defaults -// (which would land in root's own ~/Library) are wrong: the store belongs under -// rootDataDir and the bundled sing-box is resolved from the install layout -// around the executable. Both knobs are the environment variables every mode -// already honours, set process-wide here so the existing readers — configDir, -// the runner, ruleSetDir — pick them up unchanged; an operator-set environment -// still wins, matching the override semantics everywhere else. A non-root -// `--socket` run (the dev path) is left on its per-user paths untouched. -func configureSocketPaths() error { - return configureSocketPathsFor(os.Geteuid()) -} - -// configureSocketPathsFor is the euid-injectable core of configureSocketPaths, -// so the non-root no-op can be tested without a privileged process (as -// elevationHintFor is in adapters/macos). Only euid 0 — the LaunchDaemon that -// can open utun — gets the machine-scoped layout; every other euid keeps its -// per-user paths. -func configureSocketPathsFor(euid int) error { - if euid != 0 { - return nil - } - if os.Getenv("TENEBRA_CONFIG_DIR") == "" { - if err := secureDataDir(rootDataDir); err != nil { - return fmt.Errorf("socket: prepare data dir %s: %w", rootDataDir, err) - } - os.Setenv("TENEBRA_CONFIG_DIR", rootDataDir) - } - if os.Getenv("TENEBRA_SINGBOX") == "" { - exe, err := os.Executable() - if err != nil { - return fmt.Errorf("socket: locate executable: %w", err) - } - if bin := findBundledSingbox(filepath.Dir(exe)); bin != "" { - // Setting the variable rather than the runner field also lights up - // ruleSetDir, which probes the directory this path points into. - os.Setenv("TENEBRA_SINGBOX", bin) - } else { - // Not fatal: the runner falls back to sing-box next to the executable - // and reports precisely, at connect time, if it is missing. - log.Printf("tenebra-core: no bundled sing-box near %s", exe) - } - } - return nil -} - -// findBundledSingbox resolves the sing-box binary from the installed layout -// around the core executable. The macOS app bundles sing-box as a Tauri +// singboxCandidates lists where sing-box may sit in an installed macOS layout, +// in the order findSingbox probes them. The app bundles sing-box as a Tauri // externalBin sidecar, which lands next to the main binary (Contents/MacOS/), as -// does a flat dev checkout; a bundle that instead stages it under Contents/ -// Resources is covered by the sibling fallback. Returns "" when neither has it, -// matching the Windows resolver's contract — the runner then falls back to its -// own next-to-executable default and reports a miss at connect time. -func findBundledSingbox(exeDir string) string { - for _, p := range []string{ +// does a flat dev checkout; a bundle that instead stages it under +// Contents/Resources is covered by the sibling fallback. Unlike Linux, there is +// no PATH lookup: a macOS install is a self-contained bundle, and picking up a +// stray Homebrew sing-box instead of the pinned one would be a silent version +// mismatch, not a feature. +func singboxCandidates(exeDir string) []string { + return []string{ filepath.Join(exeDir, singboxBinaryName), filepath.Join(exeDir, "..", "Resources", singboxBinaryName), - } { - if _, err := os.Stat(p); err == nil { - return p - } - } - return "" -} - -// secureDataDir creates dir (with parents) and clamps it to root-owned 0700 — -// the darwin analog of the Windows service's DACL clamp, expressed in unix -// ownership and mode. The clamp is re-applied and then verified on every start, -// not just on creation: /Library/Application Support is world-traversable and a -// non-root user could have pre-created the path, so we chown it back to root and -// strip every group/other bit before the store writes credentials into it. It is -// fail-closed: if the directory cannot be made root-only, or a final stat still -// shows the wrong owner or mode, we return an error rather than scatter -// subscription secrets into a directory another user can read. (chown to root -// needs privilege the daemon has in production; configureSocketPathsFor only -// calls this at euid 0, so an unprivileged run never reaches it.) -// -// The clamp is symlink-safe. The world-traversable parent means a non-root user -// could pre-plant a symlink at the data-dir path; a chown/chmod/stat by name -// would then follow it and clamp — and verify — the link's target, not the -// directory we go on to write into, so the anti-squat defence would bless an -// attacker-controlled location. So the path is opened once with O_NOFOLLOW (a -// final symlink makes the open fail outright) and every subsequent step acts on -// that file descriptor: fchown/fchmod/fstat cannot be redirected by a symlink -// swapped in after the open, closing the squat-and-TOCTOU window together. -func secureDataDir(dir string) error { - if err := os.MkdirAll(dir, 0o700); err != nil { - return err - } - // Re-open the directory itself, refusing to traverse a final symlink. If the - // path was pre-planted as a link the open fails here, before any privileged - // clamp touches the target; MkdirAll above is a no-op on an existing entry, - // so it neither creates through nor sanitises such a link — this open is what - // rejects it. - f, err := openDirNoFollow(dir) - if err != nil { - return err - } - defer f.Close() - return clampRootOnlyDir(dir, f) -} - -// openDirNoFollow opens dir for the ownership clamp without following a final -// symlink. O_DIRECTORY additionally fails the open if the (real) target is not a -// directory, so the descriptor handed to clampRootOnlyDir is always a genuine -// directory we can fchown/fchmod/fstat. -func openDirNoFollow(dir string) (*os.File, error) { - f, err := os.OpenFile(dir, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_DIRECTORY, 0) - if err != nil { - return nil, fmt.Errorf("open %s without following symlinks: %w", dir, err) - } - return f, nil -} - -// dirHandle is the slice of *os.File clampRootOnlyDir needs: the descriptor-bound -// chown/chmod/stat that make the clamp immune to a symlink swapped in after the -// open. It is an interface so the fail-closed logic can be exercised without a -// real root-owned directory — fchown(0,0) needs privilege the unit tests lack — -// by substituting a fake that reports the ownership and mode the check must -// accept or reject. -type dirHandle interface { - Chown(uid, gid int) error - Chmod(mode os.FileMode) error - Stat() (os.FileInfo, error) -} - -// clampRootOnlyDir forces the opened directory to root-owned 0700 through its -// descriptor and then reads it back to verify, the fail-closed core shared by -// the live path and the tests. name is only for error context. Every operation -// binds to the handle, never to the path, so no symlink race can redirect them. -func clampRootOnlyDir(name string, h dirHandle) error { - if err := h.Chown(0, 0); err != nil { - return fmt.Errorf("chown %s to root: %w", name, err) - } - if err := h.Chmod(0o700); err != nil { - return fmt.Errorf("chmod %s: %w", name, err) - } - info, err := h.Stat() - if err != nil { - return err - } - if err := verifyRootOnlyDir(info); err != nil { - return fmt.Errorf("%s is not root-only after clamp: %w", name, err) - } - return nil -} - -// verifyRootOnlyDir is the fail-closed check secureDataDir ends on: dir must be -// a directory, owned by root (uid 0), with no group or other access. It is the -// pure predicate over a stat result so the rejection paths can be tested without -// a root-owned directory (a non-root test dir trips the ownership check; a 0777 -// dir trips the mode check). -func verifyRootOnlyDir(info os.FileInfo) error { - if !info.IsDir() { - return errors.New("not a directory") - } - if perm := info.Mode().Perm(); perm&0o077 != 0 { - return fmt.Errorf("mode %#o grants group/other access", perm) - } - st, ok := info.Sys().(*syscall.Stat_t) - if !ok { - return errors.New("cannot read ownership") - } - if st.Uid != 0 { - return fmt.Errorf("owned by uid %d, not root", st.Uid) } - return nil } diff --git a/cmd/tenebra-core/socket_darwin_test.go b/cmd/tenebra-core/socket_darwin_test.go index 2ae8d08..427d3d9 100644 --- a/cmd/tenebra-core/socket_darwin_test.go +++ b/cmd/tenebra-core/socket_darwin_test.go @@ -3,78 +3,18 @@ package main import ( - "context" "os" "path/filepath" - "syscall" "testing" - "time" - - "github.com/Divaaaan/tenebra/core/control" ) -// TestSocketPathFrom pins the env mapping the socket transport shares with the -// desktop UI's configured_name: unset/empty is the well-known default, `off`/`0` -// disables the transport, and any other value names an alternate path. -func TestSocketPathFrom(t *testing.T) { - tests := []struct { - name string - value string - wantPath string - wantEnabled bool - }{ - {"unset or empty", "", control.DefaultSocketPath, true}, - {"off disables", "off", "", false}, - {"zero disables", "0", "", false}, - {"custom path", "/tmp/tenebra-alt.sock", "/tmp/tenebra-alt.sock", true}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - gotPath, gotEnabled := socketPathFrom(tc.value) - if gotPath != tc.wantPath || gotEnabled != tc.wantEnabled { - t.Errorf("socketPathFrom(%q) = (%q, %v), want (%q, %v)", - tc.value, gotPath, gotEnabled, tc.wantPath, tc.wantEnabled) - } - }) - } -} - -// TestServeSocketDisabled: asking to serve while TENEBRA_SOCKET=off is a caller -// error, not a silent stdio fallback (a LaunchDaemon has no stdio). It must -// refuse before touching the daemon, so a nil daemon is safe here. -func TestServeSocketDisabled(t *testing.T) { - t.Setenv("TENEBRA_SOCKET", "off") - if err := serveSocket(context.Background(), nil); err == nil { - t.Fatal("serveSocket with TENEBRA_SOCKET=off returned nil, want refusal") - } -} - -// TestConfigureSocketPathsNonRootNoOp: a non-root --socket run (the dev path) -// keeps its per-user paths — the machine-scoped store and sing-box pinning is -// for the root LaunchDaemon only. The euid is injected so the no-op is asserted -// regardless of who runs the test, the way elevationHintFor is exercised. -func TestConfigureSocketPathsNonRootNoOp(t *testing.T) { - t.Setenv("TENEBRA_CONFIG_DIR", "") - t.Setenv("TENEBRA_SINGBOX", "") - - if err := configureSocketPathsFor(501); err != nil { - t.Fatalf("configureSocketPathsFor(non-root) = %v, want nil", err) - } - if got := os.Getenv("TENEBRA_CONFIG_DIR"); got != "" { - t.Errorf("non-root run set TENEBRA_CONFIG_DIR = %q, want untouched", got) - } - if got := os.Getenv("TENEBRA_SINGBOX"); got != "" { - t.Errorf("non-root run set TENEBRA_SINGBOX = %q, want untouched", got) - } -} - // TestFindBundledSingbox walks the layouts the daemon resolves sing-box against: // none present (decline), the flat/externalBin layout (beside the executable), // and the Contents/Resources sibling of an .app bundle. func TestFindBundledSingbox(t *testing.T) { t.Run("missing", func(t *testing.T) { - if got := findBundledSingbox(t.TempDir()); got != "" { - t.Errorf("findBundledSingbox on empty dir = %q, want empty", got) + if got := findSingbox(t.TempDir()); got != "" { + t.Errorf("findSingbox on empty dir = %q, want empty", got) } }) @@ -84,8 +24,8 @@ func TestFindBundledSingbox(t *testing.T) { if err := os.WriteFile(want, []byte("fake"), 0o644); err != nil { t.Fatal(err) } - if got := findBundledSingbox(dir); got != want { - t.Errorf("findBundledSingbox flat = %q, want %q", got, want) + if got := findSingbox(dir); got != want { + t.Errorf("findSingbox flat = %q, want %q", got, want) } }) @@ -104,155 +44,19 @@ func TestFindBundledSingbox(t *testing.T) { if err := os.WriteFile(want, []byte("fake"), 0o644); err != nil { t.Fatal(err) } - if got := findBundledSingbox(macosDir); got != want { - t.Errorf("findBundledSingbox resources = %q, want %q", got, want) + if got := findSingbox(macosDir); got != want { + t.Errorf("findSingbox resources = %q, want %q", got, want) } }) } -// TestSecureDataDirRejectsSymlink proves the anti-squat clamp is symlink-safe: a -// symlink pre-planted at the data-dir path is refused instead of being followed -// so the clamp lands on the link's target. This runs unprivileged — the -// O_NOFOLLOW open fails before any chown to root is attempted, so the rejection -// does not depend on being able to complete the clamp. -func TestSecureDataDirRejectsSymlink(t *testing.T) { - base := t.TempDir() - target := filepath.Join(base, "target") - if err := os.Mkdir(target, 0o700); err != nil { - t.Fatal(err) - } - link := filepath.Join(base, "data") - if err := os.Symlink(target, link); err != nil { - t.Fatal(err) - } - if err := secureDataDir(link); err == nil { - t.Fatal("secureDataDir followed a pre-planted symlink, want refusal") +// TestRootDataDirIsMachineScoped: the store the LaunchDaemon pins must be the +// machine-wide location, not anything under a user's home — a root daemon +// writing into /Users/... would both hide the profiles from the GUI's view of +// the machine store and put credentials on a user-writable path. +func TestRootDataDirIsMachineScoped(t *testing.T) { + const want = "/Library/Application Support/Tenebra/data" + if rootDataDir != want { + t.Errorf("rootDataDir = %q, want %q", rootDataDir, want) } - // The target must be left as it was — the clamp must not have chmod'd it - // through the link (it started 0700; a dangling clamp attempt would not - // change owner without privilege, but mode is observable here). - info, err := os.Stat(target) - if err != nil { - t.Fatal(err) - } - if perm := info.Mode().Perm(); perm != 0o700 { - t.Errorf("symlink target mode changed to %#o; clamp followed the link", perm) - } -} - -// TestClampRootOnlyDir exercises the descriptor-bound fail-closed core against a -// fake handle, so both the accepting and the refusing paths are covered without -// a real root-owned directory (fchown to root needs privilege the test lacks). -func TestClampRootOnlyDir(t *testing.T) { - t.Run("root-owned 0700 passes", func(t *testing.T) { - h := &fakeDirHandle{info: fakeDirInfo{mode: os.ModeDir | 0o700, uid: 0}} - if err := clampRootOnlyDir("data", h); err != nil { - t.Fatalf("clampRootOnlyDir on a root-owned 0700 dir = %v, want nil", err) - } - }) - - t.Run("wrong owner refused", func(t *testing.T) { - h := &fakeDirHandle{info: fakeDirInfo{mode: os.ModeDir | 0o700, uid: 501}} - if err := clampRootOnlyDir("data", h); err == nil { - t.Error("clampRootOnlyDir accepted a non-root-owned dir, want refusal") - } - }) - - t.Run("wrong mode refused", func(t *testing.T) { - h := &fakeDirHandle{info: fakeDirInfo{mode: os.ModeDir | 0o777, uid: 0}} - if err := clampRootOnlyDir("data", h); err == nil { - t.Error("clampRootOnlyDir accepted a group/other-accessible dir, want refusal") - } - }) - - t.Run("chown failure is fatal", func(t *testing.T) { - h := &fakeDirHandle{chownErr: os.ErrPermission, info: fakeDirInfo{mode: os.ModeDir | 0o700, uid: 0}} - if err := clampRootOnlyDir("data", h); err == nil { - t.Error("clampRootOnlyDir swallowed a failed chown, want refusal") - } - }) -} - -// fakeDirHandle is a dirHandle whose clamp operations are no-ops (optionally -// erroring) and whose Stat reports a caller-chosen FileInfo, standing in for the -// privileged *os.File so clampRootOnlyDir's verdict can be tested unprivileged. -type fakeDirHandle struct { - info os.FileInfo - chownErr error - chmodErr error -} - -func (h *fakeDirHandle) Chown(uid, gid int) error { return h.chownErr } -func (h *fakeDirHandle) Chmod(mode os.FileMode) error { return h.chmodErr } -func (h *fakeDirHandle) Stat() (os.FileInfo, error) { return h.info, nil } - -// fakeDirInfo is the minimal os.FileInfo verifyRootOnlyDir reads: IsDir/Mode from -// mode, and a syscall.Stat_t carrying uid behind Sys() for the ownership check. -type fakeDirInfo struct { - mode os.FileMode - uid uint32 -} - -func (f fakeDirInfo) Name() string { return "data" } -func (f fakeDirInfo) Size() int64 { return 0 } -func (f fakeDirInfo) Mode() os.FileMode { return f.mode } -func (f fakeDirInfo) ModTime() time.Time { return time.Time{} } -func (f fakeDirInfo) IsDir() bool { return f.mode.IsDir() } -func (f fakeDirInfo) Sys() any { return &syscall.Stat_t{Uid: f.uid} } - -// TestVerifyRootOnlyDir exercises the fail-closed predicate secureDataDir ends -// on. The success case (a real root-owned 0700 dir) needs privilege and is -// validated in a live daemon run; here we prove the rejections fire. -func TestVerifyRootOnlyDir(t *testing.T) { - t.Run("loose mode rejected", func(t *testing.T) { - dir := filepath.Join(t.TempDir(), "loose") - if err := os.Mkdir(dir, 0o700); err != nil { - t.Fatal(err) - } - // Mkdir is subject to umask; force the group/other bits on explicitly. - if err := os.Chmod(dir, 0o777); err != nil { - t.Fatal(err) - } - info, err := os.Stat(dir) - if err != nil { - t.Fatal(err) - } - if err := verifyRootOnlyDir(info); err == nil { - t.Error("verifyRootOnlyDir accepted a 0777 dir, want rejection") - } - }) - - t.Run("regular file rejected", func(t *testing.T) { - f := filepath.Join(t.TempDir(), "file") - if err := os.WriteFile(f, []byte("x"), 0o600); err != nil { - t.Fatal(err) - } - info, err := os.Stat(f) - if err != nil { - t.Fatal(err) - } - if err := verifyRootOnlyDir(info); err == nil { - t.Error("verifyRootOnlyDir accepted a regular file, want rejection") - } - }) - - t.Run("non-root owner rejected", func(t *testing.T) { - // A 0700 dir owned by the test user must fail the ownership check. When - // the suite runs as root that dir is legitimately root-owned and the - // check passes, so the assertion only applies to an unprivileged run. - if os.Geteuid() == 0 { - t.Skip("running as root: a self-owned dir is root-owned, nothing to reject") - } - dir := filepath.Join(t.TempDir(), "tight") - if err := os.Mkdir(dir, 0o700); err != nil { - t.Fatal(err) - } - info, err := os.Stat(dir) - if err != nil { - t.Fatal(err) - } - if err := verifyRootOnlyDir(info); err == nil { - t.Error("verifyRootOnlyDir accepted a non-root 0700 dir, want ownership rejection") - } - }) } diff --git a/cmd/tenebra-core/socket_linux.go b/cmd/tenebra-core/socket_linux.go new file mode 100644 index 0000000..82a25ef --- /dev/null +++ b/cmd/tenebra-core/socket_linux.go @@ -0,0 +1,23 @@ +//go:build linux + +package main + +import "github.com/Divaaaan/tenebra/adapters/linux" + +// rootDataDir is the machine-scoped profile store for the privileged daemon — +// the Linux analog of the Windows service's %ProgramData%\Tenebra\data and of +// macOS's /Library/Application Support/Tenebra/data. /var/lib is the FHS home +// for persistent state a service owns (as opposed to /run, which is wiped on +// boot and holds only the control socket), and configureSocketPaths locks it to +// root: profiles carry subscription credentials that unprivileged users reach +// through the socket protocol, never the files. +const rootDataDir = "/var/lib/tenebra/data" + +// singboxCandidates lists where sing-box may sit on Linux, in probe order. The +// list is the adapter's (adapters/linux.SingboxCandidates) rather than one of +// this package's own, because the runner resolves the same binary from the same +// layout and a second copy of the order would drift from it. See that function +// for why "next to the executable" is only the first of several answers here. +func singboxCandidates(exeDir string) []string { + return linux.SingboxCandidates(exeDir) +} diff --git a/cmd/tenebra-core/socket_linux_test.go b/cmd/tenebra-core/socket_linux_test.go new file mode 100644 index 0000000..b04e6a2 --- /dev/null +++ b/cmd/tenebra-core/socket_linux_test.go @@ -0,0 +1,217 @@ +//go:build linux + +package main + +import ( + "os" + "path/filepath" + "slices" + "testing" +) + +// emptyPATH points PATH at an empty directory for the duration of a test, so the +// last step of the sing-box search (a lookup on PATH) is decided by the test +// rather than by whatever the machine running it happens to have installed. +func emptyPATH(t *testing.T) { + t.Helper() + t.Setenv("PATH", t.TempDir()) +} + +// requireNoSystemInstall skips a "nothing is installed" assertion on a machine +// where Tenebra genuinely is installed system-wide. The absolute backstop in the +// search order is not injectable — it is the point of the backstop — so a real +// install legitimately answers, and calling that a failure would only teach +// contributors to ignore a red test. +func requireNoSystemInstall(t *testing.T) { + t.Helper() + for _, dir := range []string{"/usr/lib/tenebra", "/usr/libexec/tenebra", "/usr/share/tenebra"} { + p := filepath.Join(dir, "sing-box") + if _, err := os.Stat(p); err == nil { + t.Skipf("a system-wide sing-box at %s leaves no miss to observe", p) + } + } +} + +// TestFindSingbox walks the layouts the daemon resolves sing-box against on +// Linux: none present (decline), beside the executable (a self-contained +// install directory, an unpacked AppImage, or a dev checkout), and the private +// helper directories a distribution package uses when the launcher itself lives +// in /bin. +func TestFindSingbox(t *testing.T) { + t.Run("missing", func(t *testing.T) { + emptyPATH(t) + requireNoSystemInstall(t) + if got := findSingbox(t.TempDir()); got != "" { + t.Errorf("findSingbox on empty dir = %q, want empty", got) + } + }) + + t.Run("flat", func(t *testing.T) { + emptyPATH(t) + dir := t.TempDir() + want := filepath.Join(dir, "sing-box") + if err := os.WriteFile(want, []byte("fake"), 0o644); err != nil { + t.Fatal(err) + } + if got := findSingbox(dir); got != want { + t.Errorf("findSingbox flat = %q, want %q", got, want) + } + }) + + // /bin/tenebra-core resolves sing-box from /lib/tenebra, + // /libexec/tenebra and /share/tenebra — the conventions + // distributions split on for a package's private files. + for _, private := range []string{"lib", "libexec", "share"} { + t.Run(private+" sibling", func(t *testing.T) { + emptyPATH(t) + prefix := t.TempDir() + binDir := filepath.Join(prefix, "bin") + helperDir := filepath.Join(prefix, private, "tenebra") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(helperDir, 0o755); err != nil { + t.Fatal(err) + } + want := filepath.Join(helperDir, "sing-box") + if err := os.WriteFile(want, []byte("fake"), 0o644); err != nil { + t.Fatal(err) + } + if got := findSingbox(binDir); got != want { + t.Errorf("findSingbox %s = %q, want %q", private, got, want) + } + }) + } + + t.Run("beside the executable wins", func(t *testing.T) { + // A binary next to the core is the one that was installed with it; a + // copy in a shared helper directory must not shadow it. + emptyPATH(t) + prefix := t.TempDir() + binDir := filepath.Join(prefix, "bin") + helperDir := filepath.Join(prefix, "lib", "tenebra") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(helperDir, 0o755); err != nil { + t.Fatal(err) + } + want := filepath.Join(binDir, "sing-box") + for _, p := range []string{want, filepath.Join(helperDir, "sing-box")} { + if err := os.WriteFile(p, []byte("fake"), 0o644); err != nil { + t.Fatal(err) + } + } + if got := findSingbox(binDir); got != want { + t.Errorf("findSingbox = %q, want the neighbour %q", got, want) + } + }) + + t.Run("PATH is the last resort", func(t *testing.T) { + // The distribution-dependency layout: sing-box is a package of its own in + // a directory that knows nothing about Tenebra, reachable only on PATH. + pathDir := t.TempDir() + want := filepath.Join(pathDir, "sing-box") + if err := os.WriteFile(want, []byte("fake"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", pathDir) + + if got := findSingbox(t.TempDir()); got != want { + t.Errorf("findSingbox = %q, want the PATH hit %q", got, want) + } + }) + + t.Run("an install beats PATH", func(t *testing.T) { + // With both present the copy Tenebra installed wins, so a packaged + // version pin is never silently displaced by whatever is on PATH. + pathDir := t.TempDir() + if err := os.WriteFile(filepath.Join(pathDir, "sing-box"), []byte("fake"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", pathDir) + + exeDir := t.TempDir() + want := filepath.Join(exeDir, "sing-box") + if err := os.WriteFile(want, []byte("fake"), 0o644); err != nil { + t.Fatal(err) + } + if got := findSingbox(exeDir); got != want { + t.Errorf("findSingbox = %q, want the installed %q", got, want) + } + }) +} + +// TestRuleSetCandidatesLeadWithSingboxDir: the directory holding the configured +// sing-box stays the first place probed, so the self-contained layouts keep +// working exactly as they did before the search grew a chain. +func TestRuleSetCandidatesLeadWithSingboxDir(t *testing.T) { + dir := t.TempDir() + t.Setenv("TENEBRA_SINGBOX", filepath.Join(dir, "sing-box")) + + got := ruleSetCandidates() + if len(got) == 0 || got[0] != dir { + t.Fatalf("ruleSetCandidates() = %v, want %q first", got, dir) + } + if slices.Contains(got[1:], dir) { + t.Errorf("ruleSetCandidates() repeats %q: %v", dir, got) + } +} + +// TestRuleSetCandidatesCoverInstallDirs: with sing-box supplied by the +// distribution (its own /usr/bin package, holding no .srs), the rule-sets must +// still be found in the directories Tenebra's own package installs into. +func TestRuleSetCandidatesCoverInstallDirs(t *testing.T) { + t.Setenv("TENEBRA_SINGBOX", "/usr/bin/sing-box") + + got := ruleSetCandidates() + if got[0] != "/usr/bin" { + t.Fatalf("ruleSetCandidates() = %v, want the sing-box dir first", got) + } + for _, want := range []string{"/usr/lib/tenebra", "/usr/share/tenebra"} { + if !slices.Contains(got, want) { + t.Errorf("ruleSetCandidates() = %v, missing %q", got, want) + } + } +} + +// TestRuleSetDirFindsBundleAwayFromSingbox: the whole point of the chain — a +// sing-box in one directory and the .srs in another resolves to the directory +// that actually holds the rule-sets, instead of falling back to a slow remote +// download at every connect. +func TestRuleSetDirFindsBundleAwayFromSingbox(t *testing.T) { + // The install dirs derived from the test binary's own location are not + // writable in general, so drive the case through the one candidate a test + // controls: an override pointing somewhere with no .srs must not stop a + // later candidate from answering. Here the later candidate is arranged by + // pointing the override at a directory that does hold them, and asserting + // the earlier, empty one is skipped. + empty := t.TempDir() + full := t.TempDir() + for _, f := range ruleSetFiles { + if err := os.WriteFile(filepath.Join(full, f), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + + t.Setenv("TENEBRA_SINGBOX", filepath.Join(empty, "sing-box")) + if got := ruleSetDir(); got != "" { + t.Errorf("ruleSetDir with an empty sing-box dir = %q, want the remote fallback", got) + } + + t.Setenv("TENEBRA_SINGBOX", filepath.Join(full, "sing-box")) + if got := ruleSetDir(); got != full { + t.Errorf("ruleSetDir = %q, want %q", got, full) + } +} + +// TestRootDataDirIsMachineScoped: the store the system service pins must be the +// FHS location for state a service owns. /run would be wrong (wiped on boot, +// taking every imported profile with it) and so would anything under a user's +// home, which a root daemon has no business writing credentials into. +func TestRootDataDirIsMachineScoped(t *testing.T) { + const want = "/var/lib/tenebra/data" + if rootDataDir != want { + t.Errorf("rootDataDir = %q, want %q", rootDataDir, want) + } +} diff --git a/cmd/tenebra-core/socket_other.go b/cmd/tenebra-core/socket_other.go index 8fd6708..abca6e8 100644 --- a/cmd/tenebra-core/socket_other.go +++ b/cmd/tenebra-core/socket_other.go @@ -1,4 +1,4 @@ -//go:build !darwin +//go:build !darwin && !linux package main @@ -9,14 +9,14 @@ import ( "github.com/Divaaaan/tenebra/core/control" ) -// serveSocket rejects --socket off macOS: the unix-socket transport, and the -// root LaunchDaemon that uses it, are the macOS analog of Windows' named pipe -// and service — they exist only there, the way servePipe rejects --pipe -// everywhere but Windows. +// serveSocket rejects --socket on platforms without the unix-socket transport. +// It is the counterpart of Windows' named pipe and service, and exists where a +// privileged daemon can serve the protocol on a filesystem path — macOS and +// Linux — the way servePipe rejects --pipe everywhere but Windows. func serveSocket(context.Context, *control.Daemon) error { - return errors.New("--socket: the unix-socket transport is only available on macOS") + return errors.New("--socket: the unix-socket transport is only available on macOS and Linux") } -// configureSocketPaths has nothing to prepare off macOS, where serveSocket +// configureSocketPaths has nothing to prepare on platforms where serveSocket // rejects the socket transport outright. func configureSocketPaths() error { return nil } diff --git a/cmd/tenebra-core/socket_unix.go b/cmd/tenebra-core/socket_unix.go new file mode 100644 index 0000000..c18ef27 --- /dev/null +++ b/cmd/tenebra-core/socket_unix.go @@ -0,0 +1,232 @@ +//go:build darwin || linux + +package main + +import ( + "context" + "errors" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "syscall" + + "github.com/Divaaaan/tenebra/core/control" +) + +// serveSocket serves the control protocol on the unix domain socket, from a +// console or from the privileged daemon that owns the tunnel (a LaunchDaemon on +// macOS, a system service on Linux) — the unix counterpart of servePipe, so the +// socket path can be exercised with `tenebra-core --socket` without installing a +// daemon. The transport can be disabled with TENEBRA_SOCKET=off; asking to serve +// on a disabled socket is a caller error, so report it rather than fall through +// to a stdio a detached daemon does not have. +func serveSocket(ctx context.Context, d *control.Daemon) error { + path, enabled := socketPath() + if !enabled { + return errors.New("--socket: TENEBRA_SOCKET=off disables the unix-socket transport; unset it or give a path to serve") + } + l, err := control.ListenSocket(path) + if err != nil { + return err + } + log.Printf("tenebra-core: serving the control protocol on %s", path) + return control.ServeListener(ctx, d, l) +} + +// socketPath resolves the unix domain socket the protocol is served on and +// whether the socket transport is enabled at all. It mirrors the desktop UI's +// backend::pipe::configured_name so the two ends agree: TENEBRA_SOCKET unset or +// empty selects the well-known DefaultSocketPath, `off`/`0` disables the +// transport, and any other value names an alternate path (a test points it at a +// temp dir; an operator can relocate it). The pure mapping is split into +// socketPathFrom so it can be tested without touching the environment. +func socketPath() (path string, enabled bool) { + return socketPathFrom(os.Getenv("TENEBRA_SOCKET")) +} + +func socketPathFrom(value string) (path string, enabled bool) { + switch value { + case "": + return control.DefaultSocketPath, true + case "off", "0": + return "", false + default: + return value, true + } +} + +// configureSocketPaths pins the machine-scoped locations before buildDaemon +// reads them when the core serves the socket as root — the unix analog of the +// Windows service's configureServicePaths. A root daemon owns the tunnel while +// an unprivileged GUI drives it over the socket, so the per-user defaults (which +// would land in root's own home) are wrong: the store belongs under rootDataDir +// and the bundled sing-box is resolved from the install layout around the +// executable. Both knobs are the environment variables every mode already +// honours, set process-wide here so the existing readers — configDir, the +// runner, ruleSetDir — pick them up unchanged; an operator-set environment still +// wins, matching the override semantics everywhere else. A non-root `--socket` +// run (the dev path) is left on its per-user paths untouched. +func configureSocketPaths() error { + return configureSocketPathsFor(os.Geteuid()) +} + +// configureSocketPathsFor is the euid-injectable core of configureSocketPaths, +// so the non-root no-op can be tested without a privileged process (as +// elevationHintFor is in the platform adapters). Only euid 0 — the daemon that +// can open the tun device — gets the machine-scoped layout; every other euid +// keeps its per-user paths. +func configureSocketPathsFor(euid int) error { + if euid != 0 { + return nil + } + if os.Getenv("TENEBRA_CONFIG_DIR") == "" { + if err := secureDataDir(rootDataDir); err != nil { + return fmt.Errorf("socket: prepare data dir %s: %w", rootDataDir, err) + } + os.Setenv("TENEBRA_CONFIG_DIR", rootDataDir) + } + if os.Getenv("TENEBRA_SINGBOX") == "" { + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("socket: locate executable: %w", err) + } + exeDir := filepath.Dir(exe) + if bin := findSingbox(exeDir); bin != "" { + // Which of the candidate locations answered decides which sing-box + // runs and, through ruleSetDir, whether the rule-sets load from disk + // or are downloaded at every connect. Both are worth naming in the + // log: on a machine that can hold several copies (one shipped with + // Tenebra, one from the distribution's own package) "which one" is + // otherwise unanswerable after the fact. + log.Printf("tenebra-core: sing-box at %s", bin) + // Setting the variable rather than the runner field also lights up + // ruleSetDir, which probes the directory this path points into. + os.Setenv("TENEBRA_SINGBOX", bin) + } else { + // Not fatal: the runner repeats the same search and reports + // precisely, at connect time, if it still comes up empty. + log.Printf("tenebra-core: no sing-box in any of: %s", strings.Join(singboxCandidates(exeDir), ", ")) + } + } + return nil +} + +// findSingbox resolves the sing-box binary from the installed layout around the +// core executable, trying the platform's candidate locations in order +// (singboxCandidates). Returns "" when none of them has it, matching the Windows +// resolver's contract — the runner then falls back to its own default and +// reports a miss at connect time. +func findSingbox(exeDir string) string { + for _, p := range singboxCandidates(exeDir) { + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + +// secureDataDir creates dir (with parents) and clamps it to root-owned 0700 — +// the unix analog of the Windows service's DACL clamp, expressed in ownership +// and mode. The clamp is re-applied and then verified on every start, not just +// on creation: the parents of the machine data directory are world-traversable +// and a non-root user could have pre-created the path, so we chown it back to +// root and strip every group/other bit before the store writes credentials into +// it. It is fail-closed: if the directory cannot be made root-only, or a final +// stat still shows the wrong owner or mode, we return an error rather than +// scatter subscription secrets into a directory another user can read. (chown to +// root needs privilege the daemon has in production; configureSocketPathsFor +// only calls this at euid 0, so an unprivileged run never reaches it.) +// +// The clamp is symlink-safe. The world-traversable parent means a non-root user +// could pre-plant a symlink at the data-dir path; a chown/chmod/stat by name +// would then follow it and clamp — and verify — the link's target, not the +// directory we go on to write into, so the anti-squat defence would bless an +// attacker-controlled location. So the path is opened once with O_NOFOLLOW (a +// final symlink makes the open fail outright) and every subsequent step acts on +// that file descriptor: fchown/fchmod/fstat cannot be redirected by a symlink +// swapped in after the open, closing the squat-and-TOCTOU window together. +func secureDataDir(dir string) error { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + // Re-open the directory itself, refusing to traverse a final symlink. If the + // path was pre-planted as a link the open fails here, before any privileged + // clamp touches the target; MkdirAll above is a no-op on an existing entry, + // so it neither creates through nor sanitises such a link — this open is what + // rejects it. + f, err := openDirNoFollow(dir) + if err != nil { + return err + } + defer f.Close() + return clampRootOnlyDir(dir, f) +} + +// openDirNoFollow opens dir for the ownership clamp without following a final +// symlink. O_DIRECTORY additionally fails the open if the (real) target is not a +// directory, so the descriptor handed to clampRootOnlyDir is always a genuine +// directory we can fchown/fchmod/fstat. +func openDirNoFollow(dir string) (*os.File, error) { + f, err := os.OpenFile(dir, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_DIRECTORY, 0) + if err != nil { + return nil, fmt.Errorf("open %s without following symlinks: %w", dir, err) + } + return f, nil +} + +// dirHandle is the slice of *os.File clampRootOnlyDir needs: the descriptor-bound +// chown/chmod/stat that make the clamp immune to a symlink swapped in after the +// open. It is an interface so the fail-closed logic can be exercised without a +// real root-owned directory — fchown(0,0) needs privilege the unit tests lack — +// by substituting a fake that reports the ownership and mode the check must +// accept or reject. +type dirHandle interface { + Chown(uid, gid int) error + Chmod(mode os.FileMode) error + Stat() (os.FileInfo, error) +} + +// clampRootOnlyDir forces the opened directory to root-owned 0700 through its +// descriptor and then reads it back to verify, the fail-closed core shared by +// the live path and the tests. name is only for error context. Every operation +// binds to the handle, never to the path, so no symlink race can redirect them. +func clampRootOnlyDir(name string, h dirHandle) error { + if err := h.Chown(0, 0); err != nil { + return fmt.Errorf("chown %s to root: %w", name, err) + } + if err := h.Chmod(0o700); err != nil { + return fmt.Errorf("chmod %s: %w", name, err) + } + info, err := h.Stat() + if err != nil { + return err + } + if err := verifyRootOnlyDir(info); err != nil { + return fmt.Errorf("%s is not root-only after clamp: %w", name, err) + } + return nil +} + +// verifyRootOnlyDir is the fail-closed check secureDataDir ends on: dir must be +// a directory, owned by root (uid 0), with no group or other access. It is the +// pure predicate over a stat result so the rejection paths can be tested without +// a root-owned directory (a non-root test dir trips the ownership check; a 0777 +// dir trips the mode check). +func verifyRootOnlyDir(info os.FileInfo) error { + if !info.IsDir() { + return errors.New("not a directory") + } + if perm := info.Mode().Perm(); perm&0o077 != 0 { + return fmt.Errorf("mode %#o grants group/other access", perm) + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return errors.New("cannot read ownership") + } + if st.Uid != 0 { + return fmt.Errorf("owned by uid %d, not root", st.Uid) + } + return nil +} diff --git a/cmd/tenebra-core/socket_unix_test.go b/cmd/tenebra-core/socket_unix_test.go new file mode 100644 index 0000000..f0b1dd1 --- /dev/null +++ b/cmd/tenebra-core/socket_unix_test.go @@ -0,0 +1,216 @@ +//go:build darwin || linux + +package main + +import ( + "context" + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/Divaaaan/tenebra/core/control" +) + +// TestSocketPathFrom pins the env mapping the socket transport shares with the +// desktop UI's configured_name: unset/empty is the well-known default, `off`/`0` +// disables the transport, and any other value names an alternate path. +func TestSocketPathFrom(t *testing.T) { + tests := []struct { + name string + value string + wantPath string + wantEnabled bool + }{ + {"unset or empty", "", control.DefaultSocketPath, true}, + {"off disables", "off", "", false}, + {"zero disables", "0", "", false}, + {"custom path", "/tmp/tenebra-alt.sock", "/tmp/tenebra-alt.sock", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotPath, gotEnabled := socketPathFrom(tc.value) + if gotPath != tc.wantPath || gotEnabled != tc.wantEnabled { + t.Errorf("socketPathFrom(%q) = (%q, %v), want (%q, %v)", + tc.value, gotPath, gotEnabled, tc.wantPath, tc.wantEnabled) + } + }) + } +} + +// TestServeSocketDisabled: asking to serve while TENEBRA_SOCKET=off is a caller +// error, not a silent stdio fallback (a LaunchDaemon has no stdio). It must +// refuse before touching the daemon, so a nil daemon is safe here. +func TestServeSocketDisabled(t *testing.T) { + t.Setenv("TENEBRA_SOCKET", "off") + if err := serveSocket(context.Background(), nil); err == nil { + t.Fatal("serveSocket with TENEBRA_SOCKET=off returned nil, want refusal") + } +} + +// TestConfigureSocketPathsNonRootNoOp: a non-root --socket run (the dev path) +// keeps its per-user paths — the machine-scoped store and sing-box pinning is +// for the root LaunchDaemon only. The euid is injected so the no-op is asserted +// regardless of who runs the test, the way elevationHintFor is exercised. +func TestConfigureSocketPathsNonRootNoOp(t *testing.T) { + t.Setenv("TENEBRA_CONFIG_DIR", "") + t.Setenv("TENEBRA_SINGBOX", "") + + if err := configureSocketPathsFor(501); err != nil { + t.Fatalf("configureSocketPathsFor(non-root) = %v, want nil", err) + } + if got := os.Getenv("TENEBRA_CONFIG_DIR"); got != "" { + t.Errorf("non-root run set TENEBRA_CONFIG_DIR = %q, want untouched", got) + } + if got := os.Getenv("TENEBRA_SINGBOX"); got != "" { + t.Errorf("non-root run set TENEBRA_SINGBOX = %q, want untouched", got) + } +} + +// TestSecureDataDirRejectsSymlink proves the anti-squat clamp is symlink-safe: a +// symlink pre-planted at the data-dir path is refused instead of being followed +// so the clamp lands on the link's target. This runs unprivileged — the +// O_NOFOLLOW open fails before any chown to root is attempted, so the rejection +// does not depend on being able to complete the clamp. +func TestSecureDataDirRejectsSymlink(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "target") + if err := os.Mkdir(target, 0o700); err != nil { + t.Fatal(err) + } + link := filepath.Join(base, "data") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if err := secureDataDir(link); err == nil { + t.Fatal("secureDataDir followed a pre-planted symlink, want refusal") + } + // The target must be left as it was — the clamp must not have chmod'd it + // through the link (it started 0700; a dangling clamp attempt would not + // change owner without privilege, but mode is observable here). + info, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0o700 { + t.Errorf("symlink target mode changed to %#o; clamp followed the link", perm) + } +} + +// TestClampRootOnlyDir exercises the descriptor-bound fail-closed core against a +// fake handle, so both the accepting and the refusing paths are covered without +// a real root-owned directory (fchown to root needs privilege the test lacks). +func TestClampRootOnlyDir(t *testing.T) { + t.Run("root-owned 0700 passes", func(t *testing.T) { + h := &fakeDirHandle{info: fakeDirInfo{mode: os.ModeDir | 0o700, uid: 0}} + if err := clampRootOnlyDir("data", h); err != nil { + t.Fatalf("clampRootOnlyDir on a root-owned 0700 dir = %v, want nil", err) + } + }) + + t.Run("wrong owner refused", func(t *testing.T) { + h := &fakeDirHandle{info: fakeDirInfo{mode: os.ModeDir | 0o700, uid: 501}} + if err := clampRootOnlyDir("data", h); err == nil { + t.Error("clampRootOnlyDir accepted a non-root-owned dir, want refusal") + } + }) + + t.Run("wrong mode refused", func(t *testing.T) { + h := &fakeDirHandle{info: fakeDirInfo{mode: os.ModeDir | 0o777, uid: 0}} + if err := clampRootOnlyDir("data", h); err == nil { + t.Error("clampRootOnlyDir accepted a group/other-accessible dir, want refusal") + } + }) + + t.Run("chown failure is fatal", func(t *testing.T) { + h := &fakeDirHandle{chownErr: os.ErrPermission, info: fakeDirInfo{mode: os.ModeDir | 0o700, uid: 0}} + if err := clampRootOnlyDir("data", h); err == nil { + t.Error("clampRootOnlyDir swallowed a failed chown, want refusal") + } + }) +} + +// fakeDirHandle is a dirHandle whose clamp operations are no-ops (optionally +// erroring) and whose Stat reports a caller-chosen FileInfo, standing in for the +// privileged *os.File so clampRootOnlyDir's verdict can be tested unprivileged. +type fakeDirHandle struct { + info os.FileInfo + chownErr error + chmodErr error +} + +func (h *fakeDirHandle) Chown(uid, gid int) error { return h.chownErr } +func (h *fakeDirHandle) Chmod(mode os.FileMode) error { return h.chmodErr } +func (h *fakeDirHandle) Stat() (os.FileInfo, error) { return h.info, nil } + +// fakeDirInfo is the minimal os.FileInfo verifyRootOnlyDir reads: IsDir/Mode from +// mode, and a syscall.Stat_t carrying uid behind Sys() for the ownership check. +type fakeDirInfo struct { + mode os.FileMode + uid uint32 +} + +func (f fakeDirInfo) Name() string { return "data" } +func (f fakeDirInfo) Size() int64 { return 0 } +func (f fakeDirInfo) Mode() os.FileMode { return f.mode } +func (f fakeDirInfo) ModTime() time.Time { return time.Time{} } +func (f fakeDirInfo) IsDir() bool { return f.mode.IsDir() } +func (f fakeDirInfo) Sys() any { return &syscall.Stat_t{Uid: f.uid} } + +// TestVerifyRootOnlyDir exercises the fail-closed predicate secureDataDir ends +// on. The success case (a real root-owned 0700 dir) needs privilege and is +// validated in a live daemon run; here we prove the rejections fire. +func TestVerifyRootOnlyDir(t *testing.T) { + t.Run("loose mode rejected", func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "loose") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + // Mkdir is subject to umask; force the group/other bits on explicitly. + if err := os.Chmod(dir, 0o777); err != nil { + t.Fatal(err) + } + info, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if err := verifyRootOnlyDir(info); err == nil { + t.Error("verifyRootOnlyDir accepted a 0777 dir, want rejection") + } + }) + + t.Run("regular file rejected", func(t *testing.T) { + f := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(f, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + info, err := os.Stat(f) + if err != nil { + t.Fatal(err) + } + if err := verifyRootOnlyDir(info); err == nil { + t.Error("verifyRootOnlyDir accepted a regular file, want rejection") + } + }) + + t.Run("non-root owner rejected", func(t *testing.T) { + // A 0700 dir owned by the test user must fail the ownership check. When + // the suite runs as root that dir is legitimately root-owned and the + // check passes, so the assertion only applies to an unprivileged run. + if os.Geteuid() == 0 { + t.Skip("running as root: a self-owned dir is root-owned, nothing to reject") + } + dir := filepath.Join(t.TempDir(), "tight") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + info, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if err := verifyRootOnlyDir(info); err == nil { + t.Error("verifyRootOnlyDir accepted a non-root 0700 dir, want ownership rejection") + } + }) +} diff --git a/core/buildinfo/buildinfo.go b/core/buildinfo/buildinfo.go index 1267982..4ce7c65 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.4.5" +const Version = "0.4.6" diff --git a/core/control/peer_auth_darwin.go b/core/control/peer_auth_darwin.go index 56a7b5c..9ba5afd 100644 --- a/core/control/peer_auth_darwin.go +++ b/core/control/peer_auth_darwin.go @@ -12,26 +12,6 @@ import ( "golang.org/x/sys/unix" ) -// authorizePeer decides whether the just-accepted control-socket peer may drive -// the daemon. It reads the connecting process's uid from the unix socket and -// runs the shared peerAllowed policy against the console user (see peer_auth.go -// for the trust rationale). A conn whose peer uid can't be read — an in-memory -// test pipe, or a getsockopt failure — is allowed with a log line, matching the -// policy's fail-open stance: the goal is to authenticate, never to brick attach. -func (d *Daemon) authorizePeer(conn net.Conn) bool { - uid, ok := peerCredUID(conn) - if !ok { - // Only reached off the production path (the real listener always hands us - // a unix socket); log at info so it doesn't masquerade as a security event. - d.emitLog(LogInfo, "control: peer uid unavailable on this connection; allowing") - return true - } - self := strconv.Itoa(os.Getuid()) - return peerAllowed(strconv.Itoa(uid), self, consoleUserUID, func(msg string) { - d.emitLog(LogWarn, msg) - }) -} - // peerCredUID reads the connected peer's effective uid from a unix-domain socket // via getsockopt(SOL_LOCAL, LOCAL_PEERCRED), which returns the credentials of // the process on the other end at connect time. It returns ok=false for any conn diff --git a/core/control/peer_auth_linux.go b/core/control/peer_auth_linux.go new file mode 100644 index 0000000..da77cce --- /dev/null +++ b/core/control/peer_auth_linux.go @@ -0,0 +1,232 @@ +//go:build linux + +package control + +import ( + "bufio" + "bytes" + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + + "golang.org/x/sys/unix" +) + +// seatStateDir is where logind publishes one file per seat — a seat being a set +// of input and display devices one person sits at. consoleUserUID reads the +// active session's uid out of it. +const seatStateDir = "/run/systemd/seats" + +// userRuntimeDir is the parent of the per-user runtime directories +// (XDG_RUNTIME_DIR) a session manager creates as /run/user/. It backs the +// fallback consoleUserUID takes when no seat state is published. +const userRuntimeDir = "/run/user" + +// activeSeatUIDKey is the field logind writes into a seat's state file naming +// the uid whose session currently owns that seat's input and display. A seat +// with no active session (nobody logged in at the display) omits it. +const activeSeatUIDKey = "ACTIVE_UID=" + +// peerCredUID reads the connected peer's uid from a unix-domain socket via +// getsockopt(SOL_SOCKET, SO_PEERCRED), which returns the pid/uid/gid the kernel +// recorded for the process on the other end at connect time. The credentials are +// captured by the kernel, not sent by the peer, so they cannot be forged; a +// process that execs into something else after connecting keeps the identity it +// had when it connected, which is the identity we want to judge. It returns +// ok=false for any conn that is not a live unix socket (e.g. a net.Pipe used in +// tests) or when the getsockopt fails, leaving the caller to take the fail-open +// path. +func peerCredUID(conn net.Conn) (int, bool) { + uc, ok := conn.(*net.UnixConn) + if !ok { + return 0, false + } + raw, err := uc.SyscallConn() + if err != nil { + return 0, false + } + var uid int + var innerErr error + ctrlErr := raw.Control(func(fd uintptr) { + cred, e := unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED) + if e != nil { + innerErr = e + return + } + uid = int(cred.Uid) + }) + if ctrlErr != nil || innerErr != nil { + return 0, false + } + return uid, true +} + +// consoleUserUID reports the uid of the user sitting at the machine, as a +// decimal string — the Linux stand-in for the owner of /dev/console on macOS, +// and the identity the peer policy admits besides the daemon's own account. +// +// Linux has no single file the kernel keeps the console owner in, so this reads +// the session manager's runtime state instead, in two steps (see +// consoleUserUIDIn). Both are deliberately file-based: the daemon must not grow +// a D-Bus client, and an OS lookup that needs a session bus is exactly what a +// root service does not have. +// +// The honest limits of the answer: +// +// - It depends on logind (systemd-logind, or elogind, which publishes the +// same runtime layout). A machine running seatd or no session manager at all +// publishes neither source, so the lookup fails and the policy falls open to +// the historical any-local-user trust — no worse than the transport had +// before the check existed, and logged as such by peerAllowed. +// - The seat files carry a "This is private data. Do not parse." header, and +// that warning is taken at face value: they are runtime state with no +// stability guarantee, and this reads them anyway because the sanctioned +// alternative is a D-Bus call to org.freedesktop.login1, which means a bus +// client in a root daemon that has no session bus and a dependency the core +// does not carry. The trade is only acceptable because the failure mode is +// benign — every parse failure returns an error and the policy falls open, +// so a format change degrades the check to what it was before it existed +// rather than misidentifying anyone. +// - Fast user switching leaves the non-active session's GUI unable to attach +// until it is switched back to. That matches the macOS behaviour, where +// /dev/console follows the foreground session. +func consoleUserUID() (string, error) { + return consoleUserUIDIn(seatStateDir, userRuntimeDir) +} + +// consoleUserUIDIn is the injectable core of consoleUserUID: the two runtime +// directories are parameters so the lookup can be tested against fixtures rather +// than the host's live login state. It prefers the seat state, which answers +// "who is at the display right now", and only falls back to the runtime +// directories, which answer the weaker "who has a session at all". +func consoleUserUIDIn(seatDir, runtimeDir string) (string, error) { + uid, seatErr := activeSeatUID(seatDir) + if seatErr == nil { + return uid, nil + } + uid, runtimeErr := soleRuntimeDirUID(runtimeDir) + if runtimeErr == nil { + return uid, nil + } + return "", fmt.Errorf("control: no interactive session found (%v; %v)", seatErr, runtimeErr) +} + +// activeSeatUID reads the uid of the session currently active on a physical +// seat. seat0 is the built-in seat — the keyboard and display attached to the +// machine — which is the one every ordinary desktop logs into, so it is tried +// first by name. A multi-seat host (or a distro that names its seats otherwise) +// falls through to scanning the directory, and refuses to answer when two seats +// name different users: guessing which of two people at one machine owns the +// tunnel is exactly the decision that must not be made silently, and an error +// here means the policy falls open with a warning instead. +func activeSeatUID(seatDir string) (string, error) { + if uid, err := seatFileUID(filepath.Join(seatDir, "seat0")); err == nil { + return uid, nil + } + entries, err := os.ReadDir(seatDir) + if err != nil { + return "", err + } + found := "" + for _, e := range entries { + uid, err := seatFileUID(filepath.Join(seatDir, e.Name())) + if err != nil { + continue + } + if found != "" && found != uid { + return "", fmt.Errorf("control: seats under %s report different active users", seatDir) + } + found = uid + } + if found == "" { + return "", fmt.Errorf("control: no seat under %s has an active session", seatDir) + } + return found, nil +} + +// seatFileUID pulls ACTIVE_UID out of one seat state file. The file is a flat +// list of KEY=VALUE lines; anything else in it is ignored. A file without the +// key describes a seat nobody is logged into, which is an error here rather than +// an empty answer, so the caller keeps looking (and ultimately falls open) — +// never treats "nobody" as an identity that could match a peer. +func seatFileUID(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + sc := bufio.NewScanner(bytes.NewReader(data)) + for sc.Scan() { + value, ok := strings.CutPrefix(strings.TrimSpace(sc.Text()), activeSeatUIDKey) + if !ok { + continue + } + value = strings.TrimSpace(value) + // Only a well-formed uid is an answer: a mangled value must not be + // compared against a peer uid, and returning it as an error keeps the + // policy on its fail-open path rather than silently denying everyone. + if _, err := strconv.ParseUint(value, 10, 32); err != nil { + return "", fmt.Errorf("control: %s has a malformed active uid %q", path, value) + } + return value, nil + } + if err := sc.Err(); err != nil { + return "", err + } + return "", fmt.Errorf("control: %s names no active session", path) +} + +// soleRuntimeDirUID names the owner of the one per-user runtime directory under +// runtimeDir, or fails. It is the fallback for hosts that keep /run/user (every +// pam_systemd or elogind login does) but publish no seat state — a headless-ish +// or unusually configured desktop — and it is deliberately weaker than the seat +// lookup: a runtime directory means "this user has a session somewhere", not +// "this user is at the display", and it lingers for a user with logind linger +// enabled. So it only answers when the answer is unambiguous; two candidates +// mean the caller falls open rather than picking one. +// +// The directories are created by the session manager with runtimeDir itself +// root-owned and 0755, so an unprivileged user cannot plant an entry here to +// nominate themselves as the console user. Ownership is read from the directory +// inode rather than parsed out of its name for the same reason: the name is a +// label, the owner is what the kernel recorded. +// +// The daemon's own account (root) is skipped. A root shell session — an ssh +// login, a `machinectl shell` — creates /run/user/0 and would otherwise make +// every lookup ambiguous on an administered machine, while root is already +// admitted by peerAllowed's self shortcut, so it is never the answer we need. +func soleRuntimeDirUID(runtimeDir string) (string, error) { + entries, err := os.ReadDir(runtimeDir) + if err != nil { + return "", err + } + found := "" + for _, e := range entries { + if !e.IsDir() { + continue + } + info, err := e.Info() + if err != nil { + continue + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok { + continue + } + if st.Uid == 0 { + continue + } + uid := strconv.FormatUint(uint64(st.Uid), 10) + if found != "" && found != uid { + return "", fmt.Errorf("control: %s holds runtime directories for several users", runtimeDir) + } + found = uid + } + if found == "" { + return "", fmt.Errorf("control: no user runtime directory under %s", runtimeDir) + } + return found, nil +} diff --git a/core/control/peer_auth_linux_test.go b/core/control/peer_auth_linux_test.go new file mode 100644 index 0000000..e2dcf93 --- /dev/null +++ b/core/control/peer_auth_linux_test.go @@ -0,0 +1,263 @@ +//go:build linux + +package control + +import ( + "os" + "path/filepath" + "strconv" + "testing" +) + +// writeSeat drops a logind-shaped seat state file (a flat list of KEY=VALUE +// lines) into dir, so the lookup can be driven against fixtures instead of the +// host's live login state. +func writeSeat(t *testing.T, dir, name, body string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// makeRuntimeDir creates parent/ owned by uid, standing in for the +// /run/user/ a session manager creates. Handing a directory to a foreign +// uid needs privilege: the containerised Linux test run is root and exercises +// these cases for real, while an unprivileged run on a developer's desktop can +// only produce directories it owns, so those cases skip rather than assert +// something weaker than they claim. +func makeRuntimeDir(t *testing.T, parent, name string, uid int) { + t.Helper() + dir := filepath.Join(parent, name) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if os.Getuid() == uid { + return + } + if os.Getuid() != 0 { + t.Skipf("need privilege to hand a runtime dir to uid %d (running as %d)", uid, os.Getuid()) + } + if err := os.Chown(dir, uid, uid); err != nil { + t.Fatalf("chown fixture runtime dir to %d: %v", uid, err) + } +} + +// seat0Body is the shape logind writes for the built-in seat with a session +// active on it, header line included: ACTIVE_UID names the user at the display, +// surrounded by fields — and a comment — the lookup must ignore. +const seat0Body = `# This is private data. Do not parse. +IS_SEAT0=1 +CAN_MULTI_SESSION=1 +CAN_TTY=1 +CAN_GRAPHICAL=1 +ACTIVE=2 +ACTIVE_UID=1000 +SESSIONS=2 1 +UIDS=1000 1000 +` + +// TestConsoleUserUIDFromSeat0: the ordinary desktop case — one built-in seat +// with a logged-in user — resolves to that user's uid, without consulting the +// weaker runtime-directory fallback (pointed at a path that does not exist). +func TestConsoleUserUIDFromSeat0(t *testing.T) { + seats := filepath.Join(t.TempDir(), "seats") + writeSeat(t, seats, "seat0", seat0Body) + + got, err := consoleUserUIDIn(seats, filepath.Join(t.TempDir(), "absent")) + if err != nil { + t.Fatalf("consoleUserUIDIn: %v", err) + } + if got != "1000" { + t.Errorf("console uid = %q, want %q", got, "1000") + } +} + +// TestConsoleUserUIDSeatWithoutActiveSession: a seat file for a display nobody +// is logged into carries no ACTIVE_UID. That must read as "unknown" — an error +// the policy turns into a fail-open — never as an identity, which an empty peer +// uid could then match. +func TestConsoleUserUIDSeatWithoutActiveSession(t *testing.T) { + seats := filepath.Join(t.TempDir(), "seats") + writeSeat(t, seats, "seat0", "IS_SEAT0=1\nCAN_GRAPHICAL=1\n") + + if got, err := consoleUserUIDIn(seats, filepath.Join(t.TempDir(), "absent")); err == nil { + t.Errorf("console uid = %q, want an error for a seat with no active session", got) + } +} + +// TestConsoleUserUIDNonSeat0: a host whose seat is not named seat0 (a multi-seat +// or unusually configured machine) is still resolved, by the directory scan +// behind the seat0 fast path. +func TestConsoleUserUIDNonSeat0(t *testing.T) { + seats := filepath.Join(t.TempDir(), "seats") + writeSeat(t, seats, "seat-virtual", "CAN_GRAPHICAL=1\nACTIVE=7\nACTIVE_UID=1234\n") + + got, err := consoleUserUIDIn(seats, filepath.Join(t.TempDir(), "absent")) + if err != nil { + t.Fatalf("consoleUserUIDIn: %v", err) + } + if got != "1234" { + t.Errorf("console uid = %q, want %q", got, "1234") + } +} + +// TestConsoleUserUIDAmbiguousSeatsRefuse: two seats with two different users at +// the machine is exactly the case the lookup must not guess at. It reports the +// ambiguity, and peerAllowed turns that into a logged fail-open rather than +// picking one of the two and locking the other's GUI out. +func TestConsoleUserUIDAmbiguousSeatsRefuse(t *testing.T) { + seats := filepath.Join(t.TempDir(), "seats") + writeSeat(t, seats, "seat-a", "ACTIVE_UID=1000\n") + writeSeat(t, seats, "seat-b", "ACTIVE_UID=1001\n") + + if got, err := consoleUserUIDIn(seats, filepath.Join(t.TempDir(), "absent")); err == nil { + t.Errorf("console uid = %q, want a refusal when two seats disagree", got) + } +} + +// TestConsoleUserUIDMalformedActiveUID: a value that is not a uid must fail the +// lookup rather than be carried on as an opaque string and compared against a +// peer uid. +func TestConsoleUserUIDMalformedActiveUID(t *testing.T) { + seats := filepath.Join(t.TempDir(), "seats") + writeSeat(t, seats, "seat0", "ACTIVE_UID=someone\n") + + if got, err := consoleUserUIDIn(seats, filepath.Join(t.TempDir(), "absent")); err == nil { + t.Errorf("console uid = %q, want a refusal for a malformed ACTIVE_UID", got) + } +} + +// TestConsoleUserUIDRuntimeDirFallback: with no seat state published (a host +// without logind's seat files) the sole per-user runtime directory answers. +func TestConsoleUserUIDRuntimeDirFallback(t *testing.T) { + runtimeDir := filepath.Join(t.TempDir(), "user") + makeRuntimeDir(t, runtimeDir, "1000", 1000) + + got, err := consoleUserUIDIn(filepath.Join(t.TempDir(), "absent-seats"), runtimeDir) + if err != nil { + t.Fatalf("consoleUserUIDIn: %v", err) + } + if got != "1000" { + t.Errorf("console uid = %q, want %q (owner of the sole runtime dir)", got, "1000") + } +} + +// TestConsoleUserUIDRuntimeDirTrustsOwnerNotName: the answer comes from the +// directory's owner, not from its name, so a directory labelled with someone +// else's uid cannot nominate that uid as the console user. +func TestConsoleUserUIDRuntimeDirTrustsOwnerNotName(t *testing.T) { + runtimeDir := filepath.Join(t.TempDir(), "user") + makeRuntimeDir(t, runtimeDir, "4242", 1000) + + got, err := consoleUserUIDIn(filepath.Join(t.TempDir(), "absent-seats"), runtimeDir) + if err != nil { + t.Fatalf("consoleUserUIDIn: %v", err) + } + if got != "1000" { + t.Errorf("console uid = %q, want %q — the owner, not the directory name", got, "1000") + } +} + +// TestConsoleUserUIDSkipsRootRuntimeDir: an administered machine where root also +// holds a session (/run/user/0 from an ssh login) must still resolve the desktop +// user. Root is admitted by peerAllowed's self shortcut anyway, so counting it +// here would only make the lookup ambiguous and fail open on every such host. +func TestConsoleUserUIDSkipsRootRuntimeDir(t *testing.T) { + runtimeDir := filepath.Join(t.TempDir(), "user") + makeRuntimeDir(t, runtimeDir, "0", 0) + makeRuntimeDir(t, runtimeDir, "1001", 1001) + + got, err := consoleUserUIDIn(filepath.Join(t.TempDir(), "absent-seats"), runtimeDir) + if err != nil { + t.Fatalf("consoleUserUIDIn: %v", err) + } + if got != "1001" { + t.Errorf("console uid = %q, want %q (root's runtime dir must not count)", got, "1001") + } +} + +// TestConsoleUserUIDAmbiguousRuntimeDirsRefuse: two users with live sessions and +// no seat state is not an identification — the fallback declines instead of +// picking the first one it read. +func TestConsoleUserUIDAmbiguousRuntimeDirsRefuse(t *testing.T) { + runtimeDir := filepath.Join(t.TempDir(), "user") + makeRuntimeDir(t, runtimeDir, "1000", 1000) + makeRuntimeDir(t, runtimeDir, "1001", 1001) + + if got, err := consoleUserUIDIn(filepath.Join(t.TempDir(), "absent-seats"), runtimeDir); err == nil { + t.Errorf("console uid = %q, want a refusal when two users hold runtime dirs", got) + } +} + +// TestConsoleUserUIDNoSourcesFailsOpen: a machine publishing neither source (no +// logind at all) yields an error, which peerAllowed turns into the documented +// fail-open — the historical any-local-user trust, logged rather than silent. +func TestConsoleUserUIDNoSourcesFailsOpen(t *testing.T) { + base := t.TempDir() + lookup := func() (string, error) { + return consoleUserUIDIn(filepath.Join(base, "no-seats"), filepath.Join(base, "no-run-user")) + } + if _, err := lookup(); err == nil { + t.Fatal("consoleUserUIDIn with neither source present returned no error") + } + + warned := false + if !peerAllowed("1000", "0", lookup, func(string) { warned = true }) { + t.Error("an undeterminable console user must fail open, not lock the GUI out") + } + if !warned { + t.Error("the fail-open must be warned about") + } +} + +// TestConsoleUserUIDSeatWinsOverRuntimeDir: when both sources are present the +// seat state decides. It answers "who is at the display", which is the question; +// a runtime directory only proves a session exists somewhere. +func TestConsoleUserUIDSeatWinsOverRuntimeDir(t *testing.T) { + seats := filepath.Join(t.TempDir(), "seats") + writeSeat(t, seats, "seat0", seat0Body) + runtimeDir := filepath.Join(t.TempDir(), "user") + makeRuntimeDir(t, runtimeDir, "1001", 1001) + + got, err := consoleUserUIDIn(seats, runtimeDir) + if err != nil { + t.Fatalf("consoleUserUIDIn: %v", err) + } + if got != "1000" { + t.Errorf("console uid = %q, want %q from the seat state", got, "1000") + } +} + +// TestDefaultSocketPathIsRunTenebra pins the Linux control-socket path. It is a +// contract with the desktop shell, which dials the same literal string, so a +// change here silently breaks every GUI attach and must be a deliberate edit on +// both sides. +func TestDefaultSocketPathIsRunTenebra(t *testing.T) { + if DefaultSocketPath != "/run/tenebra.sock" { + t.Errorf("DefaultSocketPath = %q, want /run/tenebra.sock (the path the desktop shell dials)", DefaultSocketPath) + } +} + +// TestPeerAllowedRejectsOtherLocalUserOnLinux ties the Linux lookup to the +// policy: with a determinable console user, another local account is turned +// away. strconv is used the way authorizePeer does, so the comparison under +// test is the production one — decimal uid strings on both sides. +func TestPeerAllowedRejectsOtherLocalUserOnLinux(t *testing.T) { + seats := filepath.Join(t.TempDir(), "seats") + writeSeat(t, seats, "seat0", seat0Body) + lookup := func() (string, error) { + return consoleUserUIDIn(seats, filepath.Join(t.TempDir(), "absent")) + } + + if peerAllowed(strconv.Itoa(1001), "0", lookup, func(string) { + t.Error("a decisive deny must not warn (that channel means fail-open)") + }) { + t.Error("a local user who is neither root nor the seat's active user must be denied") + } + if !peerAllowed(strconv.Itoa(1000), "0", lookup, func(msg string) { t.Errorf("unexpected fail-open: %s", msg) }) { + t.Error("the seat's active user must be admitted") + } +} diff --git a/core/control/peer_auth_other.go b/core/control/peer_auth_other.go index 9a1fc55..66c8a50 100644 --- a/core/control/peer_auth_other.go +++ b/core/control/peer_auth_other.go @@ -1,4 +1,4 @@ -//go:build !darwin && !windows +//go:build !darwin && !linux && !windows package control @@ -7,9 +7,10 @@ import "net" // authorizePeer is the fallback for platforms with no supported detached-daemon // transport: there is no ListenSocket/ListenPipe here, so nothing ever binds a // world-reachable control channel and there is no peer to authenticate. It -// allows unconditionally, keeping ServeListener buildable everywhere (the cross -// -compiled linux build in CI needs this) without pulling in an OS credential -// API that doesn't exist on the platform. +// allows unconditionally, keeping ServeListener buildable everywhere without +// pulling in an OS credential API that doesn't exist on the platform. The three +// targets that do bind one — macOS and Linux over a unix socket, Windows over +// the named pipe — each carry a real check instead. func (d *Daemon) authorizePeer(conn net.Conn) bool { return true } diff --git a/core/control/peer_auth_unix.go b/core/control/peer_auth_unix.go new file mode 100644 index 0000000..8a6d2c9 --- /dev/null +++ b/core/control/peer_auth_unix.go @@ -0,0 +1,35 @@ +//go:build darwin || linux + +package control + +import ( + "net" + "os" + "strconv" +) + +// authorizePeer decides whether the just-accepted control-socket peer may drive +// the daemon. It reads the connecting process's uid from the unix socket and +// runs the shared peerAllowed policy against the console user (see peer_auth.go +// for the trust rationale). A conn whose peer uid can't be read — an in-memory +// test pipe, or a getsockopt failure — is allowed with a log line, matching the +// policy's fail-open stance: the goal is to authenticate, never to brick attach. +// +// Both halves it leans on are per-platform: peerCredUID reads the credentials +// the kernel attached to the socket (LOCAL_PEERCRED on macOS, SO_PEERCRED on +// Linux) and consoleUserUID names the account the interactive session belongs +// to. The decision itself is identical on the two, so it lives here rather than +// being copied into each. +func (d *Daemon) authorizePeer(conn net.Conn) bool { + uid, ok := peerCredUID(conn) + if !ok { + // Only reached off the production path (the real listener always hands us + // a unix socket); log at info so it doesn't masquerade as a security event. + d.emitLog(LogInfo, "control: peer uid unavailable on this connection; allowing") + return true + } + self := strconv.Itoa(os.Getuid()) + return peerAllowed(strconv.Itoa(uid), self, consoleUserUID, func(msg string) { + d.emitLog(LogWarn, msg) + }) +} diff --git a/core/control/peer_auth_darwin_test.go b/core/control/peer_auth_unix_test.go similarity index 88% rename from core/control/peer_auth_darwin_test.go rename to core/control/peer_auth_unix_test.go index a4dacc6..4e27eec 100644 --- a/core/control/peer_auth_darwin_test.go +++ b/core/control/peer_auth_unix_test.go @@ -1,4 +1,4 @@ -//go:build darwin +//go:build darwin || linux package control @@ -11,10 +11,10 @@ import ( "time" ) -// shortSocketPath returns a unix-socket path short enough to satisfy macOS's -// ~104-byte sun_path limit — t.TempDir() embeds the (long) test name and can -// overflow it, so a bind there fails with EINVAL. The dir is cleaned up with the -// test. +// shortSocketPath returns a unix-socket path short enough to satisfy the +// sun_path limit (~104 bytes on macOS, 108 on Linux) — t.TempDir() embeds the +// (long) test name and can overflow it, so a bind there fails with EINVAL. The +// dir is cleaned up with the test. func shortSocketPath(t *testing.T) string { t.Helper() dir, err := os.MkdirTemp("", "tnb") @@ -25,7 +25,8 @@ func shortSocketPath(t *testing.T) string { return filepath.Join(dir, "s") } -// TestPeerCredUIDOverUnixSocket drives the real LOCAL_PEERCRED read: it binds a +// TestPeerCredUIDOverUnixSocket drives the real peer-credential read — the +// platform's own syscall, LOCAL_PEERCRED on macOS and SO_PEERCRED on Linux: it binds a // unix socket, dials it, and asserts peerCredUID reports the connecting // process's uid — which, for a client dialled from this same test process, is // our own uid. This is the syscall the production policy relies on to identify a diff --git a/core/control/proxy_other.go b/core/control/proxy_other.go index c517717..c9c1625 100644 --- a/core/control/proxy_other.go +++ b/core/control/proxy_other.go @@ -5,9 +5,13 @@ package control import "errors" // errSystemProxyUnsupported is returned by the system-proxy ops on platforms -// without an implementation (the Linux CI build). The daemon degrades gracefully: -// arming logs this and stays disarmed, so system-proxy mode simply doesn't take -// effect rather than crashing the core. +// without an implementation. Linux is the live case: pointing the desktop at a +// 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. var errSystemProxyUnsupported = errors.New("control: system proxy is not supported on this platform") func enableSystemProxy(string) error { return errSystemProxyUnsupported } diff --git a/core/control/socket_darwin.go b/core/control/socket_darwin.go index 6a87c3a..937775f 100644 --- a/core/control/socket_darwin.go +++ b/core/control/socket_darwin.go @@ -2,87 +2,11 @@ package control -import ( - "errors" - "fmt" - "net" - "os" - "time" -) - // DefaultSocketPath is the unix domain socket the control protocol is served on // when the core runs detached from the UI on macOS — as a root LaunchDaemon, or // via `tenebra-core --socket` from a shell for development. It is the darwin // analog of PipeName: one well-known path the UI discovers the core by dialling. // It lives under /var/run (root-owned) because the daemon that binds it is root; -// the bind is opened up to every local user by the 0666 clamp in ListenSocket. +// the bind is opened up to every local user by the 0666 clamp in ListenSocket +// and narrowed again, per connection, by the peer check in authorizePeer. const DefaultSocketPath = "/var/run/tenebra.sock" - -// socketDialProbe bounds the "is a live core already here?" dial in ListenSocket. -// A local connect() to a bound socket returns at once, and to a dead or non- -// socket path it fails at once (ECONNREFUSED / ENOTSOCK), so this only caps a -// pathological stall rather than being hit in the normal case. -const socketDialProbe = 2 * time.Second - -// ListenSocket opens the unix domain socket listener the control protocol is -// served on. path is DefaultSocketPath in production; tests pass a path in a -// temp dir so a run never touches /var/run. The returned listener plugs into -// ServeListener, and removes the socket file when it is closed (a clean -// shutdown), so the path is free for the next start. -// -// Bring-up is guarded against two hazards a named pipe gets from the OS for -// free but a bind-to-a-filesystem-path does not: -// -// - A leftover file at path. Unlike the pipe's FILE_FLAG_FIRST_PIPE_INSTANCE, -// bind(2) refuses a path that already exists with EADDRINUSE whether or not -// anyone is serving it — so a crash that skipped cleanup would wedge every -// later start. We disambiguate by dialling first: if something answers, -// another core owns the tunnel and we must not steal it (refuse loudly, the -// spirit of the pipe's first-instance claim); if the dial fails the file is -// stale, so we unlink it and bind. This is best-effort against an honest -// double-start, not a lock against a hostile racer — two roots racing the -// unlink is out of scope, as it is for the pipe. -// - Default permissions. bind honors the umask, which typically leaves the -// socket rwx only for its owner (root), locking every unprivileged GUI out. -// We chmod it to 0666 so any local user's GUI can attach — the unix- -// permission analog of the pipe DACL's generic-read/write grant to -// Interactive Users, and the same trust statement: control of a machine-wide -// tunnel is shared with every local user (see docs/control-protocol.md). -func ListenSocket(path string) (net.Listener, error) { - if _, err := os.Stat(path); err == nil { - conn, derr := net.DialTimeout("unix", path, socketDialProbe) - if derr == nil { - // Someone is serving here right now. Leave their socket alone. - _ = conn.Close() - return nil, fmt.Errorf("control: another tenebra-core is already serving on %s", path) - } - // The file is there but nothing answers: a crashed core left it behind. - // Clear it so the bind below can claim the path. A concurrent cleanup - // that already removed it (the file raced out between the stat and here) - // is fine — the goal state is "gone", and bind will create it fresh. - if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { - return nil, fmt.Errorf("control: remove stale socket %s: %w", path, err) - } - } - - addr, err := net.ResolveUnixAddr("unix", path) - if err != nil { - return nil, fmt.Errorf("control: resolve %s: %w", path, err) - } - l, err := net.ListenUnix("unix", addr) - if err != nil { - return nil, fmt.Errorf("control: listen on %s: %w", path, err) - } - // Removing the file on Close is the default for a listener that created it; - // state it so a clean shutdown is guaranteed to free the path, not left to - // an implementation detail. - l.SetUnlinkOnClose(true) - - if err := os.Chmod(path, 0o666); err != nil { - // A socket only root can reach is useless to the GUI; fail rather than - // serve a control surface no client can open. Close unlinks the file. - _ = l.Close() - return nil, fmt.Errorf("control: chmod %s: %w", path, err) - } - return l, nil -} diff --git a/core/control/socket_linux.go b/core/control/socket_linux.go new file mode 100644 index 0000000..7f47eac --- /dev/null +++ b/core/control/socket_linux.go @@ -0,0 +1,23 @@ +//go:build linux + +package control + +// DefaultSocketPath is the unix domain socket the control protocol is served on +// when the core runs detached from the UI on Linux — as the system service that +// owns the tunnel, or via `tenebra-core --socket` from a shell for development. +// It is the Linux analog of PipeName: one well-known path the UI discovers the +// core by dialling, so the desktop shell can hard-code the same string. +// +// /run is the FHS home for volatile runtime state: root-owned, a tmpfs on every +// modern distribution, and cleared on boot — which means a socket file left +// behind by a hard kill never survives a reboot (ListenSocket also clears a +// stale one within a boot). /var/run is a compatibility symlink onto it, so the +// canonical spelling is the one used here; macOS keeps the /var/run form +// because that is the real directory there. +// +// The daemon that binds this path runs as root — opening /dev/net/tun and +// installing auto_route's routing rules needs CAP_NET_ADMIN — while the GUI +// that drives it does not. The bind is therefore opened up to every local user +// by the 0666 clamp in ListenSocket and narrowed again, per connection, by the +// SO_PEERCRED check in authorizePeer. +const DefaultSocketPath = "/run/tenebra.sock" diff --git a/core/control/socket_unix.go b/core/control/socket_unix.go new file mode 100644 index 0000000..10b801c --- /dev/null +++ b/core/control/socket_unix.go @@ -0,0 +1,84 @@ +//go:build darwin || linux + +package control + +import ( + "errors" + "fmt" + "net" + "os" + "time" +) + +// socketDialProbe bounds the "is a live core already here?" dial in ListenSocket. +// A local connect() to a bound socket returns at once, and to a dead or non- +// socket path it fails at once (ECONNREFUSED / ENOTSOCK), so this only caps a +// pathological stall rather than being hit in the normal case. +const socketDialProbe = 2 * time.Second + +// ListenSocket opens the unix domain socket listener the control protocol is +// served on. path is DefaultSocketPath in production — the per-platform +// well-known path the UI discovers the core by dialling (see socket_darwin.go +// and socket_linux.go); tests pass a path in a temp dir so a run never touches +// the production location. The returned listener plugs into ServeListener, and +// removes the socket file when it is closed (a clean shutdown), so the path is +// free for the next start. +// +// Bring-up is guarded against two hazards a named pipe gets from the OS for +// free but a bind-to-a-filesystem-path does not: +// +// - A leftover file at path. Unlike the pipe's FILE_FLAG_FIRST_PIPE_INSTANCE, +// bind(2) refuses a path that already exists with EADDRINUSE whether or not +// anyone is serving it — so a crash that skipped cleanup would wedge every +// later start. We disambiguate by dialling first: if something answers, +// another core owns the tunnel and we must not steal it (refuse loudly, the +// spirit of the pipe's first-instance claim); if the dial fails the file is +// stale, so we unlink it and bind. This is best-effort against an honest +// double-start, not a lock against a hostile racer — two roots racing the +// unlink is out of scope, as it is for the pipe. +// - Default permissions. bind honors the umask, which typically leaves the +// socket rwx only for its owner (root), locking every unprivileged GUI out. +// We chmod it to 0666 so any local user's GUI can attach — the unix- +// permission analog of the pipe DACL's generic-read/write grant to +// Interactive Users. Reaching the socket is not the same as being admitted: +// every accepted connection is then authenticated by peer credentials +// (authorizePeer), which narrows the caller set to the console user and the +// daemon's own account. See docs/control-protocol.md. +func ListenSocket(path string) (net.Listener, error) { + if _, err := os.Stat(path); err == nil { + conn, derr := net.DialTimeout("unix", path, socketDialProbe) + if derr == nil { + // Someone is serving here right now. Leave their socket alone. + _ = conn.Close() + return nil, fmt.Errorf("control: another tenebra-core is already serving on %s", path) + } + // The file is there but nothing answers: a crashed core left it behind. + // Clear it so the bind below can claim the path. A concurrent cleanup + // that already removed it (the file raced out between the stat and here) + // is fine — the goal state is "gone", and bind will create it fresh. + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("control: remove stale socket %s: %w", path, err) + } + } + + addr, err := net.ResolveUnixAddr("unix", path) + if err != nil { + return nil, fmt.Errorf("control: resolve %s: %w", path, err) + } + l, err := net.ListenUnix("unix", addr) + if err != nil { + return nil, fmt.Errorf("control: listen on %s: %w", path, err) + } + // Removing the file on Close is the default for a listener that created it; + // state it so a clean shutdown is guaranteed to free the path, not left to + // an implementation detail. + l.SetUnlinkOnClose(true) + + if err := os.Chmod(path, 0o666); err != nil { + // A socket only root can reach is useless to the GUI; fail rather than + // serve a control surface no client can open. Close unlinks the file. + _ = l.Close() + return nil, fmt.Errorf("control: chmod %s: %w", path, err) + } + return l, nil +} diff --git a/core/control/socket_darwin_test.go b/core/control/socket_unix_test.go similarity index 90% rename from core/control/socket_darwin_test.go rename to core/control/socket_unix_test.go index 1c20a58..3b401ba 100644 --- a/core/control/socket_darwin_test.go +++ b/core/control/socket_unix_test.go @@ -1,4 +1,4 @@ -//go:build darwin +//go:build darwin || linux package control @@ -13,11 +13,11 @@ import ( "time" ) -// tempSocketPath returns a short unix socket path under /tmp. macOS caps the -// socket path (sun_path) at 104 bytes, and the per-test TMPDIR under -// /var/folders is long enough to blow that once a socket name is appended; /tmp -// is short and always present. Never /var/run — a test must not touch the -// production path. +// tempSocketPath returns a short unix socket path under /tmp. sun_path is capped +// at 104 bytes on macOS (108 on Linux), and the per-test TMPDIR — under +// /var/folders on macOS — is long enough to blow that once a socket name is +// appended; /tmp is short and always present on both. Never the production +// DefaultSocketPath: a test must not touch the path a live daemon binds. func tempSocketPath(t *testing.T) string { t.Helper() dir, err := os.MkdirTemp("/tmp", "tnb-sock") @@ -30,7 +30,7 @@ func tempSocketPath(t *testing.T) string { // socketHarness runs ServeListener over a real unix domain socket, mirroring the // pipe harness so the shared session semantics (round-trip, takeover, EOF, -// shutdown) are exercised on the darwin transport too. The session-behavior +// shutdown) are exercised on the unix transport too. The session-behavior // coverage itself lives in listener_test.go; these tests focus on the socket // bring-up hygiene ListenSocket adds. type socketHarness struct { @@ -166,7 +166,7 @@ func TestSocketStaleSocketCleaned(t *testing.T) { // TestSocketLiveNotStolen: a second bring-up on a socket someone is already // serving is refused — the dial probe answers, so the file is not stale and the -// running core keeps its tunnel. This is the darwin stand-in for the pipe's +// running core keeps its tunnel. This is the unix stand-in for the pipe's // first-instance claim (TestPipeNameCannotBeSquatted). func TestSocketLiveNotStolen(t *testing.T) { path := tempSocketPath(t) diff --git a/deploy/linux/tenebra.desktop b/deploy/linux/tenebra.desktop new file mode 100644 index 0000000..1548bee --- /dev/null +++ b/deploy/linux/tenebra.desktop @@ -0,0 +1,26 @@ +# The concrete desktop entry for installs that place files themselves — the Arch +# package in packaging/arch. The .deb and the AppImage do not use it: Tauri's +# bundler writes their entry from the handlebars template in +# ui-desktop/src-tauri/linux/tenebra.desktop, filling the fields in from the +# bundle config. Keep the two in step when either changes — note the bundler +# takes Categories from `bundle.category` in tauri.conf.json, which is the +# generic "Utility"; a VPN client belongs under Network, so aligning the two +# means changing that config rather than this file. +[Desktop Entry] +Type=Application +Name=Tenebra +GenericName=VPN Client +Comment=VPN client built on sing-box +# The GUI is installed as a plain `tenebra` command; %u carries a tenebra:// URL +# through when the entry is launched as a scheme handler. +Exec=tenebra %u +Icon=tenebra +Terminal=false +# One main category on purpose. Security would have to be paired with System or +# Settings to satisfy the spec, and two main categories make the entry show up +# twice in some menus — Network is where a VPN client belongs anyway. +Categories=Network; +Keywords=VPN;proxy;sing-box; +# Registers the app as the handler for tenebra:// deep links — importing a +# subscription or connecting a profile from a link goes through this line. +MimeType=x-scheme-handler/tenebra; diff --git a/deploy/linux/tenebra.service b/deploy/linux/tenebra.service new file mode 100644 index 0000000..349f81e --- /dev/null +++ b/deploy/linux/tenebra.service @@ -0,0 +1,208 @@ +# systemd unit for tenebra-core, the privileged service that owns the tun device +# on Linux. It is installed to /etc/systemd/system and enabled by +# scripts/linux/install-daemon.sh. This is the Linux counterpart of +# deploy/macos/com.tenebra.core.plist and of the `tenebra` Windows service: one +# root process holds the tunnel and serves the control protocol on a unix +# socket, and an unprivileged GUI attaches to it. See docs/porting/linux.md. +# +# This is the single copy of the unit, and it is written for the hand-install +# prefix: every path below must match what scripts/linux/install-daemon.sh writes +# to, and that script refuses to install a unit whose paths have drifted from its +# own. packaging/arch/PKGBUILD reuses this same file and rewrites the +# /usr/local/lib/tenebra prefix to /usr/lib/tenebra, the half of the hierarchy a +# package owns — so keep the prefix spelled consistently rather than mixing forms. + +[Unit] +Description=Tenebra VPN core +Documentation=https://github.com/Divaaaan/tenebra/blob/main/docs/porting/linux.md + +# Ordering mirrors wg-quick's, the closest analog — a route-owning VPN unit +# wants a configured link before it starts. The core fires autoconnect once at +# startup, so coming up before the link exists would turn an armed autoconnect +# into a failed one. nss-lookup.target keeps resolver setup ahead of the first +# subscription refresh. The cost is the usual one: on a host where +# network-online.target genuinely waits, boot reaches this unit later. +Wants=network-online.target +After=network-online.target nss-lookup.target +# systemd-resolved, where it exists, owns the stub resolver the tunnel's DNS has +# to coexist with; starting after it keeps that ordering deterministic. An +# After= on a unit this system does not have is simply ignored. +After=systemd-resolved.service + +# Never give up restarting. The rate limit that would otherwise put the unit into +# a permanent failed state after a few quick restarts is disabled here (it is a +# [Unit] setting, not a [Service] one) because a VPN daemon that has quietly +# stopped trying is worse than one that keeps trying — the restart floor in +# [Service] is what keeps a hard-failing binary from spinning. +StartLimitIntervalSec=0 + +[Service] +# Plain forking-free process: the core does not implement sd_notify, so systemd +# considers the service started as soon as it is exec'd. The control socket +# appearing is the real readiness signal, and install-daemon.sh waits on it. +Type=simple + +# The core serves the control protocol on /run/tenebra.sock instead of +# stdin/stdout, which a service has no useful pair of. It lives under +# /usr/local/lib — the FHS home for locally installed, machine-specific program +# files — beside the sing-box it drives. +ExecStart=/usr/local/lib/tenebra/tenebra-core --socket + +# Point the core at the sing-box installed beside it. This also drives local +# rule-set loading: the core looks for the .srs rule-sets in the directory of +# TENEBRA_SINGBOX, and with it unset they fall back to a slow remote download at +# connect time (see ruleSetDir in cmd/tenebra-core). +# +# TENEBRA_CONFIG_DIR is deliberately NOT set here. Running as root the core pins +# its own machine-scoped store at /var/lib/tenebra/data and clamps it to +# root-owned 0700 — but only when the variable is unset, since an operator-set +# value is honoured as an override everywhere else. Setting it here would look +# tidy and quietly skip that clamp, so the store's protection is left to the +# code that enforces it. +Environment=TENEBRA_SINGBOX=/usr/local/lib/tenebra/sing-box + +# The machine-scoped store, created before the service starts and owned by root. +# 0700 matches the mode the core itself enforces on it; profiles hold +# subscription credentials, which unprivileged users reach through the socket +# protocol, never through the files. +StateDirectory=tenebra/data +StateDirectoryMode=0700 +# sing-box writes its cache database relative to the working directory, so give +# it the state directory rather than letting the file land in /. +WorkingDirectory=/var/lib/tenebra/data + +# Restart on every exit, the process-supervision backstop beneath the kill +# switch: the core relaunches a dead sing-box on its own, but nothing inside the +# core can put the core back if the core itself crashes. systemd outlives both +# and re-establishes the tunnel and its strict_route filter on the next start. +# The 5s floor keeps a hard-failing binary from spinning; together with the +# disabled start-limit in [Unit] this matches launchd's KeepAlive on macOS. +Restart=always +RestartSec=5 + +# Tearing down auto_route leaves routes and routing rules to unwind, so give the +# core room to exit cleanly on SIGTERM before systemd escalates to SIGKILL; a +# kill mid-teardown can strand routes pointing at a dead interface. The default +# control-group kill mode is what stops the sing-box child along with it. +TimeoutStopSec=20 + +# Both streams go to the journal — the platform's own log, so there is no +# service.log to rotate the way the macOS LaunchDaemon needs. Read it with +# `journalctl -u tenebra -f`. +SyslogIdentifier=tenebra + +# A proxy multiplexes a lot of sockets; the default 1024 soft limit is the wrong +# order of magnitude for one, and running out of descriptors surfaces as opaque +# connection failures rather than a clear error. +LimitNOFILE=infinity + +# --- Sandboxing ------------------------------------------------------------- +# +# The daemon runs as root because opening /dev/net/tun and programming routes +# require it, so the sandbox below is about shrinking what that root process can +# reach beyond the tunnel — not about dropping to an unprivileged user, which is +# not possible for this workload. Every knob here was chosen against one rule: +# nothing may interfere with the tun device, routing, or DNS. +# +# Deliberately NOT set, because each of these would break the tunnel: +# PrivateDevices= replaces /dev with a minimal set that has no +# /dev/net/tun, so the tunnel could not be opened at all. +# ProtectKernelTunables= policy routing for a tun device is configured partly +# through /proc/sys — reverse-path filtering above all — +# which this would mount read-only. +# ProtectProc=, ProcSubset= per-app split tunnelling matches traffic by +# reading other processes' /proc entries; an invisible +# /proc silently stops matching anything. +# PrivateNetwork=, RestrictNetworkInterfaces=, IPAddressDeny= a VPN client +# creates its own interface and dials arbitrary endpoints. +# ProtectKernelModules= would only add a redundant seccomp layer: CAP_SYS_MODULE +# is already outside the bounding set below, so this +# process cannot load a module either way, and leaving it +# off keeps the tun autoload path easy to reason about. + +# No setuid or setgid helper is ever exec'd — sing-box is a plain binary — so +# nothing here can gain privileges the unit did not start with. +NoNewPrivileges=yes + +# The capabilities a running tunnel actually needs, and nothing else. Everything +# outside this set is unavailable to the core and to the sing-box it spawns — +# no CAP_SYS_ADMIN, no CAP_SYS_MODULE, no CAP_SYS_BOOT, no CAP_DAC_OVERRIDE — so +# a compromised tunnel process stops well short of the root it nominally runs as. +# CAP_NET_ADMIN open /dev/net/tun, and install auto_route's routes, +# routing rules and nftables sets. +# CAP_NET_RAW bind sockets to a specific interface (SO_BINDTODEVICE), +# which auto-detect-interface relies on, plus ICMP probes. +# CAP_NET_BIND_SERVICE bind a privileged port when a config puts DNS on 53. +# CAP_SYS_PTRACE resolve another process's executable through /proc for +# CAP_DAC_READ_SEARCH per-app split tunnelling; the same pair upstream +# sing-box's own unit carries for process matching. +# CAP_CHOWN re-assert root ownership of the data directory on every +# start, the anti-squat clamp the core performs itself. +# The last three are the broad ones and are here on purpose: DAC_READ_SEARCH does +# bypass read permission checks, and CHOWN can retitle any file. They buy process +# matching and the store clamp respectively, and dropping them would trade a +# working feature for a capability a root process could reacquire no other way. +CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH CAP_CHOWN + +# Device access: the standard pseudo-devices (/dev/null, /dev/zero, /dev/random, +# /dev/urandom, /dev/tty, ...) stay reachable under `closed`, everything else is +# denied, and /dev/net/tun is added back because it is the tunnel. Note this is +# the cgroup device filter, not a private /dev — the node keeps its real +# identity, which is exactly why PrivateDevices= is not used above. +DevicePolicy=closed +DeviceAllow=/dev/net/tun rw + +# /usr, /boot, /efi and /etc read-only. /run and /var stay writable, which they +# must: the control socket is bound at /run/tenebra.sock and the store lives +# under /var/lib. ProtectSystem=strict is not used for the same reason — it +# would take /run with it. Nothing in the tunnel path writes below /etc: unlike +# wg-quick, sing-box installs DNS by routing it into the tunnel rather than by +# rewriting resolv.conf. If that ever changes, relax this to `yes` rather than +# punching a ReadWritePaths= hole into /etc. +ProtectSystem=full + +# Home directories are invisible to the daemon. Its store is the machine-scoped +# /var/lib/tenebra/data, so it has no business in anyone's home, and a tunnel +# process that is compromised should not be able to read user files. +ProtectHome=yes + +# A private /tmp and /var/tmp. Nothing here coordinates through either, so this +# only removes a shared namespace that temp-file attacks live in. +PrivateTmp=yes + +# Read-only cgroup hierarchy and no clock changes: the tunnel needs neither, and +# both are standard footholds for persistence and for skewing certificate +# validity checks. +ProtectControlGroups=yes +ProtectClock=yes + +# The daemon never unshares a namespace, never asks for realtime scheduling and +# never creates setuid files. Denying all three costs nothing and removes a +# common sandbox-escape and privilege-escalation surface. +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes + +# The address families a tunnel needs: AF_UNIX for the control socket, AF_INET +# and AF_INET6 for proxied traffic, AF_NETLINK to program interfaces, routes and +# rules, and AF_PACKET because sing-box's DHCP-based DNS transport and its raw +# probes use it. Everything else (Bluetooth, CAN, XDP, ...) is refused. +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK AF_PACKET + +# The standard system-service syscall allow-list. It keeps the socket, ioctl and +# netlink calls the tunnel is built on while blocking module loading, mounting, +# swapping, rebooting and raw I/O. SystemCallArchitectures=native additionally +# rejects the compat (32-bit) call table, a classic way to reach a syscall the +# filter only blocked in its native form. +SystemCallFilter=@system-service +SystemCallArchitectures=native + +# Everything the daemon writes — profiles carrying subscription credentials, the +# sing-box cache — is root-only. The control socket is not affected: the core +# chmods it to 0666 after binding so an unprivileged GUI can attach, and chmod +# ignores the umask. +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/docs/README.md b/docs/README.md index 7a62c0f..f906239 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,8 +11,11 @@ Project overview and quick-start live in the top-level - **[development.md](development.md)** — the full set-up, build, run and test walkthrough, the environment variables, coding conventions and troubleshooting. -Platform ports (plans, not yet shipped): +Platform ports: +- **[porting/linux.md](porting/linux.md)** — the Linux desktop build: the root + systemd service that owns the tunnel and its sandbox, the two install paths + (Arch package and hand-install script), and what Linux does not support. - **[porting/macos.md](porting/macos.md)** — the macOS desktop port: same sidecar model as Windows, with a privileged helper for the `utun` tunnel and a DMG/notarization distribution path. diff --git a/docs/architecture.md b/docs/architecture.md index c30b80e..b24345b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,8 +25,10 @@ Everything that isn't tied to a specific OS lives here: - **control** — the JSON protocol the UI uses to drive the core. The core's only third-party dependencies are `github.com/Microsoft/go-winio` -and `golang.org/x/sys` — Windows plumbing for the named-pipe transport and the -service entry point; everything else is standard library. It generates sing-box +and `golang.org/x/sys` — the named-pipe transport and service entry point on +Windows, and the OS calls the detached daemon needs on the unix side (peer +credentials on the control socket, per-socket interface binding for the ping +probe); everything else is standard library. It generates sing-box configuration as plain JSON rather than linking sing-box as a library; sing-box itself is the runtime. That keeps the core pure, fully unit-testable offline, and free of the sing-box dependency tree. @@ -39,7 +41,7 @@ The system tunnel cannot be cross-platform; each OS exposes its own: |---------|-------------------| | Windows | wintun | | macOS | utun | -| Linux | utun / tun | +| Linux | tun (`/dev/net/tun`) | | Android | `VpnService` | | iOS | Network Extension | @@ -65,12 +67,15 @@ the wintun tunnel and the sing-box lifecycle. The wire format is specified in +-- wintun tunnel ``` -On Windows the same core can also run detached from the UI — as a Windows -service serving the identical protocol on the `\\.\pipe\tenebra` named pipe -(`tenebra-core --pipe` serves it from a console for development). That is the -path to the WireGuard/Tailscale privilege model, where the tunnel lives in a -SYSTEM service and the GUI runs unprivileged; the desktop shell does not use it -yet. Transports and the pipe's security model are described in +The same core can also run detached from the UI, serving the identical protocol +on a well-known endpoint: the `\\.\pipe\tenebra` named pipe from a Windows +service, or a unix domain socket from a root LaunchDaemon on macOS and a systemd +service on Linux (`tenebra-core --pipe` / `--socket` serve them from a console +for development). That is the WireGuard/Tailscale privilege model, where the +tunnel lives in a privileged service and the GUI runs unprivileged — on Linux it +is the only arrangement, since opening `/dev/net/tun` and claiming the default +route need `CAP_NET_ADMIN`. Transports, peer authentication and the endpoints' +security models are described in [control-protocol.md](control-protocol.md#transports). ### Mobile (later) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 21c18ca..06162ef 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -28,6 +28,26 @@ messages changes between transports. and the UI does not need administrator rights. Diagnostics go to `%ProgramData%\Tenebra\service.log` in service mode (a service has no stderr), and to stderr under `--pipe`. +- **unix domain socket** (macOS, Linux): `/var/run/tenebra.sock` on macOS, + `/run/tenebra.sock` on Linux. The same arrangement as the named pipe, for the + same reason — the core runs detached, as a root LaunchDaemon or a systemd + service, and an unprivileged GUI attaches to it — and `tenebra-core --socket` + serves it from a shell for development. The path differs only because `/run` + is the canonical spelling on Linux and the real directory on macOS is + `/var/run`. `TENEBRA_SOCKET` overrides it on both ends: a path, or `off`/`0` + to disable the transport. + + Two hazards a pipe gets from the OS for free are handled at bind time. A + socket file left behind by a crash would otherwise wedge every later start + (`bind` refuses an existing path whether or not anyone serves it), so the core + dials it first: if something answers, another core owns the tunnel and this + one refuses to start rather than steal it; if nothing answers the file is + stale and is unlinked. And `bind` honours the umask, which would leave a + root-bound socket unreachable to the GUI, so it is chmod'd — see below. + + The machine-scoped store lives at `/Library/Application Support/Tenebra/data` + on macOS and `/var/lib/tenebra/data` on Linux, clamped to root-owned `0700` + on every start. ### Named-pipe sessions @@ -103,8 +123,8 @@ contract. to force the sidecar (useful in development, where a running service would otherwise capture the session meant for a freshly built core). It is a client-side override only — the core has no `TENEBRA_PIPE`, and the service -always serves the well-known name. (macOS is symmetric here: both ends of the -unix transport honour `TENEBRA_SOCKET`.) +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 @@ -142,6 +162,42 @@ identity from adding instances to the bound name later (on pipes, of the same trust statement: interactive users are trusted with this control surface. +### Unix-socket security + +The socket is chmod'd to `0666`: the unix-permission analog of the pipe DACL's +grant to INTERACTIVE, and necessary for the same reason — a root daemon binds +it and an unprivileged GUI has to reach it. Mode alone would therefore admit +every local user, so **reaching the socket is not being admitted to it**. Each +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 +(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 +"interactive session" means: + +- macOS reads the owner of `/dev/console`, which the window server chowns to + whoever is logged in at the display. +- Linux has no such file. It reads logind's runtime state — `ACTIVE_UID` from + the seat under `/run/systemd/seats` — and falls back to the sole per-user + runtime directory under `/run/user` when no seat state is published. Both are + session-manager state, not kernel interfaces: a host running seatd or no + session manager publishes neither. + +**The lookup fails open.** When the interactive user cannot be determined — +no seat state, a session mid-transition, two seats disagreeing, an unparseable +value — the peer is admitted and the daemon logs a warning naming the reason. +A wrong deny bricks GUI attach, the product's core interaction, on legitimate +edge cases; the fail-open leaves the exposure exactly where the transport +already stood, and makes it auditable in the log rather than silent. + +The honest limits are the pipe's, restated: the tunnel is machine-wide, a +second user at the same seat inherits control of it, and processes of the same +user are not defended against each other. + ## Requests | cmd | fields | returns | diff --git a/docs/porting/linux.md b/docs/porting/linux.md new file mode 100644 index 0000000..1ff69c3 --- /dev/null +++ b/docs/porting/linux.md @@ -0,0 +1,253 @@ +# Linux + +How the Linux desktop build is put together, and what it does not do yet. For +the shared design this builds on, read [architecture.md](../architecture.md); +for the core ↔ UI wire format that carries over unchanged, +[control-protocol.md](../control-protocol.md). The macOS port +([macos.md](macos.md)) is the closest relative — read that first if you want the +reasoning behind the split-privilege shape, because Linux reuses it almost +verbatim. + +## Overview + +Linux lands close to macOS and far from Windows, for one reason: the tunnel needs +privilege the GUI must not have. Windows solves that with an elevated background +service and a per-machine install; macOS with a root `LaunchDaemon`; Linux with a +root **systemd service**. Everything above that line — the Go core, the config +generator, the control protocol, the React UI — is the same code. + +Three facts make the port cheap: + +- **sing-box ships official Linux binaries.** The pinned release publishes + `linux-amd64` and `linux-arm64` command-line builds with the tags Tenebra needs + (`with_gvisor`, `with_quic`, `with_utls`, `with_wireguard`, `with_clash_api`), + and `CGO: disabled`. The fetch step pulls the tarball, exactly as on macOS. +- **The tun device is a plain character device.** `sing-box` opens + `/dev/net/tun` and programs routes over netlink itself once it has + `CAP_NET_ADMIN`. No kernel module of our own, no NetworkExtension, no + entitlement — the whole privilege story is "run the core as root". +- **The control protocol already had a unix-socket transport.** It was written + for the macOS daemon; Linux binds the same socket at + [`/run/tenebra.sock`](../control-protocol.md) and the desktop shell attaches to + it the same way. + +What is genuinely different from macOS is **packaging**, not plumbing. macOS has +one filesystem layout (an `.app` bundle) and one installer story; Linux has as +many as it has distributions, so this port carries a hand-install script for any +of them and a native package for Arch, which is the distribution being targeted +first. + +## Architecture + +``` + Tauri UI ──── line-delimited JSON over /run/tenebra.sock ────► tenebra-core (root) + (React, unprivileged) │ + └─ sing-box ── /dev/net/tun +``` + +The daemon owns the tunnel and outlives any one UI process; the GUI is a WebView +and a bridge. When nothing answers on the socket the shell falls back to spawning +its own unprivileged sidecar — useful for development, but that sidecar cannot +open a tun device, so it cannot connect. + +Running as root, the core pins two machine-scoped paths instead of the per-user +defaults it would otherwise pick out of root's home: + +| What | Where | Enforced by | +|------|-------|-------------| +| Control socket | `/run/tenebra.sock`, `0666` | `ListenSocket`, narrowed per connection by the `SO_PEERCRED` peer check | +| Profile store | `/var/lib/tenebra/data`, root-owned `0700` | the core's own clamp on every start | +| sing-box + rule-sets | the install directory (see below) | `TENEBRA_SINGBOX`, then `adapters/linux.InstallDirs` | + +The socket is world-writable on purpose: any local user's GUI must be able to +attach, and the authorization decision is made per connection from the peer's +credentials rather than by file mode. The store is the opposite — it holds +subscription credentials, so it is root-only and reachable only through the +protocol. + +## Privilege and the tunnel + +The unit is [`deploy/linux/tenebra.service`](../../deploy/linux/tenebra.service). +It runs `tenebra-core --socket` as root, restarts it forever (the +process-supervision backstop under the kill switch — nothing inside the core can +restart the core), and sandboxes what that root process can reach. + +The sandbox is the part worth reading. A VPN daemon is exactly the workload most +systemd hardening presets break, so every knob was chosen against one rule: +nothing may interfere with the tun device, routing or DNS. In particular, four +common settings are deliberately **absent**, and each would break the tunnel: + +| Not set | Why it would break | +|---------|--------------------| +| `PrivateDevices=` | replaces `/dev` with a minimal set that has no `/dev/net/tun` | +| `ProtectKernelTunables=` | policy routing for a tun device is configured partly through `/proc/sys` — reverse-path filtering above all — which this mounts read-only | +| `ProtectProc=`, `ProcSubset=` | per-app split tunnelling matches traffic by reading other processes' `/proc` entries | +| `ProtectSystem=strict` | would take `/run` read-only with everything else, and the control socket is bound there | + +What *is* set: a capability bounding set of exactly `CAP_NET_ADMIN`, +`CAP_NET_RAW`, `CAP_NET_BIND_SERVICE`, `CAP_SYS_PTRACE`, `CAP_DAC_READ_SEARCH` +and `CAP_CHOWN`; `DevicePolicy=closed` with `/dev/net/tun` allowed back; +`ProtectSystem=full`, `ProtectHome=yes`, `PrivateTmp=yes`; the usual +`RestrictNamespaces`/`RestrictRealtime`/`RestrictSUIDSGID`/`LockPersonality` +trio; `RestrictAddressFamilies=` narrowed to the five a tunnel uses; and +`SystemCallFilter=@system-service`. Each carries a comment in the unit explaining +why it is safe here. + +Two consequences are worth calling out: + +- **`CAP_SYS_MODULE` is not in the bounding set**, so the daemon cannot pull in + the `tun` module itself. On a kernel that ships `tun` as a module and has not + loaded it, the first connect would otherwise fail with an opaque `ENODEV`. Both + install paths therefore drop a `modules-load.d` entry (and the script also + `modprobe`s it for the current boot). +- **`TENEBRA_CONFIG_DIR` is not set in the unit.** The core pins + `/var/lib/tenebra/data` and clamps it to root-owned `0700` only when the + variable is *unset*, because an operator-supplied value is honoured as an + override everywhere else. Setting it in the unit would look tidier and quietly + skip the clamp. + +## Installing + +Two supported paths. They deliberately use different prefixes so they cannot +collide: + +| | Package (Arch) | Hand-install script | +|---|---|---| +| Core, sing-box, `.srs` | `/usr/lib/tenebra/` | `/usr/local/lib/tenebra/` | +| Unit | `/usr/lib/systemd/system/tenebra.service` | `/etc/systemd/system/tenebra.service` | +| GUI | `/usr/bin/tenebra` | installed separately (AppImage, `.deb`, or a local build) | +| Updates | `pacman` | re-run the script | + +`/usr/local` is the half of the hierarchy a package manager never touches, and +`/etc/systemd/system` takes precedence over `/usr/lib/systemd/system` — so a +hand-install on a machine that also has the package silently wins. Both scripts +say so out loud when they see the other's unit. + +### The script + +``` +# fetch sing-box and the rule-sets first +bash scripts/fetch-resources.sh + +# build the core from this checkout and install the daemon +sudo bash scripts/linux/install-daemon.sh --dev + +# or install from an already-built payload directory +sudo bash scripts/linux/install-daemon.sh --from-dir /path/to/payload +``` + +It is safe to re-run: it stops the service, replaces the binaries, starts them +again, and restores the previous install if any step fails. Replacing a running +executable is not optional to get right on Linux — an in-place overwrite fails +with `ETXTBSY` — which is why an upgrade necessarily drops an established tunnel. + +There is no signature to check the way the macOS script leans on +`codesign`/`spctl`, so the gate it uses instead is *who could have written the +payload*: a source directory that is group- or world-writable is refused, because +everything in it is about to run as root. `--allow-unsafe-source` is the explicit +escape hatch. + +Removal is [`scripts/linux/uninstall-daemon.sh`](../../scripts/linux/uninstall-daemon.sh); +it keeps `/var/lib/tenebra` unless given `--purge`. + +### Arch Linux + +Tauri's bundler has no pacman target — its config schema only knows `deb`, `rpm`, +`appimage`, `msi`, `nsis`, `app` and `dmg` — so the Arch package is a normal +`PKGBUILD` in [`packaging/arch/`](../../packaging/arch/PKGBUILD) that builds the +desktop binary with `--no-bundle` and installs it itself. + +``` +cd packaging/arch +makepkg -si +``` + +It builds the core with Go, the shell with Rust and npm, and pulls in the pinned +sing-box and rule-sets as checksummed sources. **sing-box is not in Arch's +official repositories** (only the AUR carries it), so depending on the system +package was not an option; the release binary is pinned by SHA-256 the same way +the Windows and macOS bundles pin it, which also keeps the engine on the exact +version Tenebra's config generator targets. + +Two things differ from the script path beyond the prefix: + +- **The in-app updater does nothing for a packaged install.** Tauri's updater can + replace an AppImage, not files owned by `pacman`. Updates come from `pacman + -Syu`, and the app should not be expected to offer one. +- **Nothing is started for you.** The scriptlet prints what to run + (`systemctl enable --now tenebra.service`) rather than enabling the service + behind your back, and an upgrade deliberately leaves the running daemon alone + so a live tunnel is not dropped under you — restart it when convenient. + +`.SRCINFO` is not committed and there is no AUR package: the `PKGBUILD` simply +lives in the repository for now. + +## Bundled resources + +[`scripts/fetch-resources.sh`](../../scripts/fetch-resources.sh) serves both Unix +targets and dispatches on `uname -s`. It is one script rather than two because +the halves that drift are the shared ones — the pinned sing-box version, the +three rule-set commits and their SHA-256 digests. Those pins already live in +three places (this script, `fetch-resources.ps1` for Windows, and the `PKGBUILD`, +which needs them in its own `source()`/`sha256sums()` arrays for makepkg to +verify); a separate Linux script would have made it four. The platform-specific +part is small: macOS fetches two darwin slices and `lipo`s them into the +universal binary Tauri's universal target requires, while Linux fetches one ELF, +because an ELF holds exactly one architecture. + +``` +bash scripts/fetch-resources.sh # this host's architecture +bash scripts/fetch-resources.sh --arch arm64 # cross-fetch (Linux only) +``` + +`amd64` and `arm64` are pinned. sing-box also publishes `386` and `armv7` builds; +they are deliberately not pinned, because the bundle they would go into is a +webkit2gtk Tauri app and 32-bit Linux desktops are not a target. The release +archive also carries a `libcronet.so` that only sing-box's Naive outbound +`dlopen`s — a protocol Tenebra's generator never emits — so it is left out rather +than shipped as an unused ~40 MB blob. + +Every download is checksum-verified and a mismatch is fatal: these binaries are +bundled verbatim into a privileged tunnel, so a swapped upstream artifact must +never reach a build. + +## What is not supported + +- **System-proxy mode.** Pointing a Linux desktop at a proxy means writing + per-desktop settings (GNOME's `gsettings`, KDE's `kioslaverc`, a session's own + environment) as the logged-in user, which a root daemon has no session bus to + reach. Arming it logs and stays disarmed — it degrades quietly rather than + failing. Tun mode, the default, is unaffected. +- **AmneziaWG obfuscation.** As on every other desktop platform, the bundled + stock sing-box applies none of the AWG obfuscation parameters: an AmneziaWG + link imports and connects, but the tunnel runs as plain WireGuard. +- **musl systems.** The upstream sing-box Linux binaries are dynamically linked + against glibc, so Alpine and other musl distributions need a sing-box of their + own; point `TENEBRA_SINGBOX` at it. +- **32-bit and non-amd64/arm64 architectures.** No pinned artifacts, no bundle. +- **Non-systemd inits.** The install script refuses on a machine where systemd is + not PID 1. The daemon itself is just `tenebra-core --socket` run as root, so an + OpenRC or runit service is a small piece of work — it is simply not written. + +## Open questions and risks + +- **DNS on systemd-resolved hosts.** sing-box installs DNS by routing it into the + tunnel rather than by rewriting `resolv.conf` the way `wg-quick` does. On a host + whose resolver is the `127.0.0.53` stub, queries stay on loopback and never + enter the tunnel, so they can be answered by the link's own upstream. The unit + orders itself after `systemd-resolved` and leaves `/proc/sys` writable so the + routing side can do its job, but the interaction has not been measured against a + live tunnel and is the most likely source of a leak on Linux. +- **The sandbox has been proven not to block startup, not to pass traffic.** The + unit was verified with `systemd-analyze verify`, and a real `tenebra-core` was + run under it — the socket comes up, `/dev/net/tun` opens, `/proc/sys` is + writable, other devices are blocked. What no automated run covers is a live + tunnel against a real server with routes installed; as on Windows and macOS, + that can only be signed off by a manual, privileged run. +- **`MemoryDenyWriteExecute=` is left off.** The core starts fine with it, but it + cannot be exercised against a live sing-box tunnel here, and a Go daemon with no + JIT gains little from it. Revisit with a real tunnel to hand. +- **Per-app split tunnelling on Linux is untested.** The capabilities it needs + (`CAP_SYS_PTRACE`, `CAP_DAC_READ_SEARCH`) are in the bounding set and `/proc` is + deliberately left visible, but no run has confirmed sing-box actually matches a + process by name through them. diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD new file mode 100644 index 0000000..5d00ea3 --- /dev/null +++ b/packaging/arch/PKGBUILD @@ -0,0 +1,201 @@ +# Maintainer: Tenebra contributors + +# Builds Tenebra from source for Arch Linux: the privileged core daemon, the +# Tauri desktop UI, the systemd unit and the bundled sing-box the generated +# configs are cut against. Tauri's bundler has no pacman target — its config +# schema only knows deb, rpm, appimage, msi, nsis, app and dmg — so the desktop +# binary is built with --no-bundle and packaged here instead. +# +# Build and install straight from a checkout: +# cd packaging/arch && makepkg -si +# +# See docs/porting/linux.md for how this differs from the hand-install script in +# scripts/linux, and for the update story (pacman, not the in-app updater). + +pkgname=tenebra +# The release this builds. It is also the git tag the source is taken from, so it +# 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.4.6 +pkgrel=1 +pkgdesc="VPN client built on sing-box: a privileged core daemon and a desktop UI" +arch=('x86_64') +url="https://github.com/Divaaaan/tenebra" +# The repository ships the plain GPLv3 text with no "or later" grant in any +# source header, so the narrow identifier is the accurate one; ui-desktop's +# Cargo.toml and package.json declare the same. +license=('GPL-3.0-only') +depends=( + 'webkit2gtk-4.1' # the web engine the Tauri shell renders in + 'gtk3' # the toolkit under it + # The system tray. namcap will report this as "may not be needed" because + # nothing links against it — the tray-icon crate dlopen's it at runtime — so + # do not drop it on namcap's advice or the tray silently disappears. + 'libayatana-appindicator' + 'hicolor-icon-theme' # owns the icon directories this package fills +) +makedepends=( + 'git' # the source is a tagged clone + 'go' # the core and the sidecar + 'rust' # the Tauri shell + 'nodejs' # the front end + 'npm' +) +install="${pkgname}.install" +# Arch enables link-time optimisation by default, which appends -flto=auto to the +# CFLAGS and LDFLAGS makepkg exports. The Rust dependency tree here reaches `ring` +# (through rustls, through the updater plugin), and ring's build script assembles +# hand-written .S files with exactly those CFLAGS — under -flto they come out as +# bitcode with none of the asm symbols in them, and the final link dies on a wall +# of "undefined symbol: ring_core_*". Turning LTO off for this package is the +# standard remedy and costs nothing here, since Rust does its own LTO anyway. +# +# !debug for a plainer reason: both binaries ship stripped of debug info, so the +# split debug package Arch would otherwise build carries no symbols at all — just +# a copy of the sources and a pair of "No debugging symbols" errors from +# gdb-add-index during the strip step. +options=('!lto' '!debug') + +# sing-box is bundled, not depended on. Do not "simplify" this into +# depends=('sing-box'): there is no such package in core or extra — `pacman -S +# sing-box` on a stock Arch install answers "target not found" — it exists only +# in the AUR, and a repository package may not depend on an AUR one. Bundling is +# also what keeps the engine on the exact version Tenebra's config generator +# targets, the same reason the Windows and macOS bundles pin it. The binary goes +# into this package's private directory rather than /usr/bin, so a user who +# already has the AUR sing-box installed keeps it and nothing collides. +# +# makepkg verifies the digest below before anything is unpacked. Keep this +# version and the three rule-set commits in step with scripts/fetch-resources.sh +# and scripts/fetch-resources.ps1 — all three files pin the same artifacts. +_singboxver=1.13.13 +_geoipcommit=a508a0a09d30111e0ab5a0d9a3de1aff832d72b4 +_geositecommit=02b7bc85184c7fa94ccdfe9a35b7f4a169b28b4d + +# The rule-sets are shipped locally so smart routing loads them from disk instead +# of downloading them at every connect (a throttled raw.githubusercontent.com +# 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=( + "git+${url}.git#tag=v${pkgver}" + "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" + "geosite-ads-${_geositecommit}.srs::https://raw.githubusercontent.com/SagerNet/sing-geosite/${_geositecommit}/geosite-category-ads-all.srs" +) +# A git tag is verified by its own name, not a tarball digest, so SKIP is the +# only meaningful value for the first entry. Every downloaded artifact after it +# is pinned. +sha256sums=( + 'SKIP' + 'bb99cabf47694625db421ee17898f36cdc1f9c2cb5decf65b12bac8d8437e842' + '8bc18433e5d5b0644ba2a9ff74cd03428ba4f4e388b3c409f182de930e3c3170' + '3fb41849eefac86a4e65a86da3b868ecd40512e4d3f097ee325474f4cd401f76' + 'ca44c97fce76f4f889e08bbc28e80d497a43239328e09f760129844057a2780a' +) + +prepare() { + cd "${pkgname}" + + # Fetch every dependency up front so build() does no network I/O, and keep the + # caches inside $srcdir instead of the packager's home. + export GOPATH="${srcdir}/gopath" + go mod download + + cd ui-desktop + npm ci + cd src-tauri + export RUSTUP_TOOLCHAIN=stable + cargo fetch --locked --target "$(rustc -vV | sed -n 's/^host: //p')" +} + +build() { + cd "${pkgname}" + + export GOPATH="${srcdir}/gopath" + # Arch's Go packaging template builds with cgo and an external linker, which + # gets the distribution's full-RELRO LDFLAGS into the binary. This one does not, + # on purpose: cgo also switches Go's net package to the libc resolver, and DNS + # behaviour is load-bearing for a VPN client — the Windows and macOS builds are + # cgo-free, and the resolver must not differ per platform. The cost is a namcap + # "lacks FULL RELRO" warning on tenebra-core; PIE is still on. + export CGO_ENABLED=0 + # -trimpath keeps build paths out of the binary, -mod=readonly refuses to edit + # 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 + + # 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 + # it: on Linux the UI attaches to the root daemon's socket instead. The triple + # comes from rustc rather than being spelled out, so it stays right on any + # architecture this package is ever built for. + local triple + triple="$(rustc -vV | sed -n 's/^host: //p')" + install -Dm755 build/tenebra-core \ + "ui-desktop/src-tauri/binaries/tenebra-core-${triple}" + + # The bundled sing-box and rule-sets have to sit where the Tauri config + # declares its resources, or the build fails on a missing resource. + install -Dm755 "${srcdir}/sing-box-${_singboxver}-linux-amd64/sing-box" \ + ui-desktop/src-tauri/resources/sing-box + install -Dm644 "${srcdir}/geoip-ru-${_geoipcommit}.srs" ui-desktop/src-tauri/resources/geoip-ru.srs + install -Dm644 "${srcdir}/geosite-ru-${_geositecommit}.srs" ui-desktop/src-tauri/resources/geosite-ru.srs + install -Dm644 "${srcdir}/geosite-ads-${_geositecommit}.srs" ui-desktop/src-tauri/resources/geosite-ads.srs + + cd ui-desktop + export RUSTUP_TOOLCHAIN=stable + # --no-bundle stops at the compiled binary: pacman is the bundler here, and the + # deb/AppImage targets would only produce artifacts this package throws away. + npm run tauri build -- --no-bundle +} + +package() { + cd "${pkgname}" + + # The privileged half. Core, sing-box and the rule-sets live together because + # the core resolves the rule-sets from the directory of TENEBRA_SINGBOX. + install -Dm755 build/tenebra-core "${pkgdir}/usr/lib/${pkgname}/tenebra-core" + install -Dm755 ui-desktop/src-tauri/resources/sing-box "${pkgdir}/usr/lib/${pkgname}/sing-box" + install -Dm644 ui-desktop/src-tauri/resources/geoip-ru.srs "${pkgdir}/usr/lib/${pkgname}/geoip-ru.srs" + install -Dm644 ui-desktop/src-tauri/resources/geosite-ru.srs "${pkgdir}/usr/lib/${pkgname}/geosite-ru.srs" + install -Dm644 ui-desktop/src-tauri/resources/geosite-ads.srs "${pkgdir}/usr/lib/${pkgname}/geosite-ads.srs" + + # The unprivileged half, under the command name the .desktop entry launches. + install -Dm755 ui-desktop/src-tauri/target/release/tenebra-desktop "${pkgdir}/usr/bin/${pkgname}" + + # The one unit file this repository has, re-pointed from the /usr/local prefix + # the hand-install script uses to the /usr prefix a package owns. Rewriting it + # here keeps a single copy of the sandbox settings instead of a near-duplicate + # that would quietly drift. + install -Dm644 deploy/linux/tenebra.service "${pkgdir}/usr/lib/systemd/system/${pkgname}.service" + sed -i "s|/usr/local/lib/tenebra|/usr/lib/tenebra|g" "${pkgdir}/usr/lib/systemd/system/${pkgname}.service" + + # The unit keeps CAP_SYS_MODULE out of its bounding set, so the daemon cannot + # pull in tun itself; loading it at boot is the packaged equivalent of what the + # install script does with modprobe. + install -Dm644 /dev/stdin "${pkgdir}/usr/lib/modules-load.d/${pkgname}.conf" <<'EOF' +# Tenebra opens /dev/net/tun; make sure the module is present at boot. +tun +EOF + + install -Dm644 deploy/linux/tenebra.desktop "${pkgdir}/usr/share/applications/${pkgname}.desktop" + local size + for size in 32x32 64x64 128x128; do + install -Dm644 "ui-desktop/src-tauri/icons/${size}.png" \ + "${pkgdir}/usr/share/icons/hicolor/${size}/apps/${pkgname}.png" + done + install -Dm644 "ui-desktop/src-tauri/icons/128x128@2x.png" \ + "${pkgdir}/usr/share/icons/hicolor/256x256/apps/${pkgname}.png" + + # /var/lib/tenebra/data is deliberately NOT packaged. The unit's StateDirectory= + # creates it before the service starts and the core re-clamps it to root-owned + # 0700 on every start, so shipping an empty directory would only duplicate that + # — and leaving it unowned means pacman can never touch a user's stored + # profiles, which is the behaviour an uninstall should have anyway. + + install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" + install -Dm644 THIRD-PARTY-NOTICES.md "${pkgdir}/usr/share/licenses/${pkgname}/THIRD-PARTY-NOTICES.md" +} diff --git a/packaging/arch/tenebra.install b/packaging/arch/tenebra.install new file mode 100644 index 0000000..7988f70 --- /dev/null +++ b/packaging/arch/tenebra.install @@ -0,0 +1,57 @@ +# pacman scriptlet for the tenebra package. It never starts or restarts the +# daemon on its own: this one owns the tunnel, so an automatic restart would drop +# a live connection out from under whoever is using it. It tells the +# administrator what to run instead. + +# Arch's own systemd hook reloads the manager when a package changes a unit under +# /usr/lib/systemd/system. Calling it here as well costs nothing and keeps the +# scriptlet correct where that hook does not run — an install into a container +# image, or a pacman invoked with a different --hookdir. +_reload_units() { + systemctl daemon-reload >/dev/null 2>&1 || true +} + +post_install() { + _reload_units + cat <<'EOF' + +Tenebra's tunnel runs as a root service; the desktop app attaches to it over +/run/tenebra.sock and cannot connect on its own. Enable it once: + + sudo systemctl enable --now tenebra.service + +Profiles and settings live in /var/lib/tenebra/data, owned by root and readable +only by root — the app reaches them through the daemon, never through the files. +Logs go to the journal: journalctl -u tenebra -f + +Updates come from pacman. The in-app updater is disabled for packaged installs +(it can only replace an AppImage), so it will not offer to update this build. + +EOF +} + +post_upgrade() { + _reload_units + cat <<'EOF' + +Tenebra was upgraded, but the running daemon is still the old binary — the +tunnel it holds was deliberately left up. Restart it when convenient: + + sudo systemctl restart tenebra.service + +Until then the app will warn that the daemon has fallen behind it. + +EOF +} + +post_remove() { + _reload_units + cat <<'EOF' + +Removed. /var/lib/tenebra was kept: it holds your imported profiles and their +subscription credentials. Delete it by hand if you want them gone: + + sudo rm -rf /var/lib/tenebra + +EOF +} diff --git a/scripts/fetch-resources.sh b/scripts/fetch-resources.sh index e0ee2a3..ae0dbba 100644 --- a/scripts/fetch-resources.sh +++ b/scripts/fetch-resources.sh @@ -1,15 +1,53 @@ #!/usr/bin/env bash # Fetches the sing-box binary and the RU rule-sets into # ui-desktop/src-tauri/resources so the desktop app can bundle them. This is the -# macOS analog of fetch-resources.ps1; the pinned versions are kept in sync with -# it. These binaries are not checked in; run this before building the macOS -# bundle. Pinned versions keep builds reproducible. +# Unix analog of fetch-resources.ps1 and serves both macOS and Linux: the pinned +# version, the rule-sets, the retry policy and the checksum discipline are +# identical on the two, and only the sing-box artifact differs, so one script +# keeps a single copy of the .srs pins instead of letting two files drift apart. +# The pinned versions are kept in sync with the PowerShell script. These binaries +# are not checked in; run this before building the desktop bundle. Pinned +# versions keep builds reproducible. # -# There is no wintun on macOS. The tun device is utun, which sing-box opens -# itself once it runs with privilege (see docs/porting/macos.md); nothing needs -# to be placed beside the binary for it. +# Neither Unix target needs a driver payload beside the binary the way Windows +# needs wintun.dll: the tun device is utun on macOS and /dev/net/tun on Linux, +# and sing-box opens either itself once it runs with privilege (see +# docs/porting/macos.md and docs/porting/linux.md). set -euo pipefail +usage() { + cat >&2 <<'EOF' +Usage: fetch-resources.sh [--arch ] + +Downloads the pinned sing-box build for this host plus the RU and ads rule-sets +into ui-desktop/src-tauri/resources. + + --arch Linux only: fetch this architecture instead of the host's, for a cross + build. macOS always produces a universal (arm64 + amd64) binary, so it + rejects the flag. +EOF +} + +# Requested Linux architecture, empty for "whatever this host is". +target_arch="" +while [ "$#" -gt 0 ]; do + case "$1" in + --arch) + target_arch="${2:-}" + [ -n "$target_arch" ] || { echo "--arch needs an architecture (amd64 or arm64)" >&2; exit 2; } + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1 (see --help)" >&2 + exit 2 + ;; + esac +done + # Keep in sync with scripts/fetch-resources.ps1 ($singboxVersion). singbox_version="1.13.13" @@ -25,15 +63,20 @@ singbox_version="1.13.13" # the sing-box pins there are the Windows build and differ by platform. # # To refresh on a version/rule-set bump: bump the version, download the same URLs -# these variables feed (the GitHub darwin tarballs and the raw .srs files), run +# these variables feed (the GitHub darwin and linux tarballs and the raw .srs +# files), run # shasum -a 256 # on each, and paste the digests here. The .srs sets are rebuilt periodically on # the SagerNet `rule-set` branch, so their digests move even without a version # change — re-pin them whenever the bundled copies are refreshed. Mirror every # change into scripts/fetch-resources.ps1 (identical .srs digests; Windows -# sing-box digest for that script). +# sing-box digest for that script) and into packaging/arch/PKGBUILD, which pins +# the same linux-amd64 artifact and the same three rule-sets in its +# source()/sha256sums() arrays so makepkg can verify them itself. singbox_sha256_darwin_arm64="4ac414d4ede9ec21bc79d8ccf40b4679429203b9e06ad96d2d8d34c0fe940558" singbox_sha256_darwin_amd64="477afd64ad7751214f01338ba244265ecc223966ddb58214963f526dca7f424e" +singbox_sha256_linux_amd64="bb99cabf47694625db421ee17898f36cdc1f9c2cb5decf65b12bac8d8437e842" +singbox_sha256_linux_arm64="d7fab87b921933eb281d8ee7bd5377cdd8228089f1f7c807c9363a6a2329286c" geoip_ru_sha256="8bc18433e5d5b0644ba2a9ff74cd03428ba4f4e388b3c409f182de930e3c3170" geosite_ru_sha256="3fb41849eefac86a4e65a86da3b868ecd40512e4d3f097ee325474f4cd401f76" geosite_ads_sha256="ca44c97fce76f4f889e08bbc28e80d497a43239328e09f760129844057a2780a" @@ -77,46 +120,112 @@ verify_sha256() { fi } -# Download and unpack a single-arch darwin sing-box, echoing the path to the -# extracted binary. sing-box ships one tarball per arch; the desktop bundle wants -# one universal binary, so both are fetched and lipo'd together below. The -# tarball is checksum-verified before it is unpacked. +# Download and unpack a single-arch sing-box release tarball, echoing the path to +# the extracted binary. sing-box ships one tarball per (os, arch); the tarball is +# checksum-verified before it is unpacked, so nothing unverified is ever written +# where the bundle step will pick it up. +# +# The archive carries a LICENSE and a libcronet.so/.dylib beside the executable. +# libcronet is dlopen'd only by sing-box's Naive outbound, a protocol Tenebra's +# generator never emits, so it is deliberately left out of the bundle rather than +# shipped as a ~40 MB unused blob. fetch_arch() { - local arch="$1" - local expected="$2" - local tarball="$work/sing-box-$arch.tar.gz" - local url="https://github.com/SagerNet/sing-box/releases/download/v$singbox_version/sing-box-$singbox_version-darwin-$arch.tar.gz" + local os="$1" arch="$2" expected="$3" + local tarball="$work/sing-box-$os-$arch.tar.gz" + local url="https://github.com/SagerNet/sing-box/releases/download/v$singbox_version/sing-box-$singbox_version-$os-$arch.tar.gz" fetch "$url" "$tarball" verify_sha256 "$tarball" "$expected" - local out="$work/$arch" + local out="$work/$os-$arch" mkdir -p "$out" tar -xzf "$tarball" -C "$out" - # The tarball extracts to sing-box--darwin-/sing-box. + # The tarball extracts to sing-box---/sing-box. find "$out" -type f -name sing-box | head -n 1 } -arm64_bin="$(fetch_arch arm64 "$singbox_sha256_darwin_arm64")" -amd64_bin="$(fetch_arch amd64 "$singbox_sha256_darwin_amd64")" +# fetch_singbox_darwin stitches the two darwin slices into the one universal +# binary Tauri's universal-apple-darwin target expects of every bundled binary. +fetch_singbox_darwin() { + local arm64_bin amd64_bin + arm64_bin="$(fetch_arch darwin arm64 "$singbox_sha256_darwin_arm64")" + amd64_bin="$(fetch_arch darwin amd64 "$singbox_sha256_darwin_amd64")" + + if command -v lipo >/dev/null 2>&1; then + lipo -create "$arm64_bin" "$amd64_bin" -output "$dest/sing-box" + chmod +x "$dest/sing-box" + echo "Built universal (arm64+amd64) sing-box $singbox_version" + else + # No lipo (not on a macOS host, or the command-line tools are absent): keep + # the per-arch binaries and fall back to the arm64 slice as the default, + # since the hosted macOS runners are all Apple Silicon. A universal bundle + # still needs a lipo pass on a real macOS host. + # TODO(macos): run the lipo step on a macOS host/runner to produce a + # universal sing-box before shipping a universal bundle. + cp "$arm64_bin" "$dest/sing-box-arm64" + cp "$amd64_bin" "$dest/sing-box-amd64" + cp "$arm64_bin" "$dest/sing-box" + chmod +x "$dest/sing-box" "$dest/sing-box-arm64" "$dest/sing-box-amd64" + echo "lipo unavailable; wrote per-arch sing-box $singbox_version and defaulted to arm64 (TODO: lipo into a universal binary on macOS)" + fi +} + +# linux_arch maps `uname -m` onto sing-box's release naming, or echoes an +# explicitly requested architecture back after validating it. Only the two 64-bit +# desktop architectures are pinned: sing-box also publishes 386 and armv7 builds, +# but the bundle they would go into is a webkit2gtk Tauri app, and 32-bit Linux +# desktops are not a target — pinning a digest nobody exercises would be dead +# weight that still has to be re-cut on every version bump. +linux_arch() { + local requested="$1" machine + if [ -n "$requested" ]; then + case "$requested" in + amd64|arm64) echo "$requested"; return 0 ;; + *) echo "unsupported --arch $requested; this script pins amd64 and arm64" >&2; return 1 ;; + esac + fi + machine="$(uname -m)" + case "$machine" in + x86_64|amd64) echo "amd64" ;; + aarch64|arm64) echo "arm64" ;; + *) + echo "unsupported Linux architecture $machine; this script pins amd64 and arm64" >&2 + echo "pass --arch to cross-fetch one of them, or add a pin for $machine" >&2 + return 1 + ;; + esac +} -if command -v lipo >/dev/null 2>&1; then - # Tauri's universal-apple-darwin target expects every bundled binary to be - # universal too, so stitch the two slices into one fat binary. - lipo -create "$arm64_bin" "$amd64_bin" -output "$dest/sing-box" +# fetch_singbox_linux installs the single-architecture ELF for the target. There +# is no lipo equivalent on Linux — an ELF holds exactly one architecture — so the +# bundle is per-arch by construction and the host's architecture is the default. +# The bundled name stays plain `sing-box`, matching the darwin layout the core's +# TENEBRA_SINGBOX resolution and the installers expect. +fetch_singbox_linux() { + local arch bin expected + arch="$(linux_arch "$target_arch")" + case "$arch" in + amd64) expected="$singbox_sha256_linux_amd64" ;; + arm64) expected="$singbox_sha256_linux_arm64" ;; + esac + bin="$(fetch_arch linux "$arch" "$expected")" + cp "$bin" "$dest/sing-box" chmod +x "$dest/sing-box" - echo "Built universal (arm64+amd64) sing-box $singbox_version" -else - # No lipo (not on a macOS host, or the command-line tools are absent): keep the - # per-arch binaries and fall back to the arm64 slice as the default, since the - # hosted macOS runners are all Apple Silicon. A universal bundle still needs a - # lipo pass on a real macOS host. - # TODO(macos): run the lipo step on a macOS host/runner to produce a universal - # sing-box before shipping a universal bundle. - cp "$arm64_bin" "$dest/sing-box-arm64" - cp "$amd64_bin" "$dest/sing-box-amd64" - cp "$arm64_bin" "$dest/sing-box" - chmod +x "$dest/sing-box" "$dest/sing-box-arm64" "$dest/sing-box-amd64" - echo "lipo unavailable; wrote per-arch sing-box $singbox_version and defaulted to arm64 (TODO: lipo into a universal binary on macOS)" -fi + echo "Fetched linux-$arch sing-box $singbox_version" +} + +host_os="$(uname -s)" +case "$host_os" in + Darwin) + [ -z "$target_arch" ] || { echo "--arch is Linux-only; the macOS bundle is always universal" >&2; exit 2; } + fetch_singbox_darwin + ;; + Linux) + fetch_singbox_linux + ;; + *) + echo "unsupported host $host_os; use scripts/fetch-resources.ps1 on Windows" >&2 + exit 1 + ;; +esac # RU geo rule-sets, shipped locally so smart routing loads them from disk instead # of downloading them from GitHub at startup (the download blocks sing-box for diff --git a/scripts/linux/install-daemon.sh b/scripts/linux/install-daemon.sh new file mode 100755 index 0000000..9215c01 --- /dev/null +++ b/scripts/linux/install-daemon.sh @@ -0,0 +1,492 @@ +#!/usr/bin/env bash +# Installs the Tenebra privileged daemon on Linux: a systemd service that runs +# tenebra-core as root so it can open /dev/net/tun and program routes, and serves +# the control protocol on a unix socket an unprivileged GUI attaches to. This is +# the hand-installed path for any distribution — the counterpart of +# scripts/macos/install-daemon.sh. On Arch there is a packaged path instead +# (packaging/arch/PKGBUILD); see docs/porting/linux.md. +# +# Two source modes for the binaries: +# --from-dir copy them out of a built payload directory +# --dev build/collect them from this checkout +# +# Re-run to upgrade in place: it stops the service, replaces the binaries, and +# starts the new ones — and restores the previous install if any step fails. +# Requires root; it re-execs itself under sudo when needed. +set -euo pipefail + +# Absolute path to this script and the repo root, resolved before any sudo +# re-exec so they stay valid regardless of the caller's working directory. +SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)/$(basename "${BASH_SOURCE[0]}")" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." >/dev/null 2>&1 && pwd)" + +# Install destinations. INSTALL_DIR must match the paths baked into the unit +# file; check_unit_paths asserts that rather than trusting it. +readonly UNIT_NAME="tenebra.service" +# /usr/local is the FHS home for locally installed software — the half of the +# hierarchy a distribution's package manager never touches — so a hand-install +# and a distro package (which owns /usr/lib/tenebra) can coexist without either +# overwriting the other. +readonly INSTALL_DIR="/usr/local/lib/tenebra" +readonly UNIT_DST="/etc/systemd/system/${UNIT_NAME}" +readonly PACKAGE_UNIT="/usr/lib/systemd/system/${UNIT_NAME}" +readonly DATA_DIR="/var/lib/tenebra/data" +readonly SOCKET_PATH="/run/tenebra.sock" +readonly MODULES_CONF="/etc/modules-load.d/tenebra.conf" + +# Source of the unit that gets installed: it ships next to this script's repo. +readonly UNIT_SRC="${REPO_ROOT}/deploy/linux/${UNIT_NAME}" +# Where --dev collects the prebuilt resources fetch-resources.sh writes. +readonly DEV_RESOURCE_DIR="${REPO_ROOT}/ui-desktop/src-tauri/resources" + +# Resolved payload, filled by resolve_sources. +MODE="" +SRC_DIR="" +# Set by --allow-unsafe-source: skip the ownership/permission gate on the payload +# directory. Off by default so binaries anyone could have swapped are refused +# rather than installed as a root service; the flag is the deliberate +# dev-convenience escape hatch. +ALLOW_UNSAFE_SOURCE=0 +CORE_SRC="" +SINGBOX_SRC="" +RULESET_SRC_DIR="" +# Temp dir holding a --dev core build, removed after install. Only ever set in +# --dev mode so cleanup can never touch a real payload. +DEV_BUILD_TMP="" +# Temp dir holding the previous install, used to roll back a failed upgrade. +BACKUP_DIR="" +# Set once install_payload starts touching the system, so the EXIT trap knows a +# failure left a half-replaced install behind and must be undone. +INSTALL_STARTED=0 +# Set when main completes, so the EXIT trap can tell success from failure. +INSTALL_DONE=0 + +log() { echo "install-daemon: $*" >&2; } +die() { echo "install-daemon: error: $*" >&2; exit 1; } + +usage() { + cat >&2 <<'EOF' +Usage: install-daemon.sh (--from-dir | --dev) + + --from-dir Install tenebra-core, sing-box and the .srs rule-sets from a + built payload directory (an unpacked AppImage's app dir, an + extracted .deb, or a Tauri bundle directory — a resources/ + subdirectory is picked up automatically). + --dev Build tenebra-core from this checkout and take sing-box plus + the rule-sets from ui-desktop/src-tauri/resources. Run + scripts/fetch-resources.sh first to populate them. + --allow-unsafe-source + Install even if the payload directory is writable by users + other than its owner. For local dev trees on shared + machines only; the default refuses, because everything in + that directory is about to run as root. + +Installs the tenebra systemd service and starts it. Safe to re-run: it upgrades +the binaries in place and rolls back if the upgrade fails. Requires root; +re-execs under sudo when needed. +EOF +} + +# parse_args reads the source mode into MODE (and SRC_DIR for --from-dir). +parse_args() { + while [[ "$#" -gt 0 ]]; do + case "$1" in + --from-dir) + MODE="from-dir" + SRC_DIR="${2:-}" + [[ -n "${SRC_DIR}" ]] || die "--from-dir needs a directory" + shift 2 + ;; + --dev) + MODE="dev" + shift + ;; + --allow-unsafe-source) + ALLOW_UNSAFE_SOURCE=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "unknown argument: $1 (see --help)" + ;; + esac + done + [[ -n "${MODE}" ]] || die "choose a source mode: --from-dir or --dev" +} + +# require_systemd fails early and clearly on a machine this script cannot serve, +# instead of laying files down and then discovering systemctl is missing. +# /run/systemd/system is the documented "systemd is the running init" probe — the +# binary being installed is not enough (a container or an OpenRC host can carry +# it without systemd being PID 1). +require_systemd() { + command -v systemctl >/dev/null 2>&1 \ + || die "systemctl not found; this installer targets systemd (see docs/porting/linux.md for running the core by hand)" + [[ -d /run/systemd/system ]] \ + || die "systemd is not the running init on this machine; start tenebra-core --socket by hand instead" +} + +# resolve_sources fills CORE_SRC, SINGBOX_SRC and RULESET_SRC_DIR for the chosen +# mode. For --dev it builds the core; that build runs as the invoking user +# (before any sudo re-exec) so go uses the user's toolchain and module cache. +resolve_sources() { + case "${MODE}" in + from-dir) resolve_from_dir "${SRC_DIR}" ;; + dev) resolve_dev ;; + *) die "internal: unknown mode ${MODE}" ;; + esac +} + +# resolve_from_dir locates the payload inside a built directory. Two layouts are +# accepted: everything flat in one directory (what an extracted package or this +# repo's resource directory looks like), and a Tauri bundle, which keeps the +# sidecar beside the executable and the bundled resources in resources/. +resolve_from_dir() { + local dir="$1" + [[ -d "${dir}" ]] || die "no such directory: ${dir}" + verify_source_trust "${dir}" + + CORE_SRC="${dir}/tenebra-core" + SINGBOX_SRC="${dir}/sing-box" + RULESET_SRC_DIR="${dir}" + if [[ ! -f "${SINGBOX_SRC}" && -f "${dir}/resources/sing-box" ]]; then + verify_source_trust "${dir}/resources" + SINGBOX_SRC="${dir}/resources/sing-box" + RULESET_SRC_DIR="${dir}/resources" + fi + + [[ -f "${CORE_SRC}" ]] || die "tenebra-core not found at ${CORE_SRC}" + [[ -f "${SINGBOX_SRC}" ]] || die "sing-box not found at ${SINGBOX_SRC} (nor in ${dir}/resources)" +} + +# verify_source_trust refuses a payload directory that users other than its owner +# can write to. Linux has no Gatekeeper to ask — there is no platform signature +# on a plain ELF the way macOS's install script can lean on codesign/spctl — so +# the checkable property left is who could have put these files here. Everything +# in this directory is about to be copied into a root service, so a +# group- or world-writable directory means any local user could have swapped the +# binary that then runs as root. Refusing is fail-closed; +# --allow-unsafe-source is the explicit, logged escape hatch. +verify_source_trust() { + local dir="$1" perm + if [[ "${ALLOW_UNSAFE_SOURCE}" -eq 1 ]]; then + log "warning: --allow-unsafe-source set; not checking who can write to ${dir}" + log "warning: only do this for a directory you control — anything there installs as code that runs as root" + return 0 + fi + perm="$(stat -c '%a' "${dir}")" || die "cannot stat ${dir}" + if (( 0"${perm}" & 0022 )); then + die "${dir} is group/other-writable (mode ${perm}); another local user could swap the binaries this installs as root. Fix the permissions, or pass --allow-unsafe-source." + fi +} + +# validate_handoff_core hardens the --dev sudo hand-off. resolve_dev's fast path +# trusts _TENEBRA_CORE_BIN to name the core built moments earlier by the invoking +# user (build-as-user keeps go on that user's toolchain and module cache). But +# the environment is an attacker-influenceable channel: a sudoers rule that +# permits this script, or an env_keep entry, would let someone seed the variable +# with a planted binary that we would then install as a root service — straight +# privilege escalation. So the elevated pass refuses to install anything it +# cannot tie back to a build its own non-root half could have produced: a regular +# file (never a symlink) named tenebra-core, inside a tenebra-core-build.* temp +# dir under the expected TMPDIR, owned by the very user who invoked sudo, in a +# directory that user owns and no other user can write. A path pointing anywhere +# else — a world-writable drop, another user's file, a symlink to a system binary +# — fails closed here rather than reaching install_payload. +validate_handoff_core() { + local given="$1" + # SUDO_UID is set only when we genuinely re-exec'd from a normal user via sudo. + # Its absence means the variable was set some other way (e.g. straight `sudo + # env _TENEBRA_CORE_BIN=... install-daemon.sh`), which is exactly the injection + # we refuse: there is no trusted invoking user to bind the artifact to. + local invoker="${SUDO_UID:-}" + [[ -n "${invoker}" && "${invoker}" != "0" ]] \ + || die "refusing _TENEBRA_CORE_BIN hand-off: no unprivileged SUDO_UID. Run 'install-daemon.sh --dev' and let it re-exec; do not set _TENEBRA_CORE_BIN yourself." + + # A final symlink could smuggle in a system binary the checks below would then + # read through; reject it outright, then resolve the real target. + [[ ! -L "${given}" ]] || die "refusing _TENEBRA_CORE_BIN hand-off: ${given} is a symlink" + local real + real="$(realpath "${given}" 2>/dev/null)" || die "refusing _TENEBRA_CORE_BIN hand-off: cannot resolve ${given}" + [[ -f "${real}" ]] || die "refusing _TENEBRA_CORE_BIN hand-off: ${real} is not a regular file" + [[ "$(basename "${real}")" == "tenebra-core" ]] \ + || die "refusing _TENEBRA_CORE_BIN hand-off: unexpected basename $(basename "${real}"), want tenebra-core" + + # Must sit directly inside a tenebra-core-build.* dir under the same temp root + # resolve_dev's mktemp uses. Both sides are canonicalised so a symlinked TMPDIR + # does not defeat the match. + local tmp_root build_dir + tmp_root="$(realpath "${TMPDIR:-/tmp}" 2>/dev/null)" || die "cannot resolve TMPDIR" + build_dir="$(dirname "${real}")" + case "${build_dir}" in + "${tmp_root}"/tenebra-core-build.*) : ;; + *) die "refusing _TENEBRA_CORE_BIN hand-off: ${real} is not under a tenebra-core-build.* dir in ${tmp_root}" ;; + esac + + # The build dir and the artifact must be owned by the invoking user, and the + # dir must not be group/other-writable, so no second party could have swapped + # the binary in between the build and this install. + local owner perm + owner="$(stat -c '%u' "${build_dir}")" || die "cannot stat ${build_dir}" + [[ "${owner}" == "${invoker}" ]] \ + || die "refusing _TENEBRA_CORE_BIN hand-off: ${build_dir} owned by uid ${owner}, not the invoking user (uid ${invoker})" + owner="$(stat -c '%u' "${real}")" || die "cannot stat ${real}" + [[ "${owner}" == "${invoker}" ]] \ + || die "refusing _TENEBRA_CORE_BIN hand-off: ${real} owned by uid ${owner}, not the invoking user (uid ${invoker})" + perm="$(stat -c '%a' "${build_dir}")" || die "cannot stat ${build_dir}" + if (( 0"${perm}" & 0022 )); then + die "refusing _TENEBRA_CORE_BIN hand-off: build dir ${build_dir} is group/other-writable (mode ${perm})" + fi +} + +# resolve_dev builds the core from the checkout and takes sing-box plus the +# rule-sets from the fetched resource directory. +resolve_dev() { + # A prior sudo re-exec hands the already-built core back through this env var + # so it is built exactly once, as the invoking user, not again as root. The + # env channel is attacker-influenceable, so the handed path is validated + # against what our own non-root half could have produced before it is trusted. + if [[ -n "${_TENEBRA_CORE_BIN:-}" ]]; then + validate_handoff_core "${_TENEBRA_CORE_BIN}" + CORE_SRC="${_TENEBRA_CORE_BIN}" + DEV_BUILD_TMP="$(dirname "${CORE_SRC}")" + else + command -v go >/dev/null 2>&1 || die "go not found on PATH; install Go or use --from-dir" + DEV_BUILD_TMP="$(mktemp -d "${TMPDIR:-/tmp}/tenebra-core-build.XXXXXX")" + CORE_SRC="${DEV_BUILD_TMP}/tenebra-core" + log "building tenebra-core from ${REPO_ROOT}" + ( cd "${REPO_ROOT}" && go build -o "${CORE_SRC}" ./cmd/tenebra-core ) || die "go build failed" + fi + + SINGBOX_SRC="${DEV_RESOURCE_DIR}/sing-box" + RULESET_SRC_DIR="${DEV_RESOURCE_DIR}" + [[ -f "${SINGBOX_SRC}" ]] || die "sing-box not found at ${SINGBOX_SRC}; run scripts/fetch-resources.sh first" +} + +# ensure_root re-execs the script under sudo when not already root. Everything +# after this point needs root: writing under /usr/local and /etc, loading the tun +# module and opening the device all do. The --dev build has already run as the +# invoking user; its result is forwarded so the elevated pass reuses it instead +# of rebuilding as root. +ensure_root() { + [[ "${EUID}" -eq 0 ]] && return 0 + log "requesting administrator privileges via sudo" + if [[ "${MODE}" == "dev" ]]; then + exec sudo "_TENEBRA_CORE_BIN=${CORE_SRC}" -- "${SELF}" "$@" + else + exec sudo -- "${SELF}" "$@" + fi +} + +# check_unit_paths refuses to install a unit that points somewhere other than +# where this script puts the binaries. The two files are edited independently, so +# a drifted path would otherwise surface as a service that starts, fails and +# restarts forever with a bare "No such file or directory". +check_unit_paths() { + [[ -f "${UNIT_SRC}" ]] || die "unit not found at ${UNIT_SRC}" + grep -qF "ExecStart=${INSTALL_DIR}/tenebra-core" "${UNIT_SRC}" \ + || die "${UNIT_SRC} does not start ${INSTALL_DIR}/tenebra-core; the unit and this installer disagree on the install prefix" + grep -qF "TENEBRA_SINGBOX=${INSTALL_DIR}/sing-box" "${UNIT_SRC}" \ + || die "${UNIT_SRC} does not point TENEBRA_SINGBOX at ${INSTALL_DIR}/sing-box; the unit and this installer disagree on the install prefix" +} + +# warn_packaged_install points out an existing distro package. A unit in +# /etc/systemd/system shadows the one a package ships in /usr/lib/systemd/system +# — systemd's own precedence rule — so the hand-install silently wins and pacman +# would later update binaries nothing runs. That is confusing enough to be worth +# saying out loud, but it is a legitimate thing to do deliberately, so it is a +# warning and not a refusal. +warn_packaged_install() { + [[ -f "${PACKAGE_UNIT}" ]] || return 0 + log "warning: ${PACKAGE_UNIT} exists, so Tenebra is already installed as a distribution package" + log "warning: ${UNIT_DST} takes precedence over it; uninstall the package, or remove ${UNIT_DST} to go back to it" +} + +# ensure_tun_module makes /dev/net/tun available before the service needs it. The +# unit deliberately keeps CAP_SYS_MODULE out of its bounding set, so the daemon +# cannot pull the module in itself, and on a kernel that ships tun as a module +# and has not loaded it yet the first connect would fail with an opaque ENODEV. +# Loading it now covers this boot; the modules-load.d drop-in covers every later +# one. Neither is fatal — plenty of kernels have tun built in, where both steps +# are simply redundant. +ensure_tun_module() { + if [[ ! -c /dev/net/tun ]]; then + if modprobe tun 2>/dev/null; then + log "loaded the tun kernel module" + else + log "warning: could not load the tun module and /dev/net/tun is absent; the tunnel will not open until it is available" + fi + fi + install -d -o root -g root -m 755 "$(dirname "${MODULES_CONF}")" + printf '# Tenebra opens /dev/net/tun; make sure the module is present at boot.\ntun\n' >"${MODULES_CONF}" + chmod 644 "${MODULES_CONF}" +} + +# stop_running stops an active service before its binaries are replaced. This is +# not optional on Linux the way it is on macOS: overwriting a running executable +# fails outright with ETXTBSY, so an upgrade that skipped this would abort +# halfway. It also means an established tunnel drops here — the daemon owns it, +# so replacing the daemon necessarily interrupts it. +stop_running() { + systemctl is-active --quiet "${UNIT_NAME}" || return 0 + log "stopping the running service (an established tunnel will drop)" + systemctl stop "${UNIT_NAME}" +} + +# stage_previous copies the current install aside so a failure can put it back. +# Only the pieces this script replaces are kept: the payload directory and the +# unit file. The data directory is never touched by an upgrade, so it needs no +# backup. +stage_previous() { + BACKUP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/tenebra-install-backup.XXXXXX")" + if [[ -d "${INSTALL_DIR}" ]]; then + cp -a "${INSTALL_DIR}" "${BACKUP_DIR}/payload" + fi + if [[ -f "${UNIT_DST}" ]]; then + cp -a "${UNIT_DST}" "${BACKUP_DIR}/${UNIT_NAME}" + fi +} + +# rollback restores the staged install after a failed upgrade, so a broken run +# leaves the machine on the daemon it had rather than on a half-replaced one. A +# first install has nothing to restore: it removes what it managed to create +# instead, which is equally "back where we started". +rollback() { + log "install failed; restoring the previous state" + systemctl stop "${UNIT_NAME}" 2>/dev/null || true + rm -rf "${INSTALL_DIR}" + if [[ -d "${BACKUP_DIR}/payload" ]]; then + cp -a "${BACKUP_DIR}/payload" "${INSTALL_DIR}" + fi + if [[ -f "${BACKUP_DIR}/${UNIT_NAME}" ]]; then + cp -a "${BACKUP_DIR}/${UNIT_NAME}" "${UNIT_DST}" + else + rm -f "${UNIT_DST}" + fi + systemctl daemon-reload || true + # Only bring the service back if there is something to bring back; after a + # failed first install there is not, and enabling a removed unit would fail. + if [[ -f "${UNIT_DST}" ]]; then + systemctl start "${UNIT_NAME}" 2>/dev/null || log "warning: the restored service did not start; check 'journalctl -u ${UNIT_NAME}'" + fi + log "restored" +} + +# install_payload lays down the binaries and rule-sets with the ownership and +# modes a root service expects. +install_payload() { + log "installing binaries into ${INSTALL_DIR}" + # root:root 755 directory, so only root can replace what systemd runs as root. + install -d -o root -g root -m 755 "${INSTALL_DIR}" + # Executables 755; overwriting in place is what makes a re-run an upgrade. + install -o root -g root -m 755 "${CORE_SRC}" "${INSTALL_DIR}/tenebra-core" + install -o root -g root -m 755 "${SINGBOX_SRC}" "${INSTALL_DIR}/sing-box" + install_rulesets + # The machine-scoped store. The core creates and clamps this itself on every + # start, and the unit's StateDirectory= would too; creating it here as well + # means the layout is right and inspectable before the service has ever run. + install -d -o root -g root -m 700 "$(dirname "${DATA_DIR}")" + install -d -o root -g root -m 700 "${DATA_DIR}" +} + +# install_rulesets copies every .srs rule-set found beside the source binaries. +# The core only loads them locally when the full set is present, otherwise it +# falls back to a remote download; a missing set is therefore a warning, not a +# failure. +install_rulesets() { + local found=0 f + shopt -s nullglob + for f in "${RULESET_SRC_DIR}"/*.srs; do + install -o root -g root -m 644 "${f}" "${INSTALL_DIR}/$(basename "${f}")" + found=1 + done + shopt -u nullglob + if [[ "${found}" -eq 0 ]]; then + log "warning: no .srs rule-sets in ${RULESET_SRC_DIR}; smart routing will download them at connect time" + fi +} + +# install_unit copies the service file into /etc/systemd/system, the location +# reserved for units the administrator installs (as opposed to /usr/lib, which +# belongs to the package manager). 644 root:root is what systemd expects of a +# unit it will run as root. +install_unit() { + log "installing ${UNIT_NAME} to ${UNIT_DST}" + install -o root -g root -m 644 "${UNIT_SRC}" "${UNIT_DST}" +} + +# enable_service reloads systemd's view of the unit and starts it, enabling it so +# the daemon comes back after a reboot. `enable --now` is idempotent: on a re-run +# the symlink is already there and only the start happens — and the start is what +# an upgrade needs, because stop_running has already put the service down to free +# the binaries it replaced. +enable_service() { + systemctl daemon-reload + log "enabling and starting ${UNIT_NAME}" + systemctl enable --now "${UNIT_NAME}" +} + +# await_socket polls briefly for the control socket. Its appearance is the crisp +# "the daemon is up and answering" signal; absence after the budget means the +# core failed to start, so point the user at the journal instead of claiming +# success. +await_socket() { + local waited=0 + local budget=10 + while [[ "${waited}" -lt "${budget}" ]]; do + if [[ -S "${SOCKET_PATH}" ]]; then + log "done: ${UNIT_NAME} is running and ${SOCKET_PATH} is live" + return 0 + fi + sleep 1 + waited=$((waited + 1)) + done + log "the control socket ${SOCKET_PATH} did not appear within ${budget}s" + log "inspect 'journalctl -u ${UNIT_NAME} -n 50' and 'systemctl status ${UNIT_NAME}'" + return 1 +} + +# cleanup removes the --dev build's temp dir and the upgrade backup, and rolls +# back if the run died after it started replacing files. DEV_BUILD_TMP is only +# ever set in --dev mode, so this can never delete a real payload; exec (in +# ensure_root) does not fire an EXIT trap, so a temp built before the sudo +# re-exec survives into the elevated pass and is cleaned up there. +cleanup() { + if [[ "${INSTALL_DONE}" -eq 0 && "${INSTALL_STARTED}" -eq 1 ]]; then + rollback + fi + if [[ -n "${DEV_BUILD_TMP}" ]] && [[ -d "${DEV_BUILD_TMP}" ]]; then + rm -rf "${DEV_BUILD_TMP}" + fi + if [[ -n "${BACKUP_DIR}" ]] && [[ -d "${BACKUP_DIR}" ]]; then + rm -rf "${BACKUP_DIR}" + fi +} + +main() { + trap cleanup EXIT + parse_args "$@" + require_systemd + resolve_sources + ensure_root "$@" + check_unit_paths + warn_packaged_install + stage_previous + INSTALL_STARTED=1 + stop_running + ensure_tun_module + install_payload + install_unit + enable_service + # From here the install itself is complete, so a socket that never shows up is + # reported rather than rolled back: the operator needs the failed daemon and + # its journal to diagnose, not a silent revert to the previous binaries. + INSTALL_DONE=1 + await_socket +} + +main "$@" diff --git a/scripts/linux/uninstall-daemon.sh b/scripts/linux/uninstall-daemon.sh new file mode 100755 index 0000000..93c3ac5 --- /dev/null +++ b/scripts/linux/uninstall-daemon.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# Removes the Tenebra privileged daemon installed by install-daemon.sh: it stops +# and disables the systemd service and deletes the unit, the binaries and the +# tun modules-load drop-in. User data under /var/lib/tenebra is kept by default; +# pass --purge to remove it too. Requires root; it re-execs itself under sudo +# when needed. See docs/porting/linux.md. +# +# This removes a hand-install only. A Tenebra installed from a distribution +# package is removed with that package manager (`pacman -Rns tenebra` on Arch); +# the paths below are deliberately the /usr/local ones a package never owns. +set -euo pipefail + +SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)/$(basename "${BASH_SOURCE[0]}")" + +readonly UNIT_NAME="tenebra.service" +readonly INSTALL_DIR="/usr/local/lib/tenebra" +readonly UNIT_DST="/etc/systemd/system/${UNIT_NAME}" +readonly PACKAGE_UNIT="/usr/lib/systemd/system/${UNIT_NAME}" +readonly DATA_PARENT="/var/lib/tenebra" +readonly MODULES_CONF="/etc/modules-load.d/tenebra.conf" + +PURGE=0 + +log() { echo "uninstall-daemon: $*" >&2; } +die() { echo "uninstall-daemon: error: $*" >&2; exit 1; } + +usage() { + cat >&2 <<'EOF' +Usage: uninstall-daemon.sh [--purge] + + --purge Also remove user data (/var/lib/tenebra), which holds your profiles + and their subscription credentials. Without it the data is kept, so a + later re-install picks up where you left off. + +Stops, disables and removes the tenebra systemd service. Requires root; +re-execs under sudo when needed. +EOF +} + +parse_args() { + while [[ "$#" -gt 0 ]]; do + case "$1" in + --purge) + PURGE=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "unknown argument: $1 (see --help)" + ;; + esac + done +} + +# ensure_root re-execs under sudo when not already root: stopping a system +# service and deleting under /usr/local and /etc both need it. Arguments +# (including --purge) are preserved across the re-exec. +ensure_root() { + [[ "${EUID}" -eq 0 ]] && return 0 + log "requesting administrator privileges via sudo" + exec sudo -- "${SELF}" "$@" +} + +# stop_daemon stops and disables the service if systemd knows about it. Every +# step tolerates absence, because a partial or repeated uninstall is a normal +# thing to run. With no systemctl at all there is nothing to stop — the files are +# still removed below. +stop_daemon() { + command -v systemctl >/dev/null 2>&1 || return 0 + if systemctl is-active --quiet "${UNIT_NAME}"; then + log "stopping ${UNIT_NAME} (an established tunnel will drop)" + systemctl stop "${UNIT_NAME}" || true + fi + # disable removes the multi-user.target want; harmless when it was never + # enabled, and required so a leftover symlink does not point at a deleted unit. + systemctl disable "${UNIT_NAME}" >/dev/null 2>&1 || true +} + +# remove_files deletes the unit, the binaries and the modules-load drop-in. Every +# path is a fixed constant, never derived from input, so the rm -rf cannot widen. +# daemon-reload afterwards is what makes systemd forget the unit it just lost; +# reset-failed clears a failed state a crashing daemon may have left behind, so +# `systemctl status` does not keep reporting a service that no longer exists. +remove_files() { + rm -f "${UNIT_DST}" + rm -rf "${INSTALL_DIR}" + rm -f "${MODULES_CONF}" + log "removed ${UNIT_DST}, ${INSTALL_DIR} and ${MODULES_CONF}" + if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload || true + systemctl reset-failed "${UNIT_NAME}" >/dev/null 2>&1 || true + fi +} + +# warn_packaged_install fires when a distribution package is still installed. The +# hand-installed unit in /etc was shadowing the packaged one; removing it hands +# control back to the package, which is very likely not what someone running an +# uninstall script expects to happen. +warn_packaged_install() { + [[ -f "${PACKAGE_UNIT}" ]] || return 0 + log "note: ${PACKAGE_UNIT} is still present, so the packaged Tenebra remains installed" + log "note: it is no longer shadowed — remove it with your package manager if you wanted everything gone" +} + +# purge_data removes the machine-scoped store. Only reached with --purge, because +# it destroys the imported profiles rather than just the software. +purge_data() { + rm -rf "${DATA_PARENT}" + log "purged user data (${DATA_PARENT})" +} + +main() { + parse_args "$@" + ensure_root "$@" + stop_daemon + remove_files + if [[ "${PURGE}" -eq 1 ]]; then + purge_data + else + log "kept user data (${DATA_PARENT}); pass --purge to remove it" + fi + warn_packaged_install + log "uninstalled ${UNIT_NAME}" +} + +main "$@" diff --git a/scripts/set-version.mjs b/scripts/set-version.mjs index ab8e26c..c87cca2 100644 --- a/scripts/set-version.mjs +++ b/scripts/set-version.mjs @@ -7,11 +7,11 @@ // 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 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 to catch that). Run this instead of -// editing the four files by hand. +// 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 +// to catch that). Run this instead of editing the files by hand. import { readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -36,6 +36,12 @@ const targets = [ // The Go core's own copy: the daemon reports it in every State snapshot so a // GUI can spot a stale hand-installed daemon (the macOS LaunchDaemon path). { file: "core/buildinfo/buildinfo.go", re: /(^const Version = ")([^"]+)(")/m }, + // The Arch package's version doubles as the git tag its source is taken from, + // so a stale copy here does not merely mislabel the package — makepkg would + // check out the wrong release, or fail outright on a tag that does not exist + // yet. Anchored to the line so the pinned sing-box version below it is left + // alone. + { file: "packaging/arch/PKGBUILD", re: /(^pkgver=)([^\s]+)()/m }, ]; const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; diff --git a/ui-desktop/package.json b/ui-desktop/package.json index 5f19afb..8fbeddc 100644 --- a/ui-desktop/package.json +++ b/ui-desktop/package.json @@ -1,6 +1,6 @@ { "name": "tenebra-desktop", - "version": "0.4.5", + "version": "0.4.6", "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 851fab6..2d544b3 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.4.5" +version = "0.4.6" dependencies = [ "open", "serde", diff --git a/ui-desktop/src-tauri/Cargo.toml b/ui-desktop/src-tauri/Cargo.toml index 02a0921..fcbbcf4 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.4.5" +version = "0.4.6" description = "Tenebra desktop client" authors = ["Tenebra contributors"] license = "GPL-3.0-only" diff --git a/ui-desktop/src-tauri/linux/tenebra.desktop b/ui-desktop/src-tauri/linux/tenebra.desktop new file mode 100644 index 0000000..270a9ee --- /dev/null +++ b/ui-desktop/src-tauri/linux/tenebra.desktop @@ -0,0 +1,20 @@ +[Desktop Entry] +# Handlebars template for the desktop entry the bundler writes into the .deb and +# the AppImage (bundle > linux > deb > desktopTemplate; the AppImage is built +# from the same tree). It is the bundler's own default with one addition: the %u +# field code on Exec. MimeType below is filled in from the deep-link schemes, so +# without %u the desktop environment would hand a tenebra:// link to a launcher +# that drops it — the app would come up with no link and nothing would happen. +Categories={{categories}} +{{#if comment}} +Comment={{comment}} +{{/if}} +Exec={{exec}} %u +StartupWMClass={{exec}} +Icon={{icon}} +Name={{name}} +Terminal=false +Type=Application +{{#if mime_type}} +MimeType={{mime_type}} +{{/if}} diff --git a/ui-desktop/src-tauri/src/backend/mod.rs b/ui-desktop/src-tauri/src/backend/mod.rs index 9f4132e..960d621 100644 --- a/ui-desktop/src-tauri/src/backend/mod.rs +++ b/ui-desktop/src-tauri/src/backend/mod.rs @@ -6,10 +6,10 @@ //! transport-agnostic protocol client; [`sidecar`] runs it over a spawned //! core's stdin/stdout; [`pipe`] (Windows) runs it over the named pipe of a //! core that outlives the GUI — the service or `tenebra-core --pipe`; [`unix`] -//! (macOS) runs it over the unix domain socket of a root LaunchDaemon that -//! likewise outlives the GUI — `tenebra-core --socket`; [`mock`] is an -//! in-process fake for UI work without the core. `make_backend` in `lib.rs` -//! picks one at startup. +//! (macOS and Linux) runs it over the unix domain socket of a root daemon that +//! likewise outlives the GUI — the macOS LaunchDaemon or the Linux systemd +//! service, both `tenebra-core --socket`; [`mock`] is an in-process fake for UI +//! work without the core. `make_backend` in `lib.rs` picks one at startup. //! //! The structs below mirror the protocol's `State`, `Node`, `Profile` and //! `PingResult` shapes. They serialize to exactly the JSON the front-end types @@ -21,7 +21,10 @@ pub mod pipe; pub mod sidecar; #[cfg(test)] pub mod testutil; -#[cfg(target_os = "macos")] +// Every platform whose core runs as a socket-serving daemon. Spelled out rather +// than `cfg(unix)` so a future mobile target of this crate can't silently +// acquire a transport that has no daemon behind it. +#[cfg(any(target_os = "macos", target_os = "linux"))] pub mod unix; pub mod wire; diff --git a/ui-desktop/src-tauri/src/backend/pipe.rs b/ui-desktop/src-tauri/src/backend/pipe.rs index 6f2fb2c..1009e1b 100644 --- a/ui-desktop/src-tauri/src/backend/pipe.rs +++ b/ui-desktop/src-tauri/src/backend/pipe.rs @@ -307,7 +307,7 @@ fn reconnecting_state() -> State { preset_ru_gov: None, crash_reports: None, crash_reports_asked: false, - error: Some("Reconnecting to the Tenebra service…".to_string()), + error: Some("Reconnecting to the Tenebra service…".to_string()), } } diff --git a/ui-desktop/src-tauri/src/backend/sidecar.rs b/ui-desktop/src-tauri/src/backend/sidecar.rs index 8ad7263..6aa9c99 100644 --- a/ui-desktop/src-tauri/src/backend/sidecar.rs +++ b/ui-desktop/src-tauri/src/backend/sidecar.rs @@ -107,7 +107,21 @@ impl SidecarBackend { let dir = exe .parent() .ok_or_else(|| format!("app executable {} has no parent directory", exe.display()))?; - resolve_core_in(dir) + match resolve_core_in(dir) { + Ok(path) => Ok(path), + // A native package need not keep the two side by side the way a + // Tauri bundle does, so fall back to the same system locations the + // bundled resources are looked for in (see + // `crate::packaged_resource_paths`) before giving up. Absolute + // paths only, so this stays as fail-closed as the directory scan. + Err(e) => { + let name = format!("tenebra-core{}", std::env::consts::EXE_SUFFIX); + crate::packaged_resource_paths(&name) + .into_iter() + .find(|p| p.exists()) + .ok_or(e) + } + } } } @@ -149,9 +163,9 @@ impl WireSession for SidecarBackend { } /// Where the core's stderr diagnostics are written: `core.log` in the Tenebra -/// data directory (`%LOCALAPPDATA%\Tenebra`, temp-dir fallback), so a user hitting -/// a problem has one file to share. Shares [`crash::data_dir`](crate::crash::data_dir) -/// with the GUI crash log so both land in the same place. +/// data directory (per platform — see [`crash::data_dir`](crate::crash::data_dir)), +/// so a user hitting a problem has one file to share. Shares that directory with +/// the GUI crash log so both land in the same place. fn core_log_path() -> Option { crate::crash::data_dir().map(|d| d.join("core.log")) } diff --git a/ui-desktop/src-tauri/src/backend/unix.rs b/ui-desktop/src-tauri/src/backend/unix.rs index bda934c..f27e4d5 100644 --- a/ui-desktop/src-tauri/src/backend/unix.rs +++ b/ui-desktop/src-tauri/src/backend/unix.rs @@ -1,6 +1,8 @@ -//! The unix-domain-socket transport: a client of the core running detached from -//! the GUI as a root LaunchDaemon — `tenebra-core --socket`, which serves the -//! control protocol on `/var/run/tenebra.sock`. +//! The unix-domain-socket transport: a client of the core running detached from +//! the GUI as a privileged daemon — `tenebra-core --socket`, which serves the +//! control protocol on a well-known socket: the root LaunchDaemon's +//! `/var/run/tenebra.sock` on macOS, the systemd service's `/run/tenebra.sock` +//! on Linux (see [`SOCKET_PATH`]). //! //! The daemon listens on that socket through the same transport-agnostic //! `ServeListener` the Windows pipe uses (see `core/control/listener.go` and the @@ -11,6 +13,11 @@ //! and the unprivileged GUI needs no elevation of its own because the privileged //! tunnel already lives in the daemon. //! +//! - **Patience on the first dial.** Where the daemon comes up alongside the +//! desktop session rather than long before it, the opening dial waits a +//! bounded [`DIAL_ABSENT_WAIT`] for a listener to appear rather than reading +//! its absence as "no daemon on this machine" — the transport is chosen once +//! per run, so that single answer decides the whole session. //! - **Re-sync on connect.** The socket delivers no backlog of events on attach, //! so the state on connect is whatever it already is. Every new session //! therefore opens with a `status` request and pushes the answer at the UI. @@ -45,7 +52,7 @@ //! file's own permissions, which the daemon (the core) sets when it binds. The //! GUI only dials, and cannot be tricked into lending an identity it never had. -use std::io::{Read, Write}; +use std::io::{self, Read, Write}; use std::net::Shutdown; use std::os::unix::net::UnixStream; use std::sync::atomic::{AtomicBool, Ordering}; @@ -57,9 +64,14 @@ use std::time::{Duration, Instant}; use super::wire::{obj, read_loop, WireClient, WireSession}; use super::{ConnectionState, EventSink, State}; -/// The well-known control socket, mirroring the path the macOS LaunchDaemon -/// binds on the Go side. +/// The well-known control socket, mirroring `control.DefaultSocketPath` on the +/// Go side. The two platforms put a runtime socket in different places: macOS +/// daemons still bind under `/var/run`, while on Linux `/run` is the canonical +/// tmpfs systemd itself uses and `/var/run` is only a compatibility symlink. +#[cfg(target_os = "macos")] pub const SOCKET_PATH: &str = "/var/run/tenebra.sock"; +#[cfg(target_os = "linux")] +pub const SOCKET_PATH: &str = "/run/tenebra.sock"; /// Reconnect backoff: first retry comes quickly (the common loss is a daemon /// restart or a displaced session, both back within a second), then doubles to @@ -76,6 +88,33 @@ const MAX_BACKOFF: Duration = Duration::from_secs(5); /// a genuinely stopped daemon in single-digit seconds. const RECONNECT_GRACE: Duration = Duration::from_secs(8); +/// How long the *first* dial keeps asking for a socket nothing is answering on +/// yet. The GUI picks its transport exactly once (`make_backend` in `lib.rs`), +/// so losing a start-up race here does not cost a beat but the whole run: the +/// app spends it on an unprivileged core with its own profile store, where the +/// profile list is empty and Connect appears to do nothing. +/// +/// On Linux that race is ordinary. The daemon is a systemd unit started +/// alongside everything else the machine brings up, while the GUI is launched by +/// the desktop session — at login they start concurrently, and the core still +/// has to load its store before it binds. Five seconds covers that with room to +/// spare while staying under the eight-second [`RECONNECT_GRACE`] the mid-run +/// path already treats as "not a failure yet", and the dial re-attempts every +/// [`DIAL_RETRY_TICK`], so a listener that arrives early is picked up within +/// tens of milliseconds. +/// +/// macOS gets no budget. launchd loads the daemon at boot, long before anyone +/// opens the app, so a socket that is absent at launch means it was never +/// installed — the ordinary state for a client running its own core — and +/// waiting would delay every one of those launches for an answer already known. +#[cfg(target_os = "linux")] +const DIAL_ABSENT_WAIT: Duration = Duration::from_secs(5); +#[cfg(not(target_os = "linux"))] +const DIAL_ABSENT_WAIT: Duration = Duration::ZERO; + +/// How often a dial re-attempts while it is waiting out a transient failure. +const DIAL_RETRY_TICK: Duration = Duration::from_millis(50); + /// The socket path the GUI should dial, or `None` to skip the unix-socket /// transport entirely. Honors `TENEBRA_SOCKET`: unset or empty means the /// well-known path, `off`/`0` disables it (handy in development, where a running @@ -94,6 +133,45 @@ fn path_from(value: Option<&str>) -> Option { } } +/// Whether a daemon is accepting on `path` right now, asked without connecting. +/// +/// A dial would answer the same question, at a price this must not pay: sessions +/// are last-writer-wins (see the Transports section of +/// `docs/control-protocol.md`), so a dial used as a probe would displace +/// whichever client currently holds the daemon. `/proc/net/unix` lists every +/// unix socket the kernel holds, and `SO_ACCEPTCON` is set on exactly the ones a +/// server is accepting on, so the answer comes out of a read that touches +/// nothing. +/// +/// Linux-only, because it is the one platform that publishes that table; macOS +/// has no equivalent, which is why the late-daemon watch in `lib.rs` is +/// Linux-only too. The answer is a snapshot: callers should read `false` as "not +/// this instant", never as "there is no daemon on this machine". +#[cfg(target_os = "linux")] +pub fn is_listening(path: &str) -> bool { + std::fs::read_to_string("/proc/net/unix").is_ok_and(|table| listening_in(&table, path)) +} + +/// The `/proc/net/unix` reading behind [`is_listening`], split out so the table +/// format can be checked without a live socket. +/// +/// Columns are `Num RefCount Protocol Flags Type St Inode Path`, with Flags in +/// hex; a socket a server is accepting on carries `SO_ACCEPTCON` there. The path +/// is the last column and is absent for anonymous sockets, so a short line is +/// simply not ours — as is the header, whose non-numeric Flags cannot parse. +#[cfg(target_os = "linux")] +fn listening_in(table: &str, path: &str) -> bool { + const SO_ACCEPTCON: u32 = 0x0001_0000; + table.lines().any(|line| { + let mut columns = line.split_whitespace(); + // Flags is column 3; the path is column 7, three past it. + let (Some(flags), Some(entry)) = (columns.nth(3), columns.nth(3)) else { + return false; + }; + entry == path && u32::from_str_radix(flags, 16).is_ok_and(|f| f & SO_ACCEPTCON != 0) + }) +} + /// One dialed connection, as the halves the wire client consumes plus the spare /// handle used to wake the reader on teardown. struct Conn { @@ -142,10 +220,28 @@ impl UnixBackend { /// caller can fall back to another transport when no core is listening; /// after that the connection is supervised — lost sessions reconnect with /// backoff and re-sync — until the backend is dropped. + /// + /// It is as patient as the platform warrants: a daemon that is merely still + /// starting answers within [`DIAL_ABSENT_WAIT`], and only a socket nobody + /// serves in that window is reported as "no core here" for the caller to + /// fall back on. pub fn connect(path: &str, sink: Arc) -> Result { + Self::connect_within(path, sink, DIAL_ABSENT_WAIT) + } + + /// [`connect`](Self::connect) with an explicit budget for a socket nothing + /// answers on yet — [`DIAL_ABSENT_WAIT`] in production, set by the tests + /// that pin both the patient and the fail-fast behaviour on either platform. + fn connect_within( + path: &str, + sink: Arc, + absent_wait: Duration, + ) -> Result { let stop = Arc::new(AtomicBool::new(false)); let mut dialer = UnixDialer { path: path.to_string(), + stop: Arc::clone(&stop), + absent_wait, }; let first = dialer.dial()?; Self::start(first, dialer, sink, stop, RECONNECT_GRACE) @@ -253,7 +349,7 @@ fn reconnecting_state() -> State { preset_ru_gov: None, crash_reports: None, crash_reports_asked: false, - error: Some("Reconnecting to the Tenebra daemon…".to_string()), + error: Some("Reconnecting to the Tenebra daemon…".to_string()), } } @@ -430,18 +526,25 @@ fn serve_session(conn: Conn, shared: &Arc, sink: &Arc /// writer over one connection, plus a spare clone kept for `shutdown`. struct UnixDialer { path: String, + stop: Arc, + /// How long the *next* dial waits for a socket nothing answers on yet. The + /// first dial spends it and leaves it at zero: startup is the one moment an + /// absent daemon is worth waiting out, because the choice of transport hangs + /// on that single answer. The supervisor's redials must come back promptly + /// instead — they already have their own backoff, and the grace escalation + /// is timed against the moment each redial is due, so a dial that parked for + /// seconds inside that schedule would delay the honest "the daemon is still + /// unreachable" report it exists to produce. + absent_wait: Duration, } impl Dial for UnixDialer { fn dial(&mut self) -> Result { - // `connect` either succeeds or fails at once — a missing socket file is - // `ENOENT`, a bound-but-unserved one `ECONNREFUSED` — so unlike the - // named pipe there is no "server exists but has no free instance" - // transient to wait through. A failure here is the caller's to judge: at - // startup it selects the sidecar fallback, mid-run it feeds the - // reconnect backoff. - let stream = - UnixStream::connect(&self.path).map_err(|e| format!("connect {}: {e}", self.path))?; + // A failure here is the caller's to judge: at startup it selects the + // sidecar fallback, mid-run it feeds the reconnect backoff. + let absent_wait = std::mem::take(&mut self.absent_wait); + let stream = open_socket(&self.path, &self.stop, absent_wait) + .map_err(|e| format!("connect {}: {e}", self.path))?; // Both halves and the wake handle are clones of one socket: the kernel // reference-counts them, and a blocking read on one never blocks a write // on another (full-duplex), so no polling is needed to keep writes live. @@ -459,6 +562,44 @@ impl Dial for UnixDialer { } } +/// Connect to `path`, waiting out the failures that mean "ask again in a +/// moment" for `absent_wait` — [`DIAL_ABSENT_WAIT`] on the first dial, zero on +/// every later one, see [`UnixDialer::absent_wait`]. Any other failure is +/// returned at once: unlike the two below it describes a standing condition +/// (`EACCES` on the socket above all), which a retry loop would only turn into a +/// stall. +fn open_socket( + path: &str, + stop: &Arc, + absent_wait: Duration, +) -> io::Result { + let started = Instant::now(); + loop { + match UnixStream::connect(path) { + Err(e) + if is_transient(&e) + && started.elapsed() < absent_wait + && !stop.load(Ordering::SeqCst) => + { + thread::sleep(DIAL_RETRY_TICK) + } + other => return other, + } + } +} + +/// Whether a failed dial is one to wait out. Both shapes are what a daemon that +/// is still starting looks like from here: `ENOENT` while nothing has bound the +/// path yet, `ECONNREFUSED` for the moment a socket node exists but nobody is +/// accepting on it — a daemon between `bind` and `listen`, or a node a killed +/// one left behind and a restart is about to replace. +fn is_transient(e: &io::Error) -> bool { + matches!( + e.kind(), + io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused + ) +} + #[cfg(test)] mod tests { use super::super::testutil::{duplex, ChanEnd, Rec}; @@ -485,6 +626,20 @@ mod tests { ); } + #[test] + fn a_transient_dial_failure_is_only_the_startup_race() { + // The two shapes a daemon that is still starting wears, and nothing + // else: a standing refusal must go straight back to the caller rather + // than be retried for seconds. + assert!(is_transient(&io::Error::from(io::ErrorKind::NotFound))); + assert!(is_transient(&io::Error::from( + io::ErrorKind::ConnectionRefused + ))); + assert!(!is_transient(&io::Error::from( + io::ErrorKind::PermissionDenied + ))); + } + #[test] fn backoff_doubles_and_caps() { let mut delay = INITIAL_BACKOFF; @@ -993,11 +1148,14 @@ mod tests { #[test] fn connect_fails_cleanly_with_nobody_listening() { - // A path nothing ever binds: the dial must fail (not hang), and name the - // socket so the fallback log is actionable. + // With no patience budget (the production one is exercised below), a + // path nothing has bound must fail at once rather than wait: this is the + // answer `make_backend` turns into the sidecar fallback, and a machine + // that simply has no daemon should not pay for the verdict twice. let path = unique_socket_path("absent"); let sink: Arc = Arc::new(Rec::default()); - let err = match UnixBackend::connect(&path, sink) { + let started = Instant::now(); + let err = match UnixBackend::connect_within(&path, sink, Duration::ZERO) { Ok(_) => panic!("dialing a nonexistent socket must fail"), Err(e) => e, }; @@ -1005,5 +1163,98 @@ mod tests { err.contains(&path), "the error should name the socket: {err}" ); + assert!( + started.elapsed() < Duration::from_secs(1), + "a dial with no patience budget must fail fast, took {:?}", + started.elapsed() + ); + } + + #[test] + fn the_first_dial_waits_out_a_daemon_that_is_still_starting() { + // The production race this transport lives with on Linux: the service + // manager has been told to start the daemon but it has not bound the + // socket yet, so the very first dial hits ENOENT. Giving up there is not + // a small miss — the GUI picks its transport exactly once, so it spends + // the whole run on an unprivileged sidecar with a different profile + // store. + let path = unique_socket_path("late"); + let _cleanup = RemoveOnDrop(path.clone()); + let server_path = path.clone(); + let requests = Arc::new(Mutex::new(Vec::new())); + let server_requests = Arc::clone(&requests); + let server = thread::spawn(move || { + thread::sleep(Duration::from_millis(400)); + let listener = bind_listener(&server_path); + let (stream, _addr) = listener.accept().expect("accept a client"); + serve_requests(&stream, &server_requests, None, None); + }); + + let sink = Arc::new(Rec::default()); + let backend = UnixBackend::connect_within( + &path, + Arc::clone(&sink) as Arc, + Duration::from_secs(5), + ) + .expect("the first dial must wait out a daemon that is still starting"); + + // And the session that came out of it is a real one: the re-sync landed. + let states = sink.wait_for_states(1, WAIT); + assert_eq!(states[0].state, ConnectionState::Connected); + + drop(backend); + server.join().expect("socket server thread"); + } + + // --- the listener probe --------------------------------------------------- + + #[cfg(target_os = "linux")] + #[test] + fn the_probe_reads_a_listener_out_of_the_kernel_table() { + // A real socket this process is accepting on must read as listening, + // and the probe must not have taken the session away from anyone: the + // client that follows still gets served. + let path = unique_socket_path("probe"); + let _cleanup = RemoveOnDrop(path.clone()); + assert!(!is_listening(&path), "nothing has bound {path} yet"); + + let listener = bind_listener(&path); + assert!(is_listening(&path), "a bound listener must be visible"); + assert!( + is_listening(&path), + "the probe must not consume the listener" + ); + drop(listener); + assert!( + !is_listening(&path), + "a closed listener must stop reading as one" + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn the_probe_needs_the_accept_flag_and_the_exact_path() { + // The table's own shape: the header parses as nothing, a connected + // (non-accepting) socket on the same path is not a listener, and a + // different path never matches. + let table = "\ +Num RefCount Protocol Flags Type St Inode Path +ffff9a0001: 00000002 00000000 00010000 0001 01 26031 /run/tenebra.sock +ffff9a0002: 00000003 00000000 00000000 0001 03 26044 /run/other.sock +ffff9a0003: 00000003 00000000 00000000 0001 03 26055 /run/tenebra.sock +ffff9a0004: 00000002 00000000 00010000 0001 01 26066 +"; + assert!(listening_in(table, "/run/tenebra.sock")); + // Only the accepting line matches: strip it and the two remaining + // /run/tenebra.sock entries (connected, and an anonymous short line) + // must not be mistaken for a daemon. + let connected_only: String = table + .lines() + .filter(|l| !l.contains("26031")) + .collect::>() + .join("\n"); + assert!(!listening_in(&connected_only, "/run/tenebra.sock")); + assert!(!listening_in(table, "/run/nothing.sock")); + assert!(!listening_in("", "/run/tenebra.sock")); } } diff --git a/ui-desktop/src-tauri/src/crash.rs b/ui-desktop/src-tauri/src/crash.rs index f206c0b..9df5f40 100644 --- a/ui-desktop/src-tauri/src/crash.rs +++ b/ui-desktop/src-tauri/src/crash.rs @@ -40,19 +40,45 @@ const MAX_DETAIL: usize = 4000; /// webview never supplies a URL, so the destination host can't be redirected. const ISSUE_BASE: &str = "https://github.com/Divaaaan/tenebra/issues/new"; -/// The Tenebra data directory (`%LOCALAPPDATA%\Tenebra`, falling back to the temp -/// dir), created if missing. Pure `std::env` so it works from a panic hook set -/// before Tauri starts, and shared with the sidecar's `core.log` path so both -/// files land in the same, one-place-to-share directory. +/// The Tenebra data directory (`%LOCALAPPDATA%\Tenebra` on Windows, +/// `$XDG_DATA_HOME/Tenebra` on Linux, the temp dir elsewhere), created if +/// missing. Pure `std::env` so it works from a panic hook set before Tauri +/// starts, and shared with the sidecar's `core.log` path so both files land in +/// the same, one-place-to-share directory. +/// +/// Linux gets its own branch rather than the temp-dir fallback, and not only +/// for tidiness: `/tmp` is world-writable and shared between users, so a +/// pre-created `Tenebra/crash-gui.txt` symlink there would redirect this +/// append-only writer at a file of somebody else's choosing. A per-user data +/// directory is not shared, and survives a reboot besides. pub fn data_dir() -> Option { let base = std::env::var_os("LOCALAPPDATA") .map(PathBuf::from) + .or_else(linux_data_home) .unwrap_or_else(std::env::temp_dir); let dir = base.join("Tenebra"); std::fs::create_dir_all(&dir).ok()?; Some(dir) } +/// The XDG base directory for per-user data, as `$XDG_DATA_HOME` or the +/// `$HOME/.local/share` the spec defaults it to. `None` when neither is set (a +/// service-like environment with no home), leaving the temp-dir fallback. +/// Absent off Linux, where the platform has its own convention. +fn linux_data_home() -> Option { + #[cfg(target_os = "linux")] + { + std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .or_else(|| { + std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")) + }) + } + #[cfg(not(target_os = "linux"))] + None +} + fn crash_file() -> Option { Some(data_dir()?.join(CRASH_FILE)) } diff --git a/ui-desktop/src-tauri/src/lib.rs b/ui-desktop/src-tauri/src/lib.rs index ac43507..9a06bb7 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(windows)] +#[cfg(any(windows, target_os = "linux"))] use std::time::{Duration, Instant}; use serde_json::json; @@ -168,10 +168,11 @@ impl EventSink for TauriSink { // 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, the same probe over the daemon's unix socket -// (`/var/run/tenebra.sock`): if the root LaunchDaemon is listening, attach. -// TENEBRA_SOCKET renames the path or (`off`) skips it — see -// backend::unix::configured_path. +// 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. // @@ -186,11 +187,14 @@ impl EventSink for TauriSink { // 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 pipe dial itself -// waits out a service that is merely still starting (backend::pipe), and the -// fallback is reported at warn with a plain description of what changed. On -// Windows 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. +// 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. // ============================================================================= fn make_backend(app: &AppHandle, sink: Arc) -> Arc { if mock_requested(std::env::var("TENEBRA_MOCK").ok().as_deref()) { @@ -227,7 +231,7 @@ fn make_backend(app: &AppHandle, sink: Arc) -> Arc { } } - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "linux"))] if let Some(path) = backend::unix::configured_path() { match backend::unix::UnixBackend::connect(&path, Arc::clone(&sink)) { Ok(backend) => { @@ -237,15 +241,19 @@ fn make_backend(app: &AppHandle, sink: Arc) -> Arc { // As on Windows: a working configuration, but on an installed // machine a downgrade the user cannot see from the UI, so it is // reported as a warning naming what changed. - Err(e) => sink.log( - "warn", - &format!( - "could not reach the Tenebra daemon on {path} ({e}); \ - running this app's own core instead — profiles saved by the daemon \ - are not visible here, and connecting in tun mode needs \ - administrator rights" - ), - ), + Err(e) => { + sink.log( + "warn", + &format!( + "could not reach the Tenebra daemon on {path} ({e}); \ + running this app's own core instead — profiles saved by the daemon \ + are not visible here, and connecting in tun mode needs \ + root privileges" + ), + ); + #[cfg(target_os = "linux")] + watch_for_a_late_daemon(path, Arc::clone(&sink)); + } } } @@ -288,14 +296,15 @@ fn make_backend(app: &AppHandle, sink: Arc) -> Arc { /// How long the app keeps an eye out for a service that started after it did, /// and how often it looks. The window covers the cases where the fallback was a -/// lost race rather than a verdict — an installer's `sc start`, a boot where the -/// SCM was slow, a service started by hand right after the app — and then stops: -/// a machine that genuinely has no service 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(windows)] +/// lost race rather than a verdict — an installer's `sc start` or `systemctl +/// start`, a boot where the service manager was slow, a service started by hand +/// right after the app — and then stops: a machine that genuinely has no service +/// 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"))] const LATE_SERVICE_WATCH: Duration = Duration::from_secs(60); -#[cfg(windows)] +#[cfg(any(windows, 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 @@ -333,11 +342,41 @@ fn watch_for_a_late_service(name: String, sink: Arc) { }); } +/// 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; +/// 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. +#[cfg(target_os = "linux")] +fn watch_for_a_late_daemon(path: String, sink: Arc) { + // A thread that cannot be spawned costs the user nothing but this notice. + let _ = std::thread::Builder::new() + .name("tenebra-daemon-watch".into()) + .spawn(move || { + let appeared = await_probe( + || backend::unix::is_listening(&path), + LATE_SERVICE_TICK, + LATE_SERVICE_WATCH, + ); + if appeared { + sink.log( + "warn", + &format!( + "the Tenebra daemon is listening on {path} now, but this session is \ + already running the app's own core; restart Tenebra to control the \ + daemon and see the profiles saved there" + ), + ); + } + }); +} + /// Poll `probe` every `tick` until it answers true or `window` runs out, /// 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 or real seconds. -#[cfg(windows)] +/// tested without a real pipe, a real socket, or real seconds. +#[cfg(any(windows, target_os = "linux"))] fn await_probe(mut probe: impl FnMut() -> bool, tick: Duration, window: Duration) -> bool { let deadline = Instant::now() + window; loop { @@ -379,41 +418,117 @@ fn mock_requested(value: Option<&str>) -> bool { /// platform: `sing-box.exe` on Windows (with wintun.dll in the same directory /// for the tun device to load), plain `sing-box` elsewhere. /// -/// Fails closed: if the bundled resource can't be resolved to an absolute path -/// that exists, we return an error instead of falling back to a bare name. -/// A bare `sing-box` would be resolved by the core relative to its CWD (and -/// then PATH), so a `sing-box` planted in an attacker-chosen working directory -/// could be launched with the tunnel's privileges. `resolve` against the -/// Resource base directory is always absolute and rooted at the app bundle, so -/// it can't be redirected by the CWD; requiring it removes the planting vector. -/// The `TENEBRA_SINGBOX` override is operator-supplied, not webview-reachable, -/// so it stays trusted. +/// The core derives more than the executable from this path: the bundled +/// rule-sets (`geoip-ru.srs` and friends) are looked up in the same directory, +/// so whatever answers here has to be the directory the whole payload was laid +/// down in. +/// +/// Fails closed: if no candidate resolves to an absolute path that exists, we +/// return an error instead of falling back to a bare name. A bare `sing-box` +/// would be resolved by the core relative to its CWD (and then PATH), so a +/// `sing-box` planted in an attacker-chosen working directory could be launched +/// with the tunnel's privileges. Every candidate here is absolute — Tauri's +/// `resolve` against the Resource base directory is rooted at the app bundle, +/// and the packaged locations are literal system paths — so none can be +/// redirected by the CWD. The `TENEBRA_SINGBOX` override is operator-supplied, +/// not webview-reachable, so it stays trusted. fn singbox_path(app: &AppHandle) -> Result { if let Some(p) = std::env::var_os("TENEBRA_SINGBOX") { return Ok(std::path::PathBuf::from(p)); } #[cfg(windows)] - let resource = "resources/sing-box.exe"; + let name = "sing-box.exe"; #[cfg(not(windows))] - let resource = "resources/sing-box"; + let name = "sing-box"; + let resource = format!("resources/{name}"); + let mut tried: Vec = Vec::new(); let resolved = app .path() - .resolve(resource, tauri::path::BaseDirectory::Resource) + .resolve(&resource, tauri::path::BaseDirectory::Resource) .map_err(|e| { format!( "cannot resolve the bundled sing-box resource ({resource}): {e}; \ refusing to fall back to a bare name resolved from CWD/PATH" ) })?; - if !resolved.exists() { - return Err(format!( - "bundled sing-box resource resolved to {} but no file is there; \ - refusing to fall back to a bare name resolved from CWD/PATH", - resolved.display() - )); + if resolved.exists() { + return Ok(resolved); + } + tried.push(resolved.display().to_string()); + + for candidate in packaged_resource_paths(name) { + if candidate.exists() { + return Ok(candidate); + } + tried.push(candidate.display().to_string()); + } + + Err(format!( + "no bundled sing-box found (looked for {}); \ + refusing to fall back to a bare name resolved from CWD/PATH", + tried.join(", ") + )) +} + +/// Where a distribution package may have put the payload instead of the layout +/// Tauri's own bundler produces. +/// +/// Tauri resolves resources relative to the bundle it built (`/usr/lib/Tenebra` +/// from the .deb, the mount point inside an AppImage), which is exactly right +/// for those two and useless for a native package built without the bundler: a +/// distribution package spreads the same payload across the filesystem +/// hierarchy, with the launcher in `/bin` and the private helpers in a +/// per-package directory. Arch ships one of those, and it carries its own +/// sing-box — the binary is not in the official repositories — so the resources +/// really are somewhere under `/usr/lib/tenebra` rather than beside the app. +/// +/// The system directories are the ones the core walks for the same payload +/// (`adapters/linux.InstallDirs`), in the same order, so both ends of the handoff +/// agree on where a package may have put things: `/{lib,libexec,share} +/// /tenebra` derived from the running executable, then the same three under +/// `/usr` as an absolute backstop. Each is tried with and without the +/// `resources/` sub-directory the bundler adds. The core's list opens with the +/// executable's own directory, which here is already covered by the Tauri +/// resolve this runs after; it closes with a `PATH` lookup, which this one +/// deliberately omits — a search that ends in a spawn must not resolve anything +/// a user could have planted, the whole reason [`singbox_path`] fails closed. +/// +/// Empty off Linux: nothing else ships the app outside its own bundle format. +fn packaged_resource_paths(name: &str) -> Vec { + #[cfg(target_os = "linux")] + { + use std::path::PathBuf; + + // /usr for a /usr/bin/tenebra, /usr/local for a local install; absent + // when the executable cannot be located, leaving the /usr backstop. + let prefix = std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().and_then(|dir| dir.parent()).map(PathBuf::from)); + + let mut out: Vec = Vec::new(); + for base in prefix.into_iter().chain([PathBuf::from("/usr")]) { + for private in ["lib", "libexec", "share"] { + let dir = base.join(private).join("tenebra"); + for candidate in [dir.join(name), dir.join("resources").join(name)] { + // An install under /usr makes the prefix and the backstop the + // same directory; drop the repeats so the error a miss + // produces reads as an honest search path. Absolute only — + // a relative candidate would be resolved from the current + // directory, the very thing this must never do. + if candidate.is_absolute() && !out.contains(&candidate) { + out.push(candidate); + } + } + } + } + out + } + #[cfg(not(target_os = "linux"))] + { + let _ = name; + Vec::new() } - Ok(resolved) } // --- command handlers --------------------------------------------------------- @@ -796,6 +911,7 @@ pub fn run() { set_language, take_launch_deep_links, update_channel::check_update_for_channel, + update_channel::in_app_updates_supported, crash::check_crash_report, crash::record_web_crash, crash::open_report_url, @@ -808,10 +924,19 @@ pub fn run() { /// any link the app launched with (cold start), and forward links that arrive /// while it runs. Pulled out of `run` so the setup closure stays readable. fn setup_deep_link(app: &AppHandle) { - // Development only: register the tenebra:// scheme at runtime so links work - // from a `tauri dev` build. In a release build the installer owns - // registration, pointing the scheme at the installed executable. - #[cfg(debug_assertions)] + // Register the tenebra:// scheme at runtime where nothing else will have. + // In development that is every platform: a `tauri dev` build was never + // installed, so no installer claimed the scheme for it. + // + // On Linux it is also the release path. The scheme is claimed by a .desktop + // file, and only a package that installs one — the .deb, or a native package + // shipping the same entry — hands the desktop environment a handler; an + // AppImage is a single file that no one registered, so without this the + // link has nowhere to go. Registration writes a per-user handler entry + // pointing at this executable (the AppImage path when running from one) and + // is idempotent, so re-running it on every launch keeps it correct after the + // file moves. A machine without `xdg-mime` simply gets an error we ignore. + #[cfg(any(debug_assertions, target_os = "linux"))] { let _ = app.deep_link().register_all(); } @@ -1131,7 +1256,7 @@ mod tests { assert!(mock_requested(Some("yes"))); } - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] #[test] fn the_service_watch_stops_at_the_first_sighting() { // A service that shows up on the third look is reported, and the watch @@ -1150,7 +1275,7 @@ mod tests { assert_eq!(looks.get(), 3, "the watch must stop once it has an answer"); } - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] #[test] fn the_service_watch_gives_up_when_its_window_closes() { // Nothing ever appears — the ordinary case on a machine with no service @@ -1171,6 +1296,50 @@ mod tests { ); } + #[test] + fn packaged_resource_paths_stay_absolute_and_off_windows_and_macos() { + // The fallback exists for a distribution package that lays the payload + // out per the FHS instead of inside a Tauri bundle. Every candidate has + // to be an absolute system path: a relative one would be resolved from + // the current directory, which is exactly the planting vector + // `singbox_path` fails closed to avoid. + let paths = packaged_resource_paths("sing-box"); + assert!( + paths.iter().all(|p| p.is_absolute()), + "every candidate must be absolute: {paths:?}" + ); + + #[cfg(target_os = "linux")] + { + let shown: Vec = paths.iter().map(|p| p.display().to_string()).collect(); + // The layout the Arch package actually ships — sing-box is not in + // the official repositories, so the package carries its own copy + // beside the rule-sets — plus the other FHS homes the core walks for + // the same payload, and the bundler's resources/ sub-directory. + for expected in [ + "/usr/lib/tenebra/sing-box", + "/usr/lib/tenebra/resources/sing-box", + "/usr/libexec/tenebra/sing-box", + "/usr/share/tenebra/sing-box", + ] { + assert!( + shown.iter().any(|p| p == expected), + "missing {expected} in {shown:?}" + ); + } + // Each directory is offered once, even though the running + // executable's own prefix is very often /usr itself. + let mut unique = shown.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(unique.len(), shown.len(), "duplicate candidates: {shown:?}"); + } + // Nothing else ships the app outside its own bundle format, so nothing + // else widens the search. + #[cfg(not(target_os = "linux"))] + assert!(paths.is_empty(), "unexpected candidates: {paths:?}"); + } + #[test] fn language_code_maps_to_lang() { assert_eq!(Lang::from_code("ru"), Lang::Ru); diff --git a/ui-desktop/src-tauri/src/tray.rs b/ui-desktop/src-tauri/src/tray.rs index 07f216b..26cc02c 100644 --- a/ui-desktop/src-tauri/src/tray.rs +++ b/ui-desktop/src-tauri/src/tray.rs @@ -110,7 +110,11 @@ pub fn create(app: &AppHandle) -> tauri::Result<()> { .icon(icon_for(ConnectionState::Idle)) .tooltip(tooltip_for(lang, ConnectionState::Idle)) // We handle left-click ourselves to toggle the window; the menu opens on - // a right-click, the platform-conventional behaviour on Windows. + // a right-click, the platform-conventional behaviour on Windows. Both + // this and the click handler below are inert on Linux, where the tray is + // an app indicator that reports no clicks and always opens its menu — so + // "Show Tenebra" in that menu is the only way back to the window there, + // which is why it is the first item. .show_menu_on_left_click(false) .menu(&menu) .on_menu_event(on_menu_event) diff --git a/ui-desktop/src-tauri/src/update_channel.rs b/ui-desktop/src-tauri/src/update_channel.rs index 9d127f3..977bf0f 100644 --- a/ui-desktop/src-tauri/src/update_channel.rs +++ b/ui-desktop/src-tauri/src/update_channel.rs @@ -10,7 +10,7 @@ //! byte-for-byte the stable path. use serde::Serialize; -use tauri::{Manager, ResourceId, Webview}; +use tauri::{AppHandle, Manager, ResourceId, Webview}; use tauri_plugin_updater::UpdaterExt; use url::Url; @@ -83,6 +83,34 @@ pub async fn check_update_for_channel( } } +/// Whether this build can install its own updates, so the front end can offer +/// the update flow only where it actually leads somewhere. +/// +/// The updater works by replacing the artifact the app was installed from, and +/// on Linux there is exactly one it can replace: an AppImage, a single file the +/// user owns. A .deb or a native package belongs to the system package manager — +/// its files live under root-owned system paths, and even with the rights to +/// overwrite them the result would be an install the manager no longer matches. +/// So on those the update comes from the package manager, and the honest thing +/// for the app to do is say so rather than offer a button that fails. Tauri +/// reports the AppImage it is running from (the `APPIMAGE` variable the runtime +/// sets), and its absence is what tells the two apart. +/// +/// Everywhere else the in-app updater is the supported path — the NSIS installer +/// and the macOS .app both update themselves — so the answer is a plain yes. +#[tauri::command] +pub fn in_app_updates_supported(app: AppHandle) -> bool { + #[cfg(target_os = "linux")] + { + app.env().appimage.is_some() + } + #[cfg(not(target_os = "linux"))] + { + let _ = app; + true + } +} + #[cfg(test)] mod tests { use super::manifest_url; diff --git a/ui-desktop/src-tauri/tauri.conf.json b/ui-desktop/src-tauri/tauri.conf.json index a70b475..60645d8 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.4.5", + "version": "0.4.6", "identifier": "com.tenebra.desktop", "build": { "frontendDist": "../dist", @@ -29,7 +29,7 @@ "bundle": { "active": true, "createUpdaterArtifacts": true, - "targets": ["nsis", "app", "dmg"], + "targets": ["nsis", "app", "dmg", "deb", "appimage"], "externalBin": ["binaries/tenebra-core"], "resources": [ "resources/sing-box.exe", @@ -47,7 +47,7 @@ ], "category": "Utility", "shortDescription": "Tenebra VPN client", - "longDescription": "Windows and macOS VPN client built on sing-box; Linux and mobile are planned.", + "longDescription": "Windows, macOS and Linux VPN client built on sing-box; mobile is in progress.", "windows": { "nsis": { "installMode": "perMachine", diff --git a/ui-desktop/src-tauri/tauri.linux.conf.json b/ui-desktop/src-tauri/tauri.linux.conf.json new file mode 100644 index 0000000..66f140d --- /dev/null +++ b/ui-desktop/src-tauri/tauri.linux.conf.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.png" + ], + "resources": [ + "resources/sing-box", + "resources/geoip-ru.srs", + "resources/geosite-ru.srs", + "resources/geosite-ads.srs", + "../../THIRD-PARTY-NOTICES.md" + ], + "linux": { + "deb": { + "section": "net", + "desktopTemplate": "linux/tenebra.desktop" + } + } + } +} diff --git a/ui-desktop/src/App.bootstrap.test.tsx b/ui-desktop/src/App.bootstrap.test.tsx index 9a011a9..3f820dc 100644 --- a/ui-desktop/src/App.bootstrap.test.tsx +++ b/ui-desktop/src/App.bootstrap.test.tsx @@ -73,6 +73,7 @@ vi.mock("./api", () => ({ // The launch update check would otherwise reach the (absent) updater plugin. vi.mock("./lib/updates", () => ({ checkForUpdate: vi.fn().mockResolvedValue(null), + inAppUpdatesSupported: vi.fn().mockResolvedValue(true), installUpdate: vi.fn().mockResolvedValue(undefined), })); diff --git a/ui-desktop/src/App.crash.test.tsx b/ui-desktop/src/App.crash.test.tsx index ad17528..e3eb729 100644 --- a/ui-desktop/src/App.crash.test.tsx +++ b/ui-desktop/src/App.crash.test.tsx @@ -53,6 +53,7 @@ vi.mock("./api", () => ({ // The launch update check would otherwise reach the (absent) updater plugin. vi.mock("./lib/updates", () => ({ checkForUpdate: vi.fn().mockResolvedValue(null), + inAppUpdatesSupported: vi.fn().mockResolvedValue(true), installUpdate: vi.fn().mockResolvedValue(undefined), })); diff --git a/ui-desktop/src/App.deeplink.test.tsx b/ui-desktop/src/App.deeplink.test.tsx index 02a953d..8e1ec14 100644 --- a/ui-desktop/src/App.deeplink.test.tsx +++ b/ui-desktop/src/App.deeplink.test.tsx @@ -75,6 +75,7 @@ vi.mock("./api", () => ({ // The launch update check would otherwise reach the (absent) updater plugin. vi.mock("./lib/updates", () => ({ checkForUpdate: vi.fn().mockResolvedValue(null), + inAppUpdatesSupported: vi.fn().mockResolvedValue(true), installUpdate: vi.fn().mockResolvedValue(undefined), })); diff --git a/ui-desktop/src/App.shell.test.tsx b/ui-desktop/src/App.shell.test.tsx index 09ed5ec..f6f7075 100644 --- a/ui-desktop/src/App.shell.test.tsx +++ b/ui-desktop/src/App.shell.test.tsx @@ -68,6 +68,7 @@ vi.mock("./api", () => ({ // The launch update check would otherwise reach the (absent) updater plugin. vi.mock("./lib/updates", () => ({ checkForUpdate: vi.fn().mockResolvedValue(null), + inAppUpdatesSupported: vi.fn().mockResolvedValue(true), installUpdate: vi.fn().mockResolvedValue(undefined), })); diff --git a/ui-desktop/src/App.simple.test.tsx b/ui-desktop/src/App.simple.test.tsx index 11051a7..5e59c0b 100644 --- a/ui-desktop/src/App.simple.test.tsx +++ b/ui-desktop/src/App.simple.test.tsx @@ -68,6 +68,7 @@ vi.mock("./api", () => ({ vi.mock("./lib/updates", () => ({ checkForUpdate: vi.fn().mockResolvedValue(null), + inAppUpdatesSupported: vi.fn().mockResolvedValue(true), installUpdate: vi.fn().mockResolvedValue(undefined), })); diff --git a/ui-desktop/src/App.tsx b/ui-desktop/src/App.tsx index 530ba22..58f5da5 100644 --- a/ui-desktop/src/App.tsx +++ b/ui-desktop/src/App.tsx @@ -135,10 +135,11 @@ export function App() { const update = useUpdateCheck(phase); // Daemon build vs app build, latched from state snapshots. A skew means the - // privileged daemon predates this UI (the macOS LaunchDaemon is never touched - // by the in-app updater), so toggles of newer commands silently do nothing — - // surface it instead. Dismissal is session-only: the degradation is real, so - // it may re-prompt on the next launch. + // privileged daemon predates this UI — nothing the app installs itself + // refreshes it, neither the macOS LaunchDaemon nor the Linux system service — + // so toggles of newer commands silently do nothing; surface it instead. + // Dismissal is session-only: the degradation is real, so it may re-prompt on + // the next launch. const daemonSkew = useDaemonSkew(state, __APP_VERSION__); const [skewDismissed, setSkewDismissed] = useState(false); diff --git a/ui-desktop/src/components/DaemonSkewBanner.test.tsx b/ui-desktop/src/components/DaemonSkewBanner.test.tsx index c645171..c884033 100644 --- a/ui-desktop/src/components/DaemonSkewBanner.test.tsx +++ b/ui-desktop/src/components/DaemonSkewBanner.test.tsx @@ -6,17 +6,17 @@ import { DaemonSkewBanner, MACOS_DAEMON_UPDATE_COMMAND } from "./DaemonSkewBanne import { renderWithProviders } from "../test/renderWithProviders"; // The platform hint keys off navigator.userAgent (no os plugin in the app); -// jsdom's default UA is not a Mac, so the Windows branch is the default here -// and the Mac branch is exercised by overriding the getter per test. -function mockMacUserAgent() { +// jsdom's default UA names neither a Mac nor a real X11 session, so the Windows +// branch is the default here and the other two are exercised by overriding the +// getter per test with the string that platform's webview actually sends. +function mockUserAgent(ua: string) { const original = Object.getOwnPropertyDescriptor( Navigator.prototype, "userAgent", ); Object.defineProperty(window.navigator, "userAgent", { configurable: true, - get: () => - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15", + get: () => ua, }); return () => { delete (window.navigator as { userAgent?: unknown }).userAgent; @@ -26,6 +26,15 @@ function mockMacUserAgent() { }; } +const MAC_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15"; +const LINUX_UA = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko)"; + +function mockMacUserAgent() { + return mockUserAgent(MAC_UA); +} + afterEach(() => { vi.restoreAllMocks(); }); @@ -62,6 +71,30 @@ describe("DaemonSkewBanner", () => { ).not.toBeInTheDocument(); }); + it("points at the package and the service on Linux", () => { + // The daemon there is a system service the package owns: the app can + // neither update nor restart it, so the way out is the package manager — + // and never the macOS copy-command button, which names a repo script that + // does not apply. + const restore = mockUserAgent(LINUX_UA); + + renderWithProviders( + , + ); + + expect( + screen.getByText("Update the Tenebra package, then restart the tenebra service"), + ).toBeInTheDocument(); + expect( + screen.queryByText("Reinstall the app to update it"), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Copy update command/ }), + ).not.toBeInTheDocument(); + + restore(); + }); + it("copies the daemon update command on macOS", async () => { const restore = mockMacUserAgent(); // Install the clipboard spy after userEvent.setup(): setup replaces diff --git a/ui-desktop/src/components/DaemonSkewBanner.tsx b/ui-desktop/src/components/DaemonSkewBanner.tsx index 701188a..aa65fc2 100644 --- a/ui-desktop/src/components/DaemonSkewBanner.tsx +++ b/ui-desktop/src/components/DaemonSkewBanner.tsx @@ -22,9 +22,11 @@ interface DaemonSkewBannerProps { // One-line strip under the top bar, sharing the update banner's quiet look: a // skewed daemon is a degradation warning, not an incident. It names the two // builds and offers the platform's way out — the reinstall command on macOS -// (where the in-app updater never touches the root LaunchDaemon), a reinstall -// hint elsewhere (the Windows installer refreshes the service itself, so a skew -// there means the app was laid down without running it). +// (where the in-app updater never touches the root LaunchDaemon), the package +// route on Linux (the daemon is a system service the package owns, so the app +// can neither update nor restart it), and a reinstall hint on Windows (the +// installer refreshes the service itself, so a skew there means the app was +// laid down without running it). export function DaemonSkewBanner({ daemonVersion, appVersion, @@ -33,8 +35,11 @@ export function DaemonSkewBanner({ const { t } = useI18n(); const [copied, setCopied] = useState(false); - // No os plugin in the app; the UA is enough to pick a hint string. + // No os plugin in the app; the UA is enough to pick a hint string. Linux + // browsers report "X11" or "Linux" in it, and never "Mac", so the two tests + // cannot both match. const isMac = navigator.userAgent.includes("Mac"); + const isLinux = !isMac && /Linux|X11/.test(navigator.userAgent); const text = daemonVersion ? t.daemon.stale @@ -61,7 +66,9 @@ export function DaemonSkewBanner({ ⧉ {copied ? t.daemon.copied : t.daemon.copyCommand} ) : ( - {t.daemon.reinstallHint} + + {isLinux ? t.daemon.restartServiceHint : t.daemon.reinstallHint} + )}