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 @@
[](https://github.com/Divaaaan/tenebra/actions/workflows/ci.yml)
[](https://www.gnu.org/licenses/gpl-3.0)
[](https://github.com/Divaaaan/tenebra/releases/latest)
-[](#project-status)
+[](#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.
@@ -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}
+
)}