Skip to content

Linux support: systemd daemon, deb/AppImage bundles and an Arch package - #8

Merged
Divaaaan merged 6 commits into
mainfrom
feat/linux-support
Jul 27, 2026
Merged

Linux support: systemd daemon, deb/AppImage bundles and an Arch package#8
Divaaaan merged 6 commits into
mainfrom
feat/linux-support

Conversation

@Divaaaan

Copy link
Copy Markdown
Owner

Linux was previously a compile target only: the Go core built and was tested in
CI, but --socket was rejected off macOS, so there was no way to run a detached
daemon, and no bundle was produced for any distribution.

This branch makes Linux a first-class desktop platform, with Arch as the primary
target.

What is here

  • Control transport. The unix-socket listener and the fail-closed,
    symlink-safe data-directory clamp are now shared with macOS instead of copied;
    Linux serves on /run/tenebra.sock and stores under /var/lib/tenebra/data.
    Peer credentials come from SO_PEERCRED, and the console user is resolved from
    logind's seat state, failing open the way darwin does.
  • Tunnel supervisor. Its own adapter rather than the generic one — reusing
    the Windows adapter meant every diagnostic on a Linux desktop was prefixed
    windows: and the privilege hints named a service that does not exist there.
  • Resource resolution. The bundled sing-box and the .srs rule-sets are
    found across the filesystem hierarchy rather than "next to the executable",
    under both the package name and the product name the Debian bundle uses.
  • Desktop app. deb and AppImage bundles, updater gated so a package-managed
    install does not offer a button that cannot work, crash/core logs moved off
    /tmp, and deep-link registration fixed for release builds.
  • Packaging. systemd unit with a deliberately narrow sandbox, install and
    uninstall scripts, and an Arch PKGBUILD. sing-box is bundled with a pinned
    digest rather than declared as a dependency: it is not in core or extra, only
    in the AUR, and a package may not depend on one.
  • CI. A Linux job builds the deb and runs the Rust suite; the release
    workflow adds Linux as a third link in the existing chain and builds the pacman
    package in an Arch container, proving it installs before attaching it.

Verified

  • Go: gofmt/vet clean; cross-builds for linux, darwin, windows; full suite
    green on macOS and, with -race plus staticcheck, in a Linux container.
  • Desktop: tsc, eslint, 542 front-end tests, cargo fmt, clippy -D warnings,
    157 Rust tests plus the e2e on macOS; cargo check --all-targets clean in both
    a Debian and an Arch container.
  • Packaging: shellcheck clean; systemd-analyze verify clean and a full
    install/upgrade/rollback/uninstall lifecycle exercised in a live systemd
    container (systemd-analyze security 3.9); makepkg builds
    tenebra-0.4.5-1-x86_64.pkg.tar.zst, pacman -U installs it, namcap clean.
  • Bundle: the .deb was built and unpacked — the generated desktop entry really
    carries Exec=... %u and the tenebra:// MimeType, and the dependency list is
    correct.
  • On a live Arch container the daemon starts, creates the socket, reports its
    version, admits an unprivileged client, clamps its store to root-owned 0700 and
    resolves sing-box from the packaged path.

Not verified

No live tunnel on Linux yet — nothing here proves traffic flows, routes install
or the kill switch holds. DNS on systemd-resolved hosts is the most likely leak
source and is documented as the top open question. The GUI binary compiles but
has never been launched (no display in a container), and on a desktop with no
tray host the close-to-tray behaviour would leave the window unreachable.

Divaaaan added 6 commits July 27, 2026 12:30
Linux built and tested in CI but could not host a detached daemon: --socket
was rejected everywhere but macOS, and the peer check admitted every caller
unconditionally because no listener could exist to authenticate against.

Give Linux the same shape macOS has. The unix-socket listener and the
symlink-safe, fail-closed data-directory clamp are now shared between the two
rather than copied, with only the paths differing: /run/tenebra.sock and
/var/lib/tenebra/data. Peer credentials come from SO_PEERCRED, and the console
user is resolved from logind's seat state, refusing to guess when two seats
disagree and failing open the way darwin does.

Resolving the bundled sing-box needed its own answer here. Windows and macOS
ship one self-contained directory; a distribution package spreads the payload
across the hierarchy, so the search walks the install prefix and then /usr,
under both the lowercase package name and the product name the Debian bundle
uses, with and without a resources/ subdirectory. The rule-sets follow the same
list, so the binary and its .srs can never drift apart.

The tunnel supervisor is its own adapter instead of the generic one: reusing
the Windows adapter meant every diagnostic on an Arch desktop was prefixed
'windows:', and the privilege hints pointed at a service that does not exist
there.
Adds the pieces a Linux install actually needs: the sing-box fetch generalised
to linux/amd64 and arm64 behind the same SHA-256 pins the other two platforms
use, a systemd unit, install and uninstall scripts, and an Arch package.

The unit runs the core as root with a capability set of exactly six, an
explicit DeviceAllow for /dev/net/tun and a syscall filter. The sandboxing
stops where it would break a VPN: ProtectSystem is full rather than strict so
/run stays writable, PrivateDevices is off because it would take the tun device
with it, and the kernel-tunable protections are off because routing writes to
/proc/sys. TENEBRA_CONFIG_DIR is deliberately left unset so the core still pins
and clamps its own root-only store instead of quietly inheriting one.

Arch gets a PKGBUILD rather than a bundler target because Tauri has none: its
config schema knows deb, rpm and appimage and nothing else. sing-box is bundled
with a pinned digest instead of declared as a dependency — there is no sing-box
in core or extra, only in the AUR, and a package may not depend on one. It goes
into the package's own directory so an existing AUR install is left alone.

pkgver is wired into set-version.mjs: it doubles as the git tag the source is
taken from, so a stale copy would not merely mislabel the package, it would
build the wrong revision or fail on a tag that does not exist.
CI gains a job that builds the deb and runs the Rust suite on Linux, and a
cheap one that parses and lints the PKGBUILD on every push — a full makepkg
only works on a tagged commit, since the package takes its source from the tag.

The release workflow gains Linux as the third link in the existing chain. The
sequencing is not cosmetic: all the jobs upload to one release and tauri-action
merges its platform entries into that release's latest.json, so running them in
order keeps that read-modify-write deterministic. A separate job then builds the
pacman package inside an Arch container, proves it installs and that the layout
the daemon resolves against is really there, and attaches it to the release.

Both Linux jobs pin ubuntu-22.04 rather than ubuntu-latest: the artifacts link
against the runner's glibc, and anything built on 24.04 refuses to start on an
older distribution.

The release notes are identical in all three jobs now — each one rewrites the
body, so unsynchronised copies meant the published text depended on which job
finished last. They say plainly that only the Arch package installs the service,
and that a packaged install updates through the package manager rather than the
in-app updater, which can only replace an AppImage.
The unix-socket backend now covers Linux as well as macOS, carrying over the
patient first dial and the late-daemon watch added in 0.4.5 rather than
reverting to the naive version: on Linux the service and the desktop session
start concurrently, which is exactly the race that shape exists for. The
liveness probe reads /proc/net/unix instead of dialling, because a dial would
displace whichever client currently holds the session.

Bundles: deb and appimage, with a platform config so the Linux build carries
sing-box and the rule-sets under their real names and never the wintun payload.
The desktop entry is the bundler's own template plus a %u field code on Exec —
the MimeType line is filled in from the deep-link schemes, so without it the
desktop environment would hand a tenebra:// link to a launcher that drops it.
Deep-link registration also had to stop being debug-only: an AppImage has no
installer to claim the scheme, so a release build never registered as a handler.

Two Linux-only holes closed on the way. The crash and core logs were written to
/tmp, which is world-writable — a pre-planted symlink there would have
redirected an append-only writer; they now follow XDG. And the updater is
gated: a package-managed install skips the check and says where updates come
from, instead of offering a button that cannot work, since the Tauri updater
can only replace an AppImage.
The pattern for the gomobile leftover binary was unanchored, so it matched the
cmd/tenebra-core directory as well. Files already tracked stayed tracked, which
hid the problem completely: every new source file added there since was quietly
dropped from its commit, and only CI noticed, with the linux and darwin builds
failing on an undefined function whose definition was sitting in the working
tree. Anchor the pattern and add the files it swallowed.
Run the bundle build from the front-end directory. Without it npm looked for a
package.json in the repository root and the Linux job failed after five minutes
of work that had already succeeded.
@Divaaaan
Divaaaan merged commit 36cd37e into main Jul 27, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant