From a06fda6c1dda187005dc61b0bf2128f1898b8e5d Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:30:40 +0300 Subject: [PATCH 1/6] Serve the control protocol on Linux 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. --- adapters/linux/runner.go | 662 ++++++++++++++++++ adapters/linux/runner_clash_test.go | 131 ++++ adapters/linux/runner_probe_test.go | 158 +++++ adapters/linux/runner_test.go | 456 ++++++++++++ cmd/tenebra-core/main.go | 62 +- cmd/tenebra-core/main_test.go | 12 +- cmd/tenebra-core/runner_other.go | 13 +- cmd/tenebra-core/socket_darwin.go | 227 +----- cmd/tenebra-core/socket_other.go | 14 +- ...ket_darwin_test.go => socket_unix_test.go} | 44 +- core/control/peer_auth_darwin.go | 20 - core/control/peer_auth_linux.go | 232 ++++++ core/control/peer_auth_linux_test.go | 263 +++++++ core/control/peer_auth_other.go | 9 +- core/control/peer_auth_unix.go | 35 + ..._darwin_test.go => peer_auth_unix_test.go} | 13 +- core/control/proxy_other.go | 10 +- core/control/socket_darwin.go | 80 +-- core/control/socket_linux.go | 23 + core/control/socket_unix.go | 84 +++ ...ket_darwin_test.go => socket_unix_test.go} | 16 +- docs/architecture.md | 23 +- docs/control-protocol.md | 60 +- 23 files changed, 2221 insertions(+), 426 deletions(-) create mode 100644 adapters/linux/runner.go create mode 100644 adapters/linux/runner_clash_test.go create mode 100644 adapters/linux/runner_probe_test.go create mode 100644 adapters/linux/runner_test.go rename cmd/tenebra-core/{socket_darwin_test.go => socket_unix_test.go} (84%) create mode 100644 core/control/peer_auth_linux.go create mode 100644 core/control/peer_auth_linux_test.go create mode 100644 core/control/peer_auth_unix.go rename core/control/{peer_auth_darwin_test.go => peer_auth_unix_test.go} (88%) create mode 100644 core/control/socket_linux.go create mode 100644 core/control/socket_unix.go rename core/control/{socket_darwin_test.go => socket_unix_test.go} (90%) 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/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_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_darwin_test.go b/cmd/tenebra-core/socket_unix_test.go similarity index 84% rename from cmd/tenebra-core/socket_darwin_test.go rename to cmd/tenebra-core/socket_unix_test.go index 2ae8d08..f0b1dd1 100644 --- a/cmd/tenebra-core/socket_darwin_test.go +++ b/cmd/tenebra-core/socket_unix_test.go @@ -1,4 +1,4 @@ -//go:build darwin +//go:build darwin || linux package main @@ -68,48 +68,6 @@ func TestConfigureSocketPathsNonRootNoOp(t *testing.T) { } } -// 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) - } - }) - - t.Run("flat", func(t *testing.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 := findBundledSingbox(dir); got != want { - t.Errorf("findBundledSingbox flat = %q, want %q", got, want) - } - }) - - t.Run("resources sibling", func(t *testing.T) { - // /MacOS/tenebra-core resolves sing-box from /Resources. - root := t.TempDir() - macosDir := filepath.Join(root, "MacOS") - resDir := filepath.Join(root, "Resources") - if err := os.MkdirAll(macosDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(resDir, 0o755); err != nil { - t.Fatal(err) - } - want := filepath.Join(resDir, "sing-box") - 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) - } - }) -} - // 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 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/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 | From fd6ddcbd47315535a052cc0a2bd1d4c6b0411324 Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:30:52 +0300 Subject: [PATCH 2/6] Package Tenebra for Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 9 + README.md | 71 ++++- deploy/linux/tenebra.desktop | 26 ++ deploy/linux/tenebra.service | 208 +++++++++++++ docs/README.md | 5 +- docs/porting/linux.md | 253 +++++++++++++++ packaging/arch/PKGBUILD | 201 ++++++++++++ packaging/arch/tenebra.install | 57 ++++ scripts/fetch-resources.sh | 185 ++++++++--- scripts/linux/install-daemon.sh | 492 ++++++++++++++++++++++++++++++ scripts/linux/uninstall-daemon.sh | 129 ++++++++ scripts/set-version.mjs | 16 +- 12 files changed, 1604 insertions(+), 48 deletions(-) create mode 100644 deploy/linux/tenebra.desktop create mode 100644 deploy/linux/tenebra.service create mode 100644 docs/porting/linux.md create mode 100644 packaging/arch/PKGBUILD create mode 100644 packaging/arch/tenebra.install create mode 100755 scripts/linux/install-daemon.sh create mode 100755 scripts/linux/uninstall-daemon.sh diff --git a/.gitignore b/.gitignore index 941da90..e5d6db9 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,15 @@ 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 diff --git a/README.md b/README.md index 2f22a15..73047ed 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,10 @@ [![CI](https://github.com/Divaaaan/tenebra/actions/workflows/ci.yml/badge.svg)](https://github.com/Divaaaan/tenebra/actions/workflows/ci.yml) [![License: GPL v3](https://img.shields.io/badge/license-GPLv3-ff3d00.svg)](https://www.gnu.org/licenses/gpl-3.0) [![Latest release](https://img.shields.io/github/v/release/Divaaaan/tenebra?color=ff3d00&label=release)](https://github.com/Divaaaan/tenebra/releases/latest) -[![Platform](https://img.shields.io/badge/platform-Windows_%7C_macOS-0e0e0e.svg)](#project-status) +[![Platform](https://img.shields.io/badge/platform-Windows_%7C_macOS_%7C_Linux-0e0e0e.svg)](#project-status) **A cross-platform VPN client built on [sing-box](https://github.com/SagerNet/sing-box).**
-Desktop first — Windows is user-ready; macOS ships but is for advanced users (see below). A shared Go core is meant to extend to Linux, Android and iOS. +Desktop first — Windows is user-ready; macOS and Linux ship but are for advanced users (see below). A shared Go core is meant to extend to Android and iOS. A total eclipse: intercepted noise enters the dark, one clean signal leaves it. In tenebris lux. @@ -95,7 +95,8 @@ get one: | Desktop UI (Tauri 2 + React) | Implemented: all screens, reactive tray, notifications, deep links, autostart, i18n, themes | | Windows tunnel (wintun + sing-box) | Implemented — a background **service** runs the tunnel, so the app connects without an elevated GUI; installer sets it up, the in-app updater refreshes both app and service | | macOS tunnel (utun + sing-box) | Builds and runs — universal `.app`/DMG — but see the **macOS note** below: it needs a hand-installed root daemon and is not yet a click-to-run product | -| Linux / Android / iOS | Planned — the core is shared and platform-agnostic | +| Linux tunnel (`/dev/net/tun` + sing-box) | Builds and runs — a root **systemd service** owns the tunnel, installed by an Arch package or a `sudo` script; see the **Linux note** below. No live-tunnel sign-off yet | +| Android / iOS | Planned — the core is shared and platform-agnostic | | Release pipeline | Tag-triggered `release` workflow builds the Windows and macOS bundles, minisign-signs the in-app updater artifacts, and publishes a GitHub release | | Code-signing | Not set up — the Windows installer is Authenticode-unsigned (SmartScreen warns) and the macOS build is unsigned/un-notarized (Gatekeeper needs a manual "Open Anyway") | @@ -124,6 +125,44 @@ use the DMG only if you're comfortable running the install script yourself. **Windows users are unaffected** — the Windows installer sets up the service and the updater keeps everything current automatically. +### Linux note — the tunnel needs a root service + +Linux is the same shape as macOS: only a privileged process may open +`/dev/net/tun` and install routes, so the app talks to a small root **systemd +service** that owns the tunnel and serves the control protocol on +`/run/tenebra.sock`. The app alone cannot connect. Two ways to set it up: + +- **Arch Linux — build the package.** [`packaging/arch/PKGBUILD`](packaging/arch/PKGBUILD) + builds the core, the desktop app and the unit from source and installs them + with `pacman`: + + ``` + cd packaging/arch && makepkg -si + sudo systemctl enable --now tenebra.service + ``` + + Updates come from `pacman`, not the in-app updater — it can only replace an + AppImage, never files a package manager owns. + +- **Any other distribution — the install script.** Fetch the bundled resources, + then install the daemon from your checkout: + + ``` + bash scripts/fetch-resources.sh + sudo bash scripts/linux/install-daemon.sh --dev + ``` + + It is safe to re-run to upgrade, rolls back if an upgrade fails, and + [`scripts/linux/uninstall-daemon.sh`](scripts/linux/uninstall-daemon.sh) + removes it. The GUI is a separate `.deb`/AppImage build. + +Two limits worth knowing before you install: **system-proxy mode does nothing on +Linux** (it needs per-desktop settings a root daemon cannot reach, so it stays +quietly disarmed — tun mode, the default, is unaffected), and the bundled +sing-box binaries are **glibc-linked**, so musl distributions need their own. +Full detail, including the systemd sandbox and what is deliberately left out of +it, is in [docs/porting/linux.md](docs/porting/linux.md). + If you want to help close the gap, the macOS `SMAppService` path and the non-desktop adapters are the highest-leverage places — see [CONTRIBUTING.md](CONTRIBUTING.md). @@ -145,8 +184,14 @@ tenebra/ ├── cmd/ │ └── tenebra-core/ The sidecar entry point (talks the protocol on stdin/stdout). ├── ui-desktop/ Tauri 2 app: Rust shell (src-tauri) + React/TS front end (src). +├── deploy/ The privileged daemon's service definitions per platform. +├── packaging/ +│ └── arch/ PKGBUILD building the whole thing for Arch Linux. ├── scripts/ -│ └── fetch-resources.ps1 Download pinned sing-box + wintun for bundling. +│ ├── fetch-resources.ps1 Download pinned sing-box + wintun for bundling (Windows). +│ ├── fetch-resources.sh The same for macOS and Linux. +│ ├── macos/ Install/remove the root LaunchDaemon. +│ └── linux/ Install/remove the root systemd service. └── docs/ Architecture, control protocol, and the dev guide. ``` @@ -177,6 +222,24 @@ npm install npm run tauri build ``` +Desktop app (Linux): + +``` +# fetch the sing-box binary and the rule-sets into src-tauri/resources +bash scripts/fetch-resources.sh + +# build the core sidecar where Tauri bundles it +go build -o ui-desktop/src-tauri/binaries/tenebra-core-x86_64-unknown-linux-gnu ./cmd/tenebra-core + +# build the .deb and AppImage +cd ui-desktop +npm install +npm run tauri build +``` + +On Arch, `cd packaging/arch && makepkg -si` does all of the above and installs +the result — see the [Linux note](#linux-note--the-tunnel-needs-a-root-service). + ## Documentation - [docs/](docs/) — documentation index. diff --git a/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/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..426d976 --- /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.5 +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.-]+)?$/; From 35eb68515d2b99c3b5e930aec7dc461f0e2bedab Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:31:09 +0300 Subject: [PATCH 3/6] Build and release the Linux artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/ci.yml | 84 +++++++++++++++++ .github/workflows/release.yml | 165 +++++++++++++++++++++++++++++++++- 2 files changed, 247 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d9157e..465a53a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,90 @@ 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 + # 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 From c0fd462145405c1fdf2b70a1bba4cff6b8a59417 Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:34:26 +0300 Subject: [PATCH 4/6] Build and run the desktop app on Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui-desktop/src-tauri/linux/tenebra.desktop | 20 ++ ui-desktop/src-tauri/src/backend/mod.rs | 13 +- ui-desktop/src-tauri/src/backend/pipe.rs | 2 +- ui-desktop/src-tauri/src/backend/sidecar.rs | 22 +- ui-desktop/src-tauri/src/backend/unix.rs | 287 ++++++++++++++++-- ui-desktop/src-tauri/src/crash.rs | 34 ++- ui-desktop/src-tauri/src/lib.rs | 277 +++++++++++++---- ui-desktop/src-tauri/src/tray.rs | 6 +- ui-desktop/src-tauri/src/update_channel.rs | 30 +- ui-desktop/src-tauri/tauri.conf.json | 4 +- ui-desktop/src-tauri/tauri.linux.conf.json | 24 ++ ui-desktop/src/App.bootstrap.test.tsx | 1 + ui-desktop/src/App.crash.test.tsx | 1 + ui-desktop/src/App.deeplink.test.tsx | 1 + ui-desktop/src/App.shell.test.tsx | 1 + ui-desktop/src/App.simple.test.tsx | 1 + ui-desktop/src/App.tsx | 9 +- .../src/components/DaemonSkewBanner.test.tsx | 43 ++- .../src/components/DaemonSkewBanner.tsx | 17 +- ui-desktop/src/i18n/strings.ts | 21 ++ ui-desktop/src/lib/updates.test.ts | 25 +- ui-desktop/src/lib/updates.ts | 20 ++ ui-desktop/src/lib/useUpdateCheck.test.tsx | 30 +- ui-desktop/src/lib/useUpdateCheck.ts | 10 +- .../src/screens/SettingsScreen.test.tsx | 31 ++ ui-desktop/src/screens/SettingsScreen.tsx | 37 ++- 26 files changed, 854 insertions(+), 113 deletions(-) create mode 100644 ui-desktop/src-tauri/linux/tenebra.desktop create mode 100644 ui-desktop/src-tauri/tauri.linux.conf.json 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..db0a859 100644 --- a/ui-desktop/src-tauri/tauri.conf.json +++ b/ui-desktop/src-tauri/tauri.conf.json @@ -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} + )}