From ba18689e6448ec2d63ad69feef7b342e0b4c0779 Mon Sep 17 00:00:00 2001
From: DivanMe <48186011+Divaaaan@users.noreply.github.com>
Date: Fri, 11 Sep 2026 18:44:12 +0300
Subject: [PATCH 01/56] fix(ipc): restrict interactive pipe access to client
operations
---
core/control/peer_auth_windows_test.go | 3 +-
core/control/pipe_acl_windows_test.go | 55 ++++++++++++++++++++++++++
core/control/pipe_windows.go | 14 ++++---
core/control/pipe_windows_test.go | 11 +++++-
4 files changed, 73 insertions(+), 10 deletions(-)
create mode 100644 core/control/pipe_acl_windows_test.go
diff --git a/core/control/peer_auth_windows_test.go b/core/control/peer_auth_windows_test.go
index d4ea84c0..8aca7ab0 100644
--- a/core/control/peer_auth_windows_test.go
+++ b/core/control/peer_auth_windows_test.go
@@ -8,7 +8,6 @@ import (
"testing"
"time"
- "github.com/Microsoft/go-winio"
"golang.org/x/sys/windows"
)
@@ -56,7 +55,7 @@ func TestAuthorizePeerAllowsSelfOverPipe(t *testing.T) {
accepted <- c
}()
timeout := 3 * time.Second
- client, err := winio.DialPipe(name, &timeout)
+ client, err := dialTestPipe(name, &timeout)
if err != nil {
t.Fatalf("DialPipe(%s): %v", name, err)
}
diff --git a/core/control/pipe_acl_windows_test.go b/core/control/pipe_acl_windows_test.go
new file mode 100644
index 00000000..ae7e2c11
--- /dev/null
+++ b/core/control/pipe_acl_windows_test.go
@@ -0,0 +1,55 @@
+//go:build windows
+
+package control
+
+import (
+ "golang.org/x/sys/windows"
+ "testing"
+ "unsafe"
+)
+
+// Parse the actual server descriptor with Windows' security descriptor parser.
+// No pipe, registry key or service is opened by this test.
+func TestInteractivePipeACEExcludesServerCreation(t *testing.T) {
+ sd, err := windows.SecurityDescriptorFromString(pipeSecurityDescriptor)
+ if err != nil {
+ t.Fatal(err)
+ }
+ acl, _, err := sd.DACL()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if acl == nil {
+ t.Fatal("pipe has unrestricted DACL")
+ }
+ iu, err := windows.StringToSid("S-1-5-4")
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for i := uint32(0); i < uint32(acl.AceCount); i++ {
+ var ace *windows.ACCESS_ALLOWED_ACE
+ if err := windows.GetAce(acl, i, &ace); err != nil {
+ t.Fatal(err)
+ }
+ if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE {
+ continue
+ }
+ sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
+ if !windows.EqualSid(sid, iu) {
+ continue
+ }
+ found = true
+ const needed = windows.FILE_READ_DATA | windows.FILE_WRITE_DATA | windows.FILE_READ_ATTRIBUTES | windows.READ_CONTROL | windows.SYNCHRONIZE
+ if ace.Mask != needed {
+ t.Fatalf("interactive access = %#x, want exact client-only %#x", ace.Mask, needed)
+ }
+ const fileCreatePipeInstance = 0x4 // same bit as FILE_APPEND_DATA
+ if ace.Mask&(fileCreatePipeInstance|windows.GENERIC_WRITE|windows.GENERIC_ALL|windows.WRITE_DAC|windows.WRITE_OWNER) != 0 {
+ t.Fatalf("interactive user can create a server or rewrite pipe security: %#x", ace.Mask)
+ }
+ }
+ if !found {
+ t.Fatal("interactive client ACE missing")
+ }
+}
diff --git a/core/control/pipe_windows.go b/core/control/pipe_windows.go
index 40a27e66..3422d557 100644
--- a/core/control/pipe_windows.go
+++ b/core/control/pipe_windows.go
@@ -22,12 +22,14 @@ const PipeName = `\\.\pipe\tenebra`
// IU (INTERACTIVE) - any locally logged-in user, which is what lets the
// unprivileged GUI drive the privileged service.
//
-// GRGW (generic read/write) is what winio and every stock pipe client request
-// when dialling, so narrowing the client rights further would lock the GUI
-// out. Network logons never carry the INTERACTIVE SID, so a remote caller
-// needs administrator credentials to reach the pipe at all. See
-// docs/control-protocol.md for the security model and its honest limits.
-const pipeSecurityDescriptor = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)"
+// The interactive ACE grants only read/write data, read attributes, read control
+// and synchronize (0x120083). GENERIC_WRITE also includes the 0x4 server-instance
+// creation bit, so it would let an interactive client create a competing server.
+// Clients must request this exact access mask rather than GENERIC_READ/WRITE.
+// Network logons never carry INTERACTIVE; authenticated peer checks still run
+// after accept. See docs/control-protocol.md for the complete trust model.
+const pipeClientAccess uint32 = 0x120083
+const pipeSecurityDescriptor = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x120083;;;IU)"
// ListenPipe opens the named pipe listener the control protocol is served on.
// name is PipeName in production; tests pass unique names so parallel runs
diff --git a/core/control/pipe_windows_test.go b/core/control/pipe_windows_test.go
index d41e2bf0..58c0deaa 100644
--- a/core/control/pipe_windows_test.go
+++ b/core/control/pipe_windows_test.go
@@ -6,6 +6,7 @@ import (
"context"
"errors"
"fmt"
+ "net"
"os"
"sync"
"testing"
@@ -21,6 +22,12 @@ func testPipeName() string {
return fmt.Sprintf(`\\.\pipe\tenebra-test-%d-%d`, os.Getpid(), time.Now().UnixNano())
}
+func dialTestPipe(name string, timeout *time.Duration) (net.Conn, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), *timeout)
+ defer cancel()
+ return winio.DialPipeAccessImpLevel(ctx, name, pipeClientAccess, winio.PipeImpLevelIdentification)
+}
+
// requirePipeAccess skips the test when the current token holds none of the
// identities the pipe DACL admits (INTERACTIVE, Administrators, SYSTEM).
// Normal dev shells and CI runners are interactive; a bare network-logon
@@ -91,7 +98,7 @@ func (h *pipeHarness) awaitDone() error {
func (h *pipeHarness) dial() *lineClient {
h.t.Helper()
timeout := 3 * time.Second
- conn, err := winio.DialPipe(h.name, &timeout)
+ conn, err := dialTestPipe(h.name, &timeout)
if err != nil {
h.t.Fatalf("DialPipe(%s): %v", h.name, err)
}
@@ -242,7 +249,7 @@ func TestPipeStalledClientDoesNotBlockTheListener(t *testing.T) {
h.daemon.clientWriteTimeout = 200 * time.Millisecond
timeout := 3 * time.Second
- a, err := winio.DialPipe(h.name, &timeout)
+ a, err := dialTestPipe(h.name, &timeout)
if err != nil {
t.Fatalf("DialPipe: %v", err)
}
From b52b090cff36c5eab8dd735dbd8ad75350766341 Mon Sep 17 00:00:00 2001
From: DivanMe <48186011+Divaaaan@users.noreply.github.com>
Date: Fri, 11 Sep 2026 18:46:38 +0300
Subject: [PATCH 02/56] fix(proxy): apply in user session and retain verified
rollback ownership
---
cmd/tenebra-core/main.go | 7 +
cmd/tenebra-core/service_windows.go | 4 +-
core/control/connect.go | 60 ++--
core/control/daemon.go | 13 +-
core/control/proxy.go | 124 +++++---
core/control/proxy_controller_other.go | 11 +
core/control/proxy_other.go | 8 +-
core/control/proxy_safety_test.go | 125 ++++++++
core/control/proxy_test.go | 11 +-
core/control/proxy_windows.go | 407 +++++++++++++++++++++----
core/control/proxy_windows_test.go | 28 +-
core/control/session_proxy.go | 68 +++++
core/control/session_proxy_test.go | 115 +++++++
core/control/user_proxy_lease.go | 132 ++++++++
core/control/user_proxy_lease_test.go | 136 +++++++++
15 files changed, 1113 insertions(+), 136 deletions(-)
create mode 100644 core/control/proxy_controller_other.go
create mode 100644 core/control/proxy_safety_test.go
create mode 100644 core/control/session_proxy.go
create mode 100644 core/control/session_proxy_test.go
create mode 100644 core/control/user_proxy_lease.go
create mode 100644 core/control/user_proxy_lease_test.go
diff --git a/cmd/tenebra-core/main.go b/cmd/tenebra-core/main.go
index bbb5ce86..66310163 100644
--- a/cmd/tenebra-core/main.go
+++ b/cmd/tenebra-core/main.go
@@ -43,6 +43,13 @@ var socketMode = flag.Bool("socket", false, "serve the control protocol on a uni
var fileLogTail func(n int) []string
func main() {
+ if handled, err := control.RunUserProxyHelper(os.Args[1:]); handled {
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ return
+ }
flag.Parse()
// The service control manager starts us with no console and no usable
// stdio, so the service path must be detected before anything touches
diff --git a/cmd/tenebra-core/service_windows.go b/cmd/tenebra-core/service_windows.go
index 9f1c645a..f1893727 100644
--- a/cmd/tenebra-core/service_windows.go
+++ b/cmd/tenebra-core/service_windows.go
@@ -86,7 +86,7 @@ func (coreService) Execute(args []string, req <-chan svc.ChangeRequest, status c
// missing on every ordinary Windows install: see startBackgroundJobs.
startBackgroundJobs(ctx, daemon)
- status <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown}
+ status <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown | svc.AcceptSessionChange}
for {
select {
@@ -100,6 +100,8 @@ func (coreService) Execute(args []string, req <-chan svc.ChangeRequest, status c
switch c.Cmd {
case svc.Interrogate:
status <- c.CurrentStatus
+ case svc.SessionChange:
+ go daemon.ReconcileSystemProxyWhenIdle()
case svc.Stop, svc.Shutdown:
// The teardown stops sing-box and waits for the connection
// goroutines to drain; give the SCM an explicit budget for that
diff --git a/core/control/connect.go b/core/control/connect.go
index 4a76f6ee..4b25bf9e 100644
--- a/core/control/connect.go
+++ b/core/control/connect.go
@@ -3,6 +3,7 @@ package control
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"strings"
"time"
@@ -299,7 +300,9 @@ func (d *Daemon) startConnect(ctx context.Context, p profile.Profile, explicitNo
// Tear down any existing connection (and any in-flight loop) before starting a
// new one so we never run two sing-box processes at once.
- d.teardown(StateConnecting, p.ID, "")
+ if err := d.teardown(StateConnecting, p.ID, ""); err != nil {
+ return State{}, err
+ }
runCtx, cancel := context.WithCancel(context.Background())
d.mu.Lock()
@@ -442,7 +445,7 @@ func (d *Daemon) handleDisconnect(req Request) Response {
// follows it) is serialized against the off-command relaunch/reconcile connects.
// It waits on d.wg, which never tracks those goroutines (they run under
// relaunchWG), so the wait cannot deadlock against a connMu holder.
-func (d *Daemon) teardown(newState ConnState, profileID, nodeID string) {
+func (d *Daemon) teardown(newState ConnState, profileID, nodeID string) error {
d.mu.Lock()
cancel := d.cancel
d.cancel = nil
@@ -475,7 +478,11 @@ func (d *Daemon) teardown(newState ConnState, profileID, nodeID string) {
// be arming the proxy as it unwinds, and a disarm that ran earlier would leave
// that late arm standing. It is idempotent and a no-op unless we armed it, so
// tun-mode teardowns and the pre-start teardown of a fresh connect pay nothing.
- d.disarmSystemProxy()
+ if err := d.disarmSystemProxy(); err != nil {
+ msg := fmt.Errorf("system proxy cleanup failed: %w", err)
+ d.setState(State{State: StateError, Error: msg.Error(), Routing: d.snapshotState().Routing})
+ return msg
+ }
switch newState {
case StateIdle:
@@ -485,6 +492,7 @@ func (d *Daemon) teardown(newState ConnState, profileID, nodeID string) {
// interim value so a failure in between is still coherent.
d.setState(State{State: newState, Profile: profileID, Node: nodeID, Routing: d.snapshotState().Routing})
}
+ return nil
}
// fallbackLoop bundles the immutable inputs of one connect's fallback walk so
@@ -686,7 +694,7 @@ func (d *Daemon) runFallback(ctx context.Context, loop fallbackLoop) {
switch outcome, reason := d.attemptNode(ctx, loop, attempt, tracker); outcome {
case nodeConnected:
return // attemptNode promoted to connected and started the lifecycle
- case nodeSuperseded:
+ case nodeSuperseded, nodeLocalFailure:
return // teardown owns the state; the runner is already stopped
default: // nodeFailed
tracker.blockedWithReason(attempt, reason)
@@ -709,6 +717,8 @@ const (
// nodeFailed: the node did not come up under any strategy (or could not be
// rendered/started); the loop marks it blocked and advances to the next node.
nodeFailed
+ // nodeLocalFailure is an OS setup refusal; changing the remote node cannot fix it.
+ nodeLocalFailure
)
// attemptNode tries one node across the transport-strategy cascade. It begins
@@ -768,7 +778,15 @@ func (d *Daemon) attemptNode(ctx context.Context, loop fallbackLoop, attempt fal
if up {
// Superseded mid-probe returns up=false, so reaching here is a genuine
// success on the current generation.
- d.recordSuccess(ctx, loop, attempt, tracker, strat, sel)
+ if err := d.recordSuccess(ctx, loop, attempt, tracker, strat, sel); err != nil {
+ _ = d.runner.Stop()
+ if d.isCurrent(loop.gen) {
+ tracker.blockedWithReason(attempt, "local setup failed")
+ d.emitLog(LogError, err.Error())
+ d.setState(State{State: StateError, Profile: loop.profileID, Error: err.Error(), Routing: d.snapshotState().Routing})
+ }
+ return nodeLocalFailure, ""
+ }
return nodeConnected, ""
}
if !d.isCurrent(loop.gen) {
@@ -819,7 +837,15 @@ func (d *Daemon) attemptNode(ctx context.Context, loop fallbackLoop, attempt fal
// connection to the watcher/poller, and reconciles any option toggled during the
// connecting window. strat is the strategy the node came up under, so a
// non-default one is surfaced in the snapshot.
-func (d *Daemon) recordSuccess(ctx context.Context, loop fallbackLoop, attempt fallback.Attempt, tracker *attemptTracker, strat fallback.Strategy, sel selectorShape) {
+func (d *Daemon) recordSuccess(ctx context.Context, loop fallbackLoop, attempt fallback.Attempt, tracker *attemptTracker, strat fallback.Strategy, sel selectorShape) error {
+ if loop.tun.IsSystemProxy() {
+ if err := d.armSystemProxy(loop.tun.MixedHostPort()); err != nil {
+ return fmt.Errorf("system proxy setup failed: %w", err)
+ }
+ }
+ if ctx.Err() != nil || !d.isCurrent(loop.gen) {
+ return context.Canceled
+ }
loop.machine.Success(attempt)
// Record what the process that just came up can be steered to, so a later exit
// change can be decided against the config actually running rather than against
@@ -850,17 +876,6 @@ func (d *Daemon) recordSuccess(ctx context.Context, loop fallbackLoop, attempt f
// Capture the connected instant before publishing it, so the uptime the
// relaunch budget reads later is measured from a fixed point.
connectedAt := d.now()
- // In system-proxy mode, point the OS at the loopback mixed inbound before we
- // announce "connected", so the state never claims the tunnel is up while system
- // traffic still egresses direct. The probe already confirmed the inbound carries
- // traffic. The guard clears it again on any teardown (disconnect, hot-swap,
- // shutdown) and on a tunnel-process death, so the OS is never left pointing at a
- // proxy that is no longer listening. The address comes from loop.tun — the
- // snapshot this connect built its config from — so a mid-connect mode change
- // can't point the OS at the wrong port.
- if loop.tun.IsSystemProxy() {
- d.armSystemProxy(loop.tun.MixedHostPort())
- }
d.setState(State{State: StateConnected, Profile: loop.profileID,
Node: attempt.NodeID, Routing: d.snapshotState().Routing})
// Hand the live connection off to the watcher/poller.
@@ -868,6 +883,7 @@ func (d *Daemon) recordSuccess(ctx context.Context, loop fallbackLoop, attempt f
// A kill-switch/tun toggle that landed during the connecting window was
// recorded but not baked into this config; reconcile it now.
d.reconcileConnectingOptions(loop, attempt.NodeID)
+ return nil
}
// probeUntilUp waits for the clash API to come up, then probes the selector
@@ -1041,7 +1057,9 @@ func (d *Daemon) watchProcess(ctx context.Context, gen uint64, profileID, nodeID
// A kill-switch relaunch below re-arms it once the tunnel is back
// (recordSuccess); the plain error path leaves it cleared, which is the
// honest outcome (proxy mode has no strict_route to fail closed on).
- d.disarmSystemProxy()
+ if restoreErr := d.disarmSystemProxy(); restoreErr != nil {
+ msg += "; system proxy restore failed: " + restoreErr.Error()
+ }
if d.killSwitchRelaunch(gen, profileID, nodeID, d.now().Sub(connectedAt)) {
return // the relaunch owns the state from here
}
@@ -1543,12 +1561,12 @@ func (d *Daemon) Close() error {
// where it came from. Best-effort — a failure here must not hold up shutdown.
d.stopZapretQuietly()
d.connMu.Lock()
- d.teardown(StateIdle, "", "")
+ teardownErr := d.teardown(StateIdle, "", "")
d.connMu.Unlock()
// teardown already disarmed the system proxy; clear it once more defensively so
// process shutdown never leaves the OS pointing at a dead proxy even if some
// path armed it after the teardown. Idempotent — a no-op when already clear.
- d.disarmSystemProxy()
+ cleanupErr := d.disarmSystemProxy()
// The teardown above bumped the generation, so any kill-switch relaunch or
// connecting-window reconcile still in flight will observe it and abort instead
// of starting a tunnel. Wait for those goroutines to unwind (after releasing
@@ -1557,7 +1575,7 @@ func (d *Daemon) Close() error {
// The entitlement lookups were cancelled above; wait for them to unwind so
// none outlives us writing to the store.
d.entWG.Wait()
- return nil
+ return errors.Join(teardownErr, cleanupErr)
}
// selectorShape is what the built config's proxy selector looks like: the tag it
diff --git a/core/control/daemon.go b/core/control/daemon.go
index 1d39cce8..7fd072cc 100644
--- a/core/control/daemon.go
+++ b/core/control/daemon.go
@@ -190,10 +190,13 @@ type Daemon struct {
routing routing.Options
state State
tun singbox.TunOptions
- // proxyArmed records whether the daemon currently has the OS system proxy
- // pointed at our mixed inbound, so disarmSystemProxy clears it exactly once and
- // never touches a proxy we didn't set. Guarded by mu.
- proxyArmed bool
+ // proxyMu serializes apply/rollback; the fields below are also protected by mu
+ // when inspected with the rest of the daemon state. Armed means cleanup is
+ // owed, including a partial apply. Applied is true only after confirmed success.
+ proxyMu sync.Mutex
+ proxyArmed bool
+ proxyApplied bool
+ proxyTarget string
// emit is set by the server via SetEmitter before serving; the daemon calls
// it to publish state/traffic/log events. Guarded by mu.
@@ -498,7 +501,7 @@ func NewDaemon(store *profile.Store, runner Runner) *Daemon {
d := &Daemon{
store: store,
runner: runner,
- proxy: realSystemProxy{},
+ proxy: newSystemProxyController(),
// Only UnblockServices ships on, and the split is by direction rather than
// by convenience. It pins censored domains *to* the tunnel ahead of the geo
// rule, which is what stops YouTube from being sent direct because
diff --git a/core/control/proxy.go b/core/control/proxy.go
index 4b11a65c..23e21065 100644
--- a/core/control/proxy.go
+++ b/core/control/proxy.go
@@ -1,6 +1,7 @@
package control
import (
+ "errors"
"fmt"
"net"
"strings"
@@ -24,53 +25,52 @@ type proxyState struct {
// sequencing is unit-testable with a fake — the real registry/networksetup calls
// run only in a live session, never a unit test.
//
-// The guard deliberately toggles the proxy on and off rather than saving and
-// restoring a user's pre-existing proxy: a machine that already routes through a
-// corporate proxy is not a machine that also needs this mode, and capture/restore
-// adds a second failure surface. See the report's "left for live acceptance" note.
+// Enable must retain enough ownership information to restore any partially
+// applied change. Disable restores that snapshot and is harmless when Enable
+// failed before changing anything. A failed Disable must retain its snapshot.
type systemProxyController interface {
// Enable points the OS at hostport (the loopback mixed inbound).
Enable(hostport string) error
- // Disable removes the proxy pointer, restoring direct connectivity.
+ // Disable restores the configuration owned by this controller.
Disable() error
// Get reads the current OS proxy configuration. It backs the startup reconcile
// that clears a proxy a previous run left pointing at our mixed inbound.
Get() (proxyState, error)
}
-// realSystemProxy is the production controller. Its methods defer to the
-// build-tagged platform functions (proxy_windows.go / proxy_darwin.go /
-// proxy_other.go), mirroring how newPingDialer defers to bindSocketToInterface.
-type realSystemProxy struct{}
-
-func (realSystemProxy) Enable(hostport string) error { return enableSystemProxy(hostport) }
-func (realSystemProxy) Disable() error { return disableSystemProxy() }
-func (realSystemProxy) Get() (proxyState, error) { return readSystemProxy() }
-
-var _ systemProxyController = realSystemProxy{}
-
-// armSystemProxy points the OS at hostport and records that WE now own the proxy
-// pointer, so disarmSystemProxy later knows to clear it. It is idempotent: a
-// second call while already armed is a no-op, so a hot-swap that re-promotes the
-// same connection doesn't rewrite the registry. A failure to enable is logged and
-// leaves the guard disarmed — the tunnel is up but the OS still routes direct,
-// which the user sees as "connected but not protected", a visible, safe failure
-// rather than a half-set proxy.
-func (d *Daemon) armSystemProxy(hostport string) {
+// armSystemProxy confirms apply before the connection can be promoted. Ownership
+// starts before Enable because an error can follow a partial OS mutation.
+func (d *Daemon) armSystemProxy(hostport string) error {
+ d.proxyMu.Lock()
+ defer d.proxyMu.Unlock()
d.mu.Lock()
- already := d.proxyArmed
+ already := d.proxyApplied && d.proxyTarget == hostport
+ pending := d.proxyArmed
d.mu.Unlock()
if already {
- return
+ return nil
}
- if err := d.proxy.Enable(hostport); err != nil {
- d.emitLog(LogError, fmt.Sprintf("system proxy: could not point the OS at %s: %v", hostport, err))
- return
+ if pending {
+ if err := d.disarmSystemProxyLocked(); err != nil {
+ return fmt.Errorf("restore previous system proxy before applying: %w", err)
+ }
}
d.mu.Lock()
d.proxyArmed = true
d.mu.Unlock()
+ if err := d.proxy.Enable(hostport); err != nil {
+ rollback := d.disarmSystemProxyLocked()
+ if rollback != nil {
+ rollback = fmt.Errorf("rollback system proxy: %w", rollback)
+ }
+ return errors.Join(err, rollback)
+ }
+ d.mu.Lock()
+ d.proxyApplied = true
+ d.proxyTarget = hostport
+ d.mu.Unlock()
d.emitLog(LogInfo, "system proxy: OS now routing through "+hostport)
+ return nil
}
// disarmSystemProxy clears the OS proxy pointer if (and only if) we armed it,
@@ -78,22 +78,31 @@ func (d *Daemon) armSystemProxy(hostport string) {
// leaves a system-proxy connection — an explicit disconnect, a tunnel-process
// death, connect supersession, and daemon shutdown — funnels through it, so the
// OS is never left pointing at a mixed inbound that is no longer listening. It is
-// idempotent (a no-op when not armed) and clears the armed flag up front, so a
-// Disable error can't wedge the guard into retrying forever; a persistent failure
-// is logged loudly and the next startup's reconcile is the backstop.
-func (d *Daemon) disarmSystemProxy() {
+// idempotent. A failure retains ownership so a later disconnect/startup can retry.
+func (d *Daemon) disarmSystemProxy() error {
+ d.proxyMu.Lock()
+ defer d.proxyMu.Unlock()
+ return d.disarmSystemProxyLocked()
+}
+
+func (d *Daemon) disarmSystemProxyLocked() error {
d.mu.Lock()
armed := d.proxyArmed
- d.proxyArmed = false
+ d.proxyApplied = false
d.mu.Unlock()
if !armed {
- return
+ return nil
}
if err := d.proxy.Disable(); err != nil {
- d.emitLog(LogError, fmt.Sprintf("system proxy: could not restore direct connectivity: %v; turn the proxy off in OS network settings", err))
- return
+ d.emitLog(LogError, fmt.Sprintf("system proxy: could not restore previous settings: %v; cleanup remains pending", err))
+ return err
}
- d.emitLog(LogInfo, "system proxy: cleared; OS back to direct")
+ d.mu.Lock()
+ d.proxyArmed = false
+ d.proxyTarget = ""
+ d.mu.Unlock()
+ d.emitLog(LogInfo, "system proxy: previous settings restored")
+ return nil
}
// ReconcileSystemProxyAtStartup clears a system proxy a previous run left pointing
@@ -110,6 +119,19 @@ func (d *Daemon) disarmSystemProxy() {
// touches a proxy tenebra did not set. main calls it once at startup, before
// serving, while the daemon is idle. It never arms anything.
func (d *Daemon) ReconcileSystemProxyAtStartup() (cleared bool, err error) {
+ d.proxyMu.Lock()
+ defer d.proxyMu.Unlock()
+ if owned, ok := d.proxy.(interface{ Reconcile() (bool, error) }); ok {
+ found, restoreErr := owned.Reconcile()
+ d.mu.Lock()
+ if found {
+ d.proxyArmed = restoreErr != nil
+ d.proxyApplied = false
+ d.proxyTarget = ""
+ }
+ d.mu.Unlock()
+ return found && restoreErr == nil, restoreErr
+ }
st, err := d.proxy.Get()
if err != nil {
return false, fmt.Errorf("read OS proxy state: %w", err)
@@ -124,6 +146,32 @@ func (d *Daemon) ReconcileSystemProxyAtStartup() (cleared bool, err error) {
return true, nil
}
+// ReconcileSystemProxyWhenIdle handles a console logon after service startup.
+// Session notifications must not block the SCM handler or race a new connect.
+func (d *Daemon) ReconcileSystemProxyWhenIdle() {
+ if !d.connMu.TryLock() {
+ return
+ }
+ defer d.connMu.Unlock()
+ st := d.snapshotState()
+ if st.State != StateIdle && st.State != StateError {
+ return
+ }
+ if cleared, err := d.ReconcileSystemProxyAtStartup(); err != nil {
+ d.emitLog(LogWarn, fmt.Sprintf("system proxy session restore: %v", err))
+ d.mu.Lock()
+ pending := d.proxyArmed
+ d.mu.Unlock()
+ if pending {
+ st.State = StateError
+ st.Error = "system proxy restore remains pending: " + err.Error()
+ d.setState(st)
+ }
+ } else if cleared {
+ d.emitLog(LogInfo, "system proxy: recovered previous user settings after logon")
+ }
+}
+
// sameProxyTarget reports whether two proxy server strings name the same
// host:port, comparing case-insensitively on host and ignoring surrounding
// whitespace. An unparseable or portless value on either side is treated as "not
diff --git a/core/control/proxy_controller_other.go b/core/control/proxy_controller_other.go
new file mode 100644
index 00000000..1e1678de
--- /dev/null
+++ b/core/control/proxy_controller_other.go
@@ -0,0 +1,11 @@
+//go:build !windows
+
+package control
+
+type realSystemProxy struct{}
+
+func (realSystemProxy) Enable(target string) error { return enableSystemProxy(target) }
+func (realSystemProxy) Disable() error { return disableSystemProxy() }
+func (realSystemProxy) Get() (proxyState, error) { return readSystemProxy() }
+func newSystemProxyController() systemProxyController { return realSystemProxy{} }
+func RunUserProxyHelper([]string) (bool, error) { return false, nil }
diff --git a/core/control/proxy_other.go b/core/control/proxy_other.go
index c9c1625b..5a3ac19b 100644
--- a/core/control/proxy_other.go
+++ b/core/control/proxy_other.go
@@ -9,14 +9,14 @@ import "errors"
// proxy there means writing per-desktop settings (GNOME's gsettings, KDE's
// kioslaverc, and a session's own environment) as the logged-in user, which a
// root daemon has no session bus to reach — a separate piece of work from
-// bringing the tun path up. The daemon degrades gracefully: arming logs this and
-// stays disarmed, so system-proxy mode simply doesn't take effect rather than
-// crashing the core, and tun mode — the default — is unaffected.
+// bringing the tun path up. The daemon reports a local setup failure rather
+// than promoting a connection whose OS proxy could not be applied. TUN remains
+// the default supported mode.
var errSystemProxyUnsupported = errors.New("control: system proxy is not supported on this platform")
func enableSystemProxy(string) error { return errSystemProxyUnsupported }
-func disableSystemProxy() error { return errSystemProxyUnsupported }
+func disableSystemProxy() error { return nil } // unsupported apply cannot mutate the OS
// readSystemProxy reports "no proxy set" with no error so the startup reconcile
// finds nothing to clear rather than logging a spurious failure on every launch.
diff --git a/core/control/proxy_safety_test.go b/core/control/proxy_safety_test.go
new file mode 100644
index 00000000..b7af4efa
--- /dev/null
+++ b/core/control/proxy_safety_test.go
@@ -0,0 +1,125 @@
+package control
+
+import (
+ "context"
+ "errors"
+ "net"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Divaaaan/tenebra/core/model"
+ "github.com/Divaaaan/tenebra/core/profile"
+ "github.com/Divaaaan/tenebra/core/singbox"
+)
+
+// The external boundaries are fake: these tests never mutate the host proxy or
+// start/stop the real engine or bypass. In particular, cleanup does not use Close.
+func proxySafetyDaemon(t *testing.T) (*Daemon, *fakeRunner, profile.Profile) {
+ t.Helper()
+ s, err := profile.Open(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ p, err := profile.NewProfile("proxy safety", profile.SourceManual, "", []model.Node{{
+ Protocol: model.VLESS, Name: "fixture", Server: "192.0.2.1", Port: 443,
+ UUID: "123e4567-e89b-12d3-a456-426614174000",
+ }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Add(p); err != nil {
+ t.Fatal(err)
+ }
+ r := newFakeRunner()
+ d := NewDaemon(s, r)
+ d.localAddrs = func() []net.Addr { return nil }
+ d.tunWatchInterval, d.healthInterval, d.bypassVerifyDelay = 0, 0, 0
+ d.proxy = &fakeProxyController{}
+ d.probeWarmup, d.probeRetry = time.Millisecond, time.Millisecond
+ d.probeTimeout, d.probeBudget = time.Second, time.Second
+ t.Cleanup(func() {
+ d.connMu.Lock()
+ d.teardown(StateIdle, "", "")
+ d.connMu.Unlock()
+ d.relaunchWG.Wait()
+ d.entCancel()
+ })
+ return d, r, p
+}
+
+func TestProxyApplyFailureDoesNotPublishConnected(t *testing.T) {
+ d, r, p := proxySafetyDaemon(t)
+ f := &fakeProxyController{enableErr: errors.New("user proxy apply denied")}
+ d.proxy = f
+ d.tun.Mode = singbox.ModeSystemProxy
+ d.connMu.Lock()
+ _, err := d.startConnect(context.Background(), p, p.Servers[0].ID, false, false, "")
+ d.connMu.Unlock()
+ if err != nil {
+ t.Fatal(err)
+ }
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ st := d.snapshotState()
+ if st.State == StateConnected {
+ t.Fatal("published connected despite failed OS proxy apply")
+ }
+ if st.State == StateError {
+ if !strings.Contains(st.Error, "system proxy") {
+ t.Fatalf("unhelpful error: %q", st.Error)
+ }
+ if r.stops() == 0 {
+ t.Fatal("unused engine left running after proxy failure")
+ }
+ if f.disables() != 1 {
+ t.Fatalf("rollback calls = %d, want 1", f.disables())
+ }
+ return
+ }
+ time.Sleep(time.Millisecond)
+ }
+ t.Fatal("proxy failure never reached error state")
+}
+
+func TestProxyCleanupFailureRetainsOwnershipUntilRetrySucceeds(t *testing.T) {
+ d, _, _ := proxySafetyDaemon(t)
+ f := &fakeProxyController{disableErr: errors.New("temporary cleanup failure")}
+ d.proxy = f
+ d.armSystemProxy("127.0.0.1:2080")
+ d.disarmSystemProxy()
+ d.mu.Lock()
+ pending := d.proxyArmed
+ d.mu.Unlock()
+ if !pending {
+ t.Fatal("cleanup failure discarded ownership")
+ }
+ f.mu.Lock()
+ f.disableErr = nil
+ f.mu.Unlock()
+ d.disarmSystemProxy()
+ d.disarmSystemProxy()
+ if f.disables() != 2 {
+ t.Fatalf("cleanup attempts=%d, want failed + successful", f.disables())
+ }
+ d.mu.Lock()
+ pending = d.proxyArmed
+ d.mu.Unlock()
+ if pending {
+ t.Fatal("successful cleanup did not release ownership")
+ }
+}
+
+func TestPartialProxyApplyRollsBackBeforeReportingFailure(t *testing.T) {
+ d, _, _ := proxySafetyDaemon(t)
+ f := &fakeProxyController{enableErr: errors.New("refresh failed after registry write")}
+ d.proxy = f
+ d.armSystemProxy("127.0.0.1:2080")
+ if f.disables() != 1 {
+ t.Fatal("potentially partial application was not rolled back")
+ }
+ d.disarmSystemProxy()
+ if f.disables() != 1 {
+ t.Fatal("successful rollback was repeated")
+ }
+}
diff --git a/core/control/proxy_test.go b/core/control/proxy_test.go
index 504407c7..34c834aa 100644
--- a/core/control/proxy_test.go
+++ b/core/control/proxy_test.go
@@ -124,11 +124,8 @@ func TestSystemProxyDisarmWithoutArmIsNoop(t *testing.T) {
}
}
-// TestSystemProxyArmFailureLeavesDisarmed: a failed Enable must leave the guard
-// disarmed, so a later teardown does not wrongly believe it owns (and then clear)
-// a proxy that was never set. The tunnel is up but unprotected — a visible, safe
-// failure, not a corrupt half-state.
-func TestSystemProxyArmFailureLeavesDisarmed(t *testing.T) {
+// A failed Enable can have partially applied state, so it must be rolled back.
+func TestSystemProxyArmFailureRollsBack(t *testing.T) {
d, f := bareDaemonWithProxy(t)
f.enableErr = errors.New("registry write denied")
@@ -137,8 +134,8 @@ func TestSystemProxyArmFailureLeavesDisarmed(t *testing.T) {
t.Errorf("enables = %d, want 1 attempt", f.enables())
}
d.disarmSystemProxy()
- if f.disables() != 0 {
- t.Errorf("disarm after a failed arm called Disable %d times, want 0 (nothing was set)", f.disables())
+ if f.disables() != 1 {
+ t.Errorf("disarm after a failed arm called Disable %d times, want 1 rollback", f.disables())
}
}
diff --git a/core/control/proxy_windows.go b/core/control/proxy_windows.go
index 7d649862..8ef4ef17 100644
--- a/core/control/proxy_windows.go
+++ b/core/control/proxy_windows.go
@@ -3,98 +3,391 @@
package control
import (
+ "encoding/json"
+ "errors"
"fmt"
+ "os"
+ "path/filepath"
+ "runtime"
"strings"
+ "unsafe"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
)
-// inetSettingsKey is the per-user WinINet configuration key. Writing ProxyEnable
-// and ProxyServer here is exactly what the Internet Options dialog does, so it
-// applies without admin rights — the whole point of system-proxy mode on a
-// locked-down machine.
-const inetSettingsKey = `Software\Microsoft\Windows\CurrentVersion\Internet Settings`
-
-// WinINet InternetSetOption codes, from Wininet.h. SETTINGS_CHANGED tells running
-// processes the proxy config changed; REFRESH makes them reload it, so an open
-// browser honours the new setting without a restart.
const (
+ inetSettingsKey = `Software\Microsoft\Windows\CurrentVersion\Internet Settings`
+ proxyLeaseKey = `Software\Tenebra`
+ proxyLeaseValue = "SystemProxyLease"
+ proxyHelperFlag = "--user-proxy-helper"
+ proxyLeaseMaxBytes = 64 << 10
internetOptionSettingsChanged = 39
internetOptionRefresh = 37
+ internetOptionPerConnection = 75
)
var (
- modWininet = windows.NewLazySystemDLL("wininet.dll")
- procInternetSetOption = modWininet.NewProc("InternetSetOptionW")
+ modWininet = windows.NewLazySystemDLL("wininet.dll")
+ procInternetSetOption = modWininet.NewProc("InternetSetOptionW")
+ procInternetQueryOption = modWininet.NewProc("InternetQueryOptionW")
+ procProxyGlobalFree = windows.NewLazySystemDLL("kernel32.dll").NewProc("GlobalFree")
+ procProxyRegFlushKey = windows.NewLazySystemDLL("advapi32.dll").NewProc("RegFlushKey")
)
-// enableSystemProxy points the current user's WinINet proxy at hostport for all
-// protocols and refreshes live so open apps pick it up without a restart.
-func enableSystemProxy(hostport string) error {
- k, err := registry.OpenKey(registry.CURRENT_USER, inetSettingsKey, registry.SET_VALUE)
+func newSystemProxyController() systemProxyController {
+ return &sessionSystemProxy{ops: windowsProxySessions{}}
+}
+
+type windowsProxySessions struct{}
+
+func (windowsProxySessions) Current() (proxyUser, error) {
+ self, err := currentUserSID()
+ if err != nil {
+ return proxyUser{}, err
+ }
+ if self != "S-1-5-18" {
+ var session uint32
+ if err := windows.ProcessIdToSessionId(windows.GetCurrentProcessId(), &session); err != nil {
+ return proxyUser{}, err
+ }
+ return proxyUser{SID: self, Session: session}, nil
+ }
+ session := windows.WTSGetActiveConsoleSessionId()
+ if session == 0xffffffff {
+ return proxyUser{}, errors.New("no active console user for system proxy")
+ }
+ tok, err := proxySessionToken(proxyUser{Session: session})
+ if err != nil {
+ return proxyUser{}, err
+ }
+ defer tok.Close()
+ u, err := tok.GetTokenUser()
if err != nil {
- return fmt.Errorf("open Internet Settings: %w", err)
+ return proxyUser{}, err
+ }
+ return proxyUser{SID: u.User.Sid.String(), Session: session}, nil
+}
+
+// A reused session ID must never restore a lease into another user's account.
+func proxySessionToken(u proxyUser) (windows.Token, error) {
+ var tok windows.Token
+ if err := windows.WTSQueryUserToken(u.Session, &tok); err != nil {
+ return 0, fmt.Errorf("open interactive user token: %w", err)
+ }
+ tu, err := tok.GetTokenUser()
+ if err != nil || (u.SID != "" && tu.User.Sid.String() != u.SID) {
+ tok.Close()
+ return 0, errors.New("system proxy owner session is unavailable or changed")
+ }
+ return tok, nil
+}
+
+func (windowsProxySessions) Run(u proxyUser, action, target string) error {
+ self, err := currentUserSID()
+ if err != nil {
+ return err
+ }
+ if self != "S-1-5-18" {
+ if self != u.SID {
+ return errors.New("system proxy owner differs from the current user")
+ }
+ return runUserProxyAction(action, target)
+ }
+ tok, err := proxySessionToken(u)
+ if err != nil {
+ return err
+ }
+ defer tok.Close()
+ return launchUserProxyHelper(tok, action, target)
+}
+
+func openProxyUserKey(u proxyUser, path string) (registry.Key, error) {
+ if u.SID == "" {
+ return 0, errors.New("missing system proxy owner")
+ }
+ return registry.OpenKey(registry.USERS, u.SID+`\`+path, registry.QUERY_VALUE)
+}
+
+func (windowsProxySessions) Read(u proxyUser) (proxyState, error) {
+ k, err := openProxyUserKey(u, inetSettingsKey)
+ if err != nil {
+ return proxyState{}, err
}
defer k.Close()
- // ProxyServer as a bare host:port applies to every protocol (HTTP/HTTPS), which
- // is what the mixed inbound serves.
- if err := k.SetStringValue("ProxyServer", hostport); err != nil {
- return fmt.Errorf("set ProxyServer: %w", err)
+ on, _, err := k.GetIntegerValue("ProxyEnable")
+ if err != nil && !errors.Is(err, registry.ErrNotExist) {
+ return proxyState{}, err
}
- if err := k.SetDWordValue("ProxyEnable", 1); err != nil {
- return fmt.Errorf("set ProxyEnable: %w", err)
+ server, _, err := k.GetStringValue("ProxyServer")
+ if err != nil && !errors.Is(err, registry.ErrNotExist) {
+ return proxyState{}, err
}
- return refreshWinINet()
+ return proxyState{Enabled: on == 1, Server: firstProxyTarget(server)}, nil
}
-// disableSystemProxy turns the current user's WinINet proxy off and refreshes. It
-// leaves ProxyServer in place — harmless once ProxyEnable is 0 — so this touches
-// only the flag, minimising what the guard rewrites.
-func disableSystemProxy() error {
- k, err := registry.OpenKey(registry.CURRENT_USER, inetSettingsKey, registry.SET_VALUE)
+func (windowsProxySessions) HasLease(u proxyUser) (bool, error) {
+ k, err := openProxyUserKey(u, proxyLeaseKey)
+ if errors.Is(err, registry.ErrNotExist) {
+ return false, nil
+ }
if err != nil {
- return fmt.Errorf("open Internet Settings: %w", err)
+ return false, err
}
defer k.Close()
- if err := k.SetDWordValue("ProxyEnable", 0); err != nil {
- return fmt.Errorf("clear ProxyEnable: %w", err)
+ _, _, err = k.GetValue(proxyLeaseValue, nil)
+ if errors.Is(err, registry.ErrNotExist) {
+ return false, nil
}
- return refreshWinINet()
+ return err == nil, err
}
-// readSystemProxy reads ProxyEnable/ProxyServer for the startup reconcile. A
-// missing value reads as off/empty rather than an error, so a machine that never
-// had a proxy set is simply "not enabled".
-func readSystemProxy() (proxyState, error) {
- k, err := registry.OpenKey(registry.CURRENT_USER, inetSettingsKey, registry.QUERY_VALUE)
+// WinINet is unsupported in services. Run the installed, administrator-protected
+// core as the interactive user, before any normal daemon initialization.
+// No inherited handles cross the session boundary.
+// https://learn.microsoft.com/en-us/windows/win32/wininet/enabling-internet-functionality
+func launchUserProxyHelper(tok windows.Token, action, target string) error {
+ exe, err := os.Executable()
if err != nil {
- return proxyState{}, fmt.Errorf("open Internet Settings: %w", err)
+ return err
+ }
+ args := []string{exe, proxyHelperFlag, action}
+ if action == "apply" {
+ args = append(args, target)
+ }
+ app, err := windows.UTF16PtrFromString(exe)
+ if err != nil {
+ return err
+ }
+ cmd, err := windows.UTF16PtrFromString(windows.ComposeCommandLine(args))
+ if err != nil {
+ return err
+ }
+ dir, err := windows.UTF16PtrFromString(filepath.Dir(exe))
+ if err != nil {
+ return err
+ }
+ desktop, _ := windows.UTF16PtrFromString(`winsta0\default`)
+ var env *uint16
+ if err := windows.CreateEnvironmentBlock(&env, tok, false); err != nil {
+ return fmt.Errorf("create user environment: %w", err)
+ }
+ defer windows.DestroyEnvironmentBlock(env)
+ si := windows.StartupInfo{Cb: uint32(unsafe.Sizeof(windows.StartupInfo{})), Desktop: desktop, Flags: windows.STARTF_USESHOWWINDOW, ShowWindow: windows.SW_HIDE}
+ var pi windows.ProcessInformation
+ if err := windows.CreateProcessAsUser(tok, app, cmd, nil, nil, false, windows.CREATE_UNICODE_ENVIRONMENT|windows.CREATE_NO_WINDOW, env, dir, &si, &pi); err != nil {
+ return fmt.Errorf("start interactive user proxy helper: %w", err)
+ }
+ defer windows.CloseHandle(pi.Process)
+ windows.CloseHandle(pi.Thread)
+ wait, err := windows.WaitForSingleObject(pi.Process, 12_000)
+ if err != nil || wait != windows.WAIT_OBJECT_0 {
+ // Only this helper is terminated. The durable snapshot survives timeout;
+ // the daemon retains cleanup ownership and retries restore.
+ _ = windows.TerminateProcess(pi.Process, 1)
+ _, _ = windows.WaitForSingleObject(pi.Process, 1_000)
+ return errors.New("interactive user proxy helper timed out or could not be waited for; restore remains pending")
+ }
+ var code uint32
+ if err := windows.GetExitCodeProcess(pi.Process, &code); err != nil {
+ return err
+ }
+ if code != 0 {
+ return fmt.Errorf("interactive user proxy %s failed (exit %d)", action, code)
+ }
+ return nil
+}
+
+// RunUserProxyHelper recognizes a tiny protocol before flag parsing/service
+// detection. It can never launch an engine or background jobs.
+func RunUserProxyHelper(args []string) (bool, error) {
+ if len(args) == 0 || args[0] != proxyHelperFlag {
+ return false, nil
+ }
+ if len(args) == 2 && args[1] == "restore" {
+ return true, runUserProxyAction("restore", "")
+ }
+ if len(args) == 3 && args[1] == "apply" && validUserProxyTarget(args[2]) {
+ return true, runUserProxyAction("apply", args[2])
+ }
+ return true, errors.New("invalid user proxy helper arguments")
+}
+
+func runUserProxyAction(action, target string) error {
+ if action != "restore" && (action != "apply" || !validUserProxyTarget(target)) {
+ return errors.New("invalid user proxy operation")
+ }
+ sid, err := currentUserSID()
+ if err != nil {
+ return err
+ }
+ if sid == "S-1-5-18" || sid == "S-1-5-19" || sid == "S-1-5-20" {
+ return errors.New("WinINet proxy helper requires an interactive user")
+ }
+ // A mutex is thread-owned, so pin the goroutine until ReleaseMutex.
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+ name, _ := windows.UTF16PtrFromString(`Global\Tenebra.UserProxy.` + sid)
+ lock, err := windows.CreateMutex(nil, false, name)
+ if err != nil && !errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
+ return err
+ }
+ defer windows.CloseHandle(lock)
+ result, err := windows.WaitForSingleObject(lock, 5_000)
+ if err != nil || (result != windows.WAIT_OBJECT_0 && result != windows.WAIT_ABANDONED) {
+ return errors.New("user proxy operation is busy")
+ }
+ defer windows.ReleaseMutex(lock)
+ ops := wininetProxyOperations{}
+ if action == "apply" {
+ return applyUserProxy(ops, target)
+ }
+ return restoreUserProxy(ops)
+}
+
+type wininetProxyOperations struct{}
+
+func (wininetProxyOperations) Load() (*userProxyLease, error) {
+ k, err := registry.OpenKey(registry.CURRENT_USER, proxyLeaseKey, registry.QUERY_VALUE)
+ if errors.Is(err, registry.ErrNotExist) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
}
defer k.Close()
+ buf := make([]byte, proxyLeaseMaxBytes)
+ n, kind, err := k.GetValue(proxyLeaseValue, buf)
+ if errors.Is(err, registry.ErrNotExist) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ if kind != registry.BINARY || n > len(buf) {
+ return nil, errors.New("invalid user proxy snapshot format")
+ }
+ var lease userProxyLease
+ if err := json.Unmarshal(buf[:n], &lease); err != nil {
+ return nil, errors.New("invalid user proxy snapshot JSON")
+ }
+ return &lease, nil
+}
- enable, _, err := k.GetIntegerValue("ProxyEnable")
- if err != nil && err != registry.ErrNotExist {
- return proxyState{}, fmt.Errorf("read ProxyEnable: %w", err)
+func (wininetProxyOperations) Save(lease userProxyLease) error {
+ buf, err := json.Marshal(lease)
+ if err != nil {
+ return err
}
- server, _, err := k.GetStringValue("ProxyServer")
- if err != nil && err != registry.ErrNotExist {
- return proxyState{}, fmt.Errorf("read ProxyServer: %w", err)
+ if len(buf) > proxyLeaseMaxBytes {
+ return errors.New("user proxy snapshot is too large")
}
- return proxyState{Enabled: enable == 1, Server: firstProxyTarget(server)}, nil
+ k, _, err := registry.CreateKey(registry.CURRENT_USER, proxyLeaseKey, registry.SET_VALUE)
+ if err != nil {
+ return err
+ }
+ defer k.Close()
+ if err := k.SetBinaryValue(proxyLeaseValue, buf); err != nil {
+ return err
+ }
+ return flushProxyJournal(k)
}
-// firstProxyTarget extracts a bare host:port from a WinINet ProxyServer value.
-// The value is either a single "host:port" (what enableSystemProxy writes) or a
-// per-protocol list like "http=127.0.0.1:2080;https=127.0.0.1:2080"; the reconcile
-// only needs one target to compare, so strip any "scheme=" prefix and take the
-// first entry. A plain value passes through unchanged.
-func firstProxyTarget(v string) string {
- v = strings.TrimSpace(v)
- if v == "" {
- return ""
+func (wininetProxyOperations) Delete() error {
+ k, err := registry.OpenKey(registry.CURRENT_USER, proxyLeaseKey, registry.SET_VALUE)
+ if errors.Is(err, registry.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ defer k.Close()
+ if err := k.DeleteValue(proxyLeaseValue); err != nil && !errors.Is(err, registry.ErrNotExist) {
+ return err
+ }
+ return flushProxyJournal(k)
+}
+
+func flushProxyJournal(k registry.Key) error {
+ code, _, _ := procProxyRegFlushKey.Call(uintptr(k))
+ if code != 0 {
+ return fmt.Errorf("flush user proxy snapshot: %w", windows.Errno(code))
+ }
+ return nil
+}
+
+// INTERNET_PER_CONN_OPTION's union is eight bytes (FILETIME), aligned as a
+// pointer on 64-bit Windows and as a DWORD on 32-bit Windows.
+type internetPerConnOption struct {
+ Option uint32
+ Value uint64
+}
+type internetPerConnList struct {
+ Size uint32
+ Connection *uint16
+ Count uint32
+ Error uint32
+ Options *internetPerConnOption
+}
+
+func proxyOptionString(o *internetPerConnOption) *uint16 {
+ return *(**uint16)(unsafe.Pointer(&o.Value))
+}
+
+func freeProxyOptionStrings(opts []internetPerConnOption) {
+ for i := 1; i < len(opts); i++ {
+ if p := proxyOptionString(&opts[i]); p != nil {
+ procProxyGlobalFree.Call(uintptr(unsafe.Pointer(p)))
+ opts[i].Value = 0
+ }
}
- first := v
+}
+
+func (wininetProxyOperations) Read() (userProxySettings, error) {
+ // Query FLAGS_UI (10) with FLAGS (1) fallback; write with FLAGS (1).
+ for _, flagOption := range []uint32{10, 1} {
+ opts := []internetPerConnOption{{Option: flagOption}, {Option: 2}, {Option: 3}, {Option: 4}}
+ list := internetPerConnList{Count: uint32(len(opts)), Options: &opts[0]}
+ list.Size = uint32(unsafe.Sizeof(list))
+ size := list.Size
+ r, _, err := procInternetQueryOption.Call(0, internetOptionPerConnection, uintptr(unsafe.Pointer(&list)), uintptr(unsafe.Pointer(&size)))
+ if r == 0 {
+ freeProxyOptionStrings(opts)
+ if flagOption == 10 {
+ continue
+ }
+ return userProxySettings{}, fmt.Errorf("query user WinINet proxy: %w", err)
+ }
+ st := userProxySettings{Flags: uint32(opts[0].Value), Server: windows.UTF16PtrToString(proxyOptionString(&opts[1])), Bypass: windows.UTF16PtrToString(proxyOptionString(&opts[2])), PAC: windows.UTF16PtrToString(proxyOptionString(&opts[3]))}
+ freeProxyOptionStrings(opts)
+ return st, nil
+ }
+ return userProxySettings{}, errors.New("user proxy query unavailable")
+}
+
+func (wininetProxyOperations) Write(st userProxySettings) error {
+ opts := []internetPerConnOption{{Option: 1, Value: uint64(st.Flags)}, {Option: 2}, {Option: 3}, {Option: 4}}
+ keep := make([]*uint16, 0, 3)
+ for i, s := range []string{st.Server, st.Bypass, st.PAC} {
+ ptr, err := windows.UTF16PtrFromString(s)
+ if err != nil {
+ return err
+ }
+ keep = append(keep, ptr)
+ *(*unsafe.Pointer)(unsafe.Pointer(&opts[i+1].Value)) = unsafe.Pointer(ptr)
+ }
+ list := internetPerConnList{Count: uint32(len(opts)), Options: &opts[0]}
+ list.Size = uint32(unsafe.Sizeof(list))
+ r, _, err := procInternetSetOption.Call(0, internetOptionPerConnection, uintptr(unsafe.Pointer(&list)), uintptr(list.Size))
+ runtime.KeepAlive(keep)
+ if r == 0 {
+ return fmt.Errorf("set user WinINet proxy: %w", err)
+ }
+ return refreshWinINet()
+}
+
+func firstProxyTarget(v string) string {
+ first := strings.TrimSpace(v)
if i := strings.IndexByte(first, ';'); i >= 0 {
first = first[:i]
}
@@ -104,10 +397,6 @@ func firstProxyTarget(v string) string {
return strings.TrimSpace(first)
}
-// refreshWinINet broadcasts the settings-changed and refresh options so running
-// processes reload the proxy configuration immediately. A zero return from
-// InternetSetOption signals failure; the accompanying error is the last-call
-// error only then.
func refreshWinINet() error {
if r, _, err := procInternetSetOption.Call(0, internetOptionSettingsChanged, 0, 0); r == 0 {
return fmt.Errorf("InternetSetOption(SETTINGS_CHANGED): %w", err)
diff --git a/core/control/proxy_windows_test.go b/core/control/proxy_windows_test.go
index 6dd92957..16e9542b 100644
--- a/core/control/proxy_windows_test.go
+++ b/core/control/proxy_windows_test.go
@@ -2,7 +2,33 @@
package control
-import "testing"
+import (
+ "testing"
+ "unsafe"
+)
+
+func TestUserProxyWinINetABILayout(t *testing.T) {
+ var option internetPerConnOption
+ var list internetPerConnList
+ if unsafe.Sizeof(uintptr(0)) == 8 {
+ if unsafe.Sizeof(option) != 16 || unsafe.Offsetof(option.Value) != 8 || unsafe.Sizeof(list) != 32 || unsafe.Offsetof(list.Options) != 24 {
+ t.Fatal("WinINet 64-bit ABI mismatch")
+ }
+ } else if unsafe.Sizeof(option) != 12 || unsafe.Offsetof(option.Value) != 4 || unsafe.Sizeof(list) != 20 || unsafe.Offsetof(list.Options) != 16 {
+ t.Fatal("WinINet 32-bit ABI mismatch")
+ }
+}
+
+func TestUserProxyHelperRejectsInvalidArgumentsBeforeNativeWork(t *testing.T) {
+ for _, args := range [][]string{{proxyHelperFlag}, {proxyHelperFlag, "restore", "extra"}, {proxyHelperFlag, "apply", "192.0.2.1:80"}, {proxyHelperFlag, "anything"}} {
+ if handled, err := RunUserProxyHelper(args); !handled || err == nil {
+ t.Fatalf("accepted malformed helper arguments: %v", args)
+ }
+ }
+ if handled, err := RunUserProxyHelper([]string{"--pipe"}); handled || err != nil {
+ t.Fatal("ordinary core flags treated as proxy helper")
+ }
+}
// TestFirstProxyTarget pins how a WinINet ProxyServer value is reduced to a bare
// host:port for the startup reconcile's comparison: a plain value passes through,
diff --git a/core/control/session_proxy.go b/core/control/session_proxy.go
new file mode 100644
index 00000000..d8e468ee
--- /dev/null
+++ b/core/control/session_proxy.go
@@ -0,0 +1,68 @@
+package control
+
+// proxyUser identifies the owner of a per-user proxy lease. Session ID alone
+// can be reused after logout; SID must also match before any cleanup is run.
+type proxyUser struct {
+ SID string
+ Session uint32
+}
+
+type userProxySessionOps interface {
+ Current() (proxyUser, error)
+ Run(proxyUser, string, string) error
+ Read(proxyUser) (proxyState, error)
+ HasLease(proxyUser) (bool, error)
+}
+
+type sessionSystemProxy struct {
+ ops userProxySessionOps
+ owner *proxyUser
+}
+
+func (p *sessionSystemProxy) Enable(target string) error {
+ if p.owner == nil {
+ u, err := p.ops.Current()
+ if err != nil {
+ return err
+ }
+ p.owner = &u // retain the user even if apply fails after a partial write
+ }
+ return p.ops.Run(*p.owner, "apply", target)
+}
+
+func (p *sessionSystemProxy) Disable() error {
+ if p.owner == nil {
+ return nil
+ }
+ if err := p.ops.Run(*p.owner, "restore", ""); err != nil {
+ return err
+ }
+ p.owner = nil
+ return nil
+}
+
+func (p *sessionSystemProxy) Get() (proxyState, error) {
+ u, err := p.ops.Current()
+ if err != nil {
+ return proxyState{}, err
+ }
+ return p.ops.Read(u)
+}
+
+// Reconcile restores only a durable Tenebra lease, including a partially
+// applied or already-disabled proxy. Merely sharing our port is not ownership.
+func (p *sessionSystemProxy) Reconcile() (bool, error) {
+ if p.owner != nil {
+ return true, p.Disable()
+ }
+ u, err := p.ops.Current()
+ if err != nil {
+ return false, err
+ }
+ has, err := p.ops.HasLease(u)
+ if err != nil || !has {
+ return false, err
+ }
+ p.owner = &u
+ return true, p.Disable()
+}
diff --git a/core/control/session_proxy_test.go b/core/control/session_proxy_test.go
new file mode 100644
index 00000000..61f8dea5
--- /dev/null
+++ b/core/control/session_proxy_test.go
@@ -0,0 +1,115 @@
+package control
+
+import (
+ "errors"
+ "testing"
+)
+
+type fakeProxySessions struct {
+ current proxyUser
+ fail bool
+ has bool
+ users []proxyUser
+ actions []string
+}
+
+func (f *fakeProxySessions) Current() (proxyUser, error) { return f.current, nil }
+func (f *fakeProxySessions) Run(u proxyUser, a, _ string) error {
+ f.users = append(f.users, u)
+ f.actions = append(f.actions, a)
+ if f.fail {
+ return errors.New("session temporarily unavailable")
+ }
+ return nil
+}
+func (f *fakeProxySessions) Read(proxyUser) (proxyState, error) {
+ return proxyState{Enabled: true, Server: "127.0.0.1:2080"}, nil
+}
+func (f *fakeProxySessions) HasLease(proxyUser) (bool, error) { return f.has, nil }
+
+func TestUserProxyCleanupStaysWithOriginalSession(t *testing.T) {
+ a := proxyUser{SID: "S-1-5-21-100", Session: 1}
+ b := proxyUser{SID: "S-1-5-21-200", Session: 2}
+ f := &fakeProxySessions{current: a}
+ p := &sessionSystemProxy{ops: f}
+ if err := p.Enable("127.0.0.1:2080"); err != nil {
+ t.Fatal(err)
+ }
+ f.current = b
+ f.fail = true
+ if err := p.Disable(); err == nil {
+ t.Fatal("cleanup failure lost")
+ }
+ f.fail = false
+ if err := p.Disable(); err != nil {
+ t.Fatal(err)
+ }
+ for _, u := range f.users {
+ if u != a {
+ t.Fatalf("cleanup retargeted to another user: %+v", u)
+ }
+ }
+ if err := p.Enable("127.0.0.1:2081"); err != nil {
+ t.Fatal(err)
+ }
+ if f.users[len(f.users)-1] != b {
+ t.Fatal("new apply did not select the new user")
+ }
+}
+
+func TestUserProxyFailedApplyKeepsOriginalOwner(t *testing.T) {
+ a := proxyUser{SID: "first", Session: 1}
+ f := &fakeProxySessions{current: a, fail: true}
+ p := &sessionSystemProxy{ops: f}
+ if p.Enable("127.0.0.1:2080") == nil {
+ t.Fatal("apply should fail")
+ }
+ f.current = proxyUser{SID: "second", Session: 1}
+ f.fail = false
+ if err := p.Disable(); err != nil {
+ t.Fatal(err)
+ }
+ if f.users[1] != a {
+ t.Fatal("reused session id replaced cleanup owner")
+ }
+}
+
+func TestUserProxyReconcileRequiresLeaseAndRetriesFailure(t *testing.T) {
+ f := &fakeProxySessions{current: proxyUser{SID: "user", Session: 1}}
+ p := &sessionSystemProxy{ops: f}
+ if changed, err := p.Reconcile(); changed || err != nil || len(f.users) != 0 {
+ t.Fatal("matching port without ownership was changed")
+ }
+ f.has = true
+ f.fail = true
+ if changed, err := p.Reconcile(); !changed || err == nil {
+ t.Fatal("failed stale lease cleanup not surfaced")
+ }
+ f.current = proxyUser{SID: "other", Session: 2}
+ f.fail = false
+ if changed, err := p.Reconcile(); !changed || err != nil {
+ t.Fatal("cleanup did not retry")
+ }
+ if f.users[0] != f.users[1] {
+ t.Fatal("reconcile retry changed target user")
+ }
+}
+
+func TestUserProxyStartupFailureRetainsDaemonCleanup(t *testing.T) {
+ d, _ := bareDaemonWithProxy(t)
+ f := &fakeProxySessions{current: proxyUser{SID: "user", Session: 1}, has: true, fail: true}
+ d.proxy = &sessionSystemProxy{ops: f}
+ if cleared, err := d.ReconcileSystemProxyAtStartup(); cleared || err == nil {
+ t.Fatal("failed startup restore claimed success")
+ }
+ if !d.proxyArmed {
+ t.Fatal("daemon forgot startup cleanup obligation")
+ }
+ f.fail = false
+ if err := d.disarmSystemProxy(); err != nil {
+ t.Fatal(err)
+ }
+ if d.proxyArmed {
+ t.Fatal("cleanup obligation survived confirmed restore")
+ }
+}
diff --git a/core/control/user_proxy_lease.go b/core/control/user_proxy_lease.go
new file mode 100644
index 00000000..3db957ff
--- /dev/null
+++ b/core/control/user_proxy_lease.go
@@ -0,0 +1,132 @@
+package control
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "strconv"
+)
+
+// userProxySettings captures the per-connection WinINet settings, including PAC
+// and autodetection flags. It belongs to the interactive user, never LocalSystem.
+type userProxySettings struct {
+ Flags uint32 `json:"flags"`
+ Server string `json:"server"`
+ Bypass string `json:"bypass"`
+ PAC string `json:"pac"`
+}
+
+type userProxyLease struct {
+ Version int `json:"version"`
+ Before userProxySettings `json:"before"`
+ Applied userProxySettings `json:"applied"`
+}
+
+type userProxyOperations interface {
+ Read() (userProxySettings, error)
+ Write(userProxySettings) error
+ Load() (*userProxyLease, error)
+ Save(userProxyLease) error
+ Delete() error
+}
+
+func validUserProxyTarget(target string) bool {
+ host, port, err := net.SplitHostPort(target)
+ if err != nil {
+ return false
+ }
+ ip := net.ParseIP(host)
+ p, err := strconv.Atoi(port)
+ return ip != nil && ip.IsLoopback() && err == nil && p > 0 && p <= 65535
+}
+
+func applyUserProxy(o userProxyOperations, target string) error {
+ if !validUserProxyTarget(target) {
+ return errors.New("system proxy target must be a loopback IP and valid port")
+ }
+ lease, err := o.Load()
+ if err != nil {
+ return fmt.Errorf("load system proxy snapshot: %w", err)
+ }
+ if lease != nil {
+ current, err := o.Read()
+ if err != nil {
+ return err
+ }
+ if lease.Version == 1 && lease.Applied.Server == target && current == lease.Applied {
+ return nil
+ }
+ if err := restoreUserProxy(o); err != nil {
+ return err
+ }
+ }
+ before, err := o.Read()
+ if err != nil {
+ return fmt.Errorf("read user proxy: %w", err)
+ }
+ want := userProxySettings{Flags: 3, Server: target, Bypass: "localhost;127.0.0.1;[::1]"}
+ if err := o.Save(userProxyLease{Version: 1, Before: before, Applied: want}); err != nil {
+ return fmt.Errorf("save user proxy rollback snapshot: %w", err)
+ }
+ if err := o.Write(want); err != nil {
+ return errors.Join(fmt.Errorf("apply user proxy: %w", err), restoreUserProxy(o))
+ }
+ got, err := o.Read()
+ if err != nil || got != want {
+ if err == nil {
+ err = errors.New("user proxy settings did not take effect")
+ }
+ return errors.Join(err, restoreUserProxy(o))
+ }
+ return nil
+}
+
+func restoreUserProxy(o userProxyOperations) error {
+ lease, err := o.Load()
+ if err != nil {
+ return fmt.Errorf("load user proxy rollback snapshot: %w", err)
+ }
+ if lease == nil {
+ return nil
+ }
+ if lease.Version != 1 || !validUserProxyTarget(lease.Applied.Server) {
+ return errors.New("invalid user proxy ownership record; automatic restore refused")
+ }
+ current, err := o.Read()
+ if err != nil {
+ return err
+ }
+ // A different server is an explicit subsequent user/tool change. Do not
+ // restore old flags/PAC over that newer configuration.
+ if current.Server != lease.Applied.Server && current.Server != lease.Before.Server {
+ return o.Delete()
+ }
+ want := current
+ // Restore only fields still equal to our write. This also rolls back a
+ // partially completed option list without clobbering independent edits.
+ if current.Flags == lease.Applied.Flags {
+ want.Flags = lease.Before.Flags
+ }
+ if current.Server == lease.Applied.Server {
+ want.Server = lease.Before.Server
+ }
+ if current.Bypass == lease.Applied.Bypass {
+ want.Bypass = lease.Before.Bypass
+ }
+ if current.PAC == lease.Applied.PAC {
+ want.PAC = lease.Before.PAC
+ }
+ if want != current {
+ if err := o.Write(want); err != nil {
+ return fmt.Errorf("restore user proxy: %w", err)
+ }
+ got, err := o.Read()
+ if err != nil {
+ return err
+ }
+ if got != want {
+ return errors.New("user proxy restore did not take effect")
+ }
+ }
+ return o.Delete()
+}
diff --git a/core/control/user_proxy_lease_test.go b/core/control/user_proxy_lease_test.go
new file mode 100644
index 00000000..4b98bffa
--- /dev/null
+++ b/core/control/user_proxy_lease_test.go
@@ -0,0 +1,136 @@
+package control
+
+import (
+ "errors"
+ "testing"
+)
+
+type memoryUserProxy struct {
+ settings userProxySettings
+ lease *userProxyLease
+ writes int
+ failWrite int
+ failSave bool
+ failDelete bool
+}
+
+func (m *memoryUserProxy) Read() (userProxySettings, error) { return m.settings, nil }
+func (m *memoryUserProxy) Write(s userProxySettings) error {
+ m.writes++
+ if m.writes == m.failWrite {
+ m.settings.Server = s.Server
+ return errors.New("partial write")
+ }
+ m.settings = s
+ return nil
+}
+func (m *memoryUserProxy) Load() (*userProxyLease, error) { return m.lease, nil }
+func (m *memoryUserProxy) Save(l userProxyLease) error {
+ if m.failSave {
+ return errors.New("snapshot unavailable")
+ }
+ m.lease = &l
+ return nil
+}
+func (m *memoryUserProxy) Delete() error {
+ if m.failDelete {
+ return errors.New("delete failed")
+ }
+ m.lease = nil
+ return nil
+}
+
+func TestUserProxyRestoresCorporateSettingsAndPAC(t *testing.T) {
+ before := userProxySettings{Flags: 15, Server: "corp.example:8080", Bypass: "intranet;*.internal", PAC: "https://config.example/proxy.pac"}
+ m := &memoryUserProxy{settings: before}
+ if err := applyUserProxy(m, "127.0.0.1:2080"); err != nil {
+ t.Fatal(err)
+ }
+ if m.settings.Flags != 3 || m.settings.Server != "127.0.0.1:2080" || m.settings.PAC != "" {
+ t.Fatalf("proxy not applied: %+v", m.settings)
+ }
+ if m.lease == nil || m.lease.Before != before {
+ t.Fatal("original settings not retained")
+ }
+ if err := restoreUserProxy(m); err != nil {
+ t.Fatal(err)
+ }
+ if m.settings != before || m.lease != nil {
+ t.Fatal("original proxy/PAC not fully restored")
+ }
+}
+
+func TestUserProxySnapshotFailureNeverMutatesSettings(t *testing.T) {
+ m := &memoryUserProxy{failSave: true, settings: userProxySettings{Flags: 9}}
+ if err := applyUserProxy(m, "127.0.0.1:2080"); err == nil {
+ t.Fatal("snapshot failure accepted")
+ }
+ if m.writes != 0 {
+ t.Fatal("changed settings before durable rollback snapshot")
+ }
+}
+
+func TestUserProxyPartialApplyAndCleanupRetry(t *testing.T) {
+ before := userProxySettings{Flags: 9, Server: "old:80", PAC: "https://config.example/pac"}
+ m := &memoryUserProxy{settings: before, failWrite: 1, failDelete: true}
+ if err := applyUserProxy(m, "127.0.0.1:2080"); err == nil {
+ t.Fatal("partial apply accepted")
+ }
+ if m.settings != before || m.lease == nil {
+ t.Fatal("partial change not restored or retry ownership lost")
+ }
+ m.failDelete = false
+ if err := restoreUserProxy(m); err != nil {
+ t.Fatal(err)
+ }
+ if m.lease != nil || m.settings != before {
+ t.Fatal("retry did not finish restore")
+ }
+}
+
+func TestUserProxyRepeatedApplyDoesNotOverwriteOriginalSnapshot(t *testing.T) {
+ before := userProxySettings{Flags: 1}
+ m := &memoryUserProxy{settings: before}
+ if err := applyUserProxy(m, "127.0.0.1:2080"); err != nil {
+ t.Fatal(err)
+ }
+ if err := applyUserProxy(m, "127.0.0.1:2080"); err != nil {
+ t.Fatal(err)
+ }
+ if m.lease.Before != before || m.writes != 1 {
+ t.Fatal("idempotent apply lost original state")
+ }
+ if err := applyUserProxy(m, "127.0.0.1:2081"); err != nil {
+ t.Fatal(err)
+ }
+ if m.lease.Before != before || m.settings.Server != "127.0.0.1:2081" {
+ t.Fatal("port change lost original state")
+ }
+}
+
+func TestUserProxyRestorePreservesLaterUserChange(t *testing.T) {
+ m := &memoryUserProxy{settings: userProxySettings{Flags: 1}}
+ if err := applyUserProxy(m, "127.0.0.1:2080"); err != nil {
+ t.Fatal(err)
+ }
+ changed := userProxySettings{Flags: 7, Server: "new-corporate:8888", Bypass: "work", PAC: "https://work/pac"}
+ m.settings = changed
+ if err := restoreUserProxy(m); err != nil {
+ t.Fatal(err)
+ }
+ if m.settings != changed || m.lease != nil {
+ t.Fatal("cleanup overwrote newer external settings")
+ }
+}
+
+func TestUserProxyRejectsNonLoopbackAndInvalidTargets(t *testing.T) {
+ for _, target := range []string{"192.0.2.1:2080", "127.0.0.1:0", "127.0.0.1:65536", "localhost:2080", "127.0.0.1:2080;https=evil:80"} {
+ m := &memoryUserProxy{}
+ if err := applyUserProxy(m, target); err == nil {
+ t.Errorf("accepted %q", target)
+ }
+ if m.writes != 0 || m.lease != nil {
+ t.Fatal("invalid target changed settings")
+ }
+ }
+}
From 76e49e3b24f6878a789a53a5293ecf9c0d24d7a6 Mon Sep 17 00:00:00 2001
From: DivanMe <48186011+Divaaaan@users.noreply.github.com>
Date: Fri, 11 Sep 2026 18:13:47 +0300
Subject: [PATCH 03/56] fix(routing): honor disabled split and permitted bypass
rules
---
core/routing/audit_regression_test.go | 69 +++++++++++++++++++++++++++
core/routing/presets.go | 7 ++-
2 files changed, 72 insertions(+), 4 deletions(-)
create mode 100644 core/routing/audit_regression_test.go
diff --git a/core/routing/audit_regression_test.go b/core/routing/audit_regression_test.go
new file mode 100644
index 00000000..bf1ecd6d
--- /dev/null
+++ b/core/routing/audit_regression_test.go
@@ -0,0 +1,69 @@
+package routing
+
+import "testing"
+
+func TestSplitOffGamesPresetIgnoresSavedCustomApps(t *testing.T) {
+ for _, mode := range []SplitMode{SplitOff, SplitExclude, SplitInclude} {
+ t.Run(string(mode), func(t *testing.T) {
+ o := (Options{Mode: ModeGlobal, SplitMode: mode, SplitApps: []string{"chrome.exe"}, GamesDirect: true}).Normalize()
+ for _, layer := range []struct {
+ name, target string
+ rules []map[string]any
+ }{
+ {"route", "outbound", o.RouteRules()}, {"dns", "server", o.dnsRules()},
+ } {
+ custom, game := "", ""
+ for _, rule := range layer.rules {
+ apps, _ := rule["process_name"].([]string)
+ if contains(apps, "chrome.exe") {
+ custom, _ = rule[layer.target].(string)
+ }
+ if contains(apps, "steam.exe") {
+ game, _ = rule[layer.target].(string)
+ }
+ }
+ direct, proxy := tagDirect, tagProxy
+ if layer.name == "dns" {
+ direct, proxy = dnsDirectTag, dnsRemoteTag
+ }
+ wantCustom, wantGame := "", direct
+ if mode == SplitExclude {
+ wantCustom = direct
+ }
+ if mode == SplitInclude {
+ wantCustom, wantGame = proxy, ""
+ }
+ if custom != wantCustom || game != wantGame {
+ t.Errorf("%s: custom=%q game=%q; want %q/%q", layer.name, custom, game, wantCustom, wantGame)
+ }
+ }
+ })
+ }
+}
+
+func TestBypassKeepsProxyPinsWhenDirectIsForbidden(t *testing.T) {
+ for _, kill := range []bool{false, true} {
+ o := (Options{Mode: ModeSmart, KillSwitch: kill, ZapretActive: true, UnblockServices: true}).Normalize()
+ for _, layer := range []struct {
+ name, target, direct, proxy string
+ rules []map[string]any
+ }{
+ {"route", "outbound", tagDirect, tagProxy, o.RouteRules()},
+ {"dns", "server", dnsDirectTag, dnsRemoteTag, o.dnsRules()},
+ } {
+ var got []string
+ for _, rule := range layer.rules {
+ if contains(suffixesOf(rule), "googlevideo.com") {
+ got = append(got, rule[layer.target].(string))
+ }
+ }
+ want := layer.direct
+ if kill {
+ want = layer.proxy
+ }
+ if len(got) != 1 || got[0] != want {
+ t.Errorf("kill=%v %s: googlevideo targets=%v, want [%s]", kill, layer.name, got, want)
+ }
+ }
+ }
+}
diff --git a/core/routing/presets.go b/core/routing/presets.go
index 3fcb488c..1d04e2f3 100644
--- a/core/routing/presets.go
+++ b/core/routing/presets.go
@@ -230,10 +230,9 @@ func (o Options) proxySuffixesWithPresets() []string {
}
merged := make([]string, 0, len(base)+len(blockedServiceSuffixes))
merged = append(merged, base...)
- if o.ZapretActive {
- covered := o.coverage()
+ if direct := o.zapretDirectSuffixes(); len(direct) > 0 {
for _, s := range blockedServiceSuffixes {
- if !coveredByZapret(covered, s) {
+ if !coveredByZapret(direct, s) {
merged = append(merged, s)
}
}
@@ -285,7 +284,7 @@ func (o Options) directSplitApps() []string {
if !o.gamesDirectActive() {
return nil
}
- return o.splitAppsWithPresets()
+ return normalizeApps(gameProcesses)
}
}
From 36008aea6f1a5b828da528967368576547493d1b Mon Sep 17 00:00:00 2001
From: DivanMe <48186011+Divaaaan@users.noreply.github.com>
Date: Fri, 11 Sep 2026 18:27:14 +0300
Subject: [PATCH 04/56] fix(singbox): reject invalid or unsupported multihop
chains
---
core/routing/routing.go | 6 ++----
core/singbox/builder.go | 24 ++++++++++--------------
core/singbox/multihop.go | 33 +++++++++++++++++++++++++++++++++
core/singbox/multihop_test.go | 34 ++++++++++------------------------
4 files changed, 55 insertions(+), 42 deletions(-)
create mode 100644 core/singbox/multihop.go
diff --git a/core/routing/routing.go b/core/routing/routing.go
index 8f99b9b2..c9d9f080 100644
--- a/core/routing/routing.go
+++ b/core/routing/routing.go
@@ -307,10 +307,8 @@ type Options struct {
// at the exit. MultihopEntry and MultihopExit are the builder outbound tags
// (what singbox.sanitizeTag assigns) of the two chosen nodes, already resolved
// from the user's stable server-ID selection by the control layer — the builder
- // works only in tags. Multihop is inert unless both tags are set, distinct, and
- // resolve to regular built outbounds; the builder then falls back to the normal
- // selector, so a stale or unresolvable selection degrades to a single hop rather
- // than a broken config.
+ // works only in tags. An enabled chain requires distinct tags resolving to
+ // regular built outbounds; the builder rejects stale or unsupported selections.
Multihop bool
MultihopEntry string
MultihopExit string
diff --git a/core/singbox/builder.go b/core/singbox/builder.go
index 8a83f3a7..4acb6c47 100644
--- a/core/singbox/builder.go
+++ b/core/singbox/builder.go
@@ -241,23 +241,19 @@ func Build(nodes []model.Node, selectedTag string, ro routing.Options, tun TunOp
// Multihop rewires the topology into a two-hop chain: the exit outbound gets a
// detour through the entry outbound, and the selector collapses to the exit so
// the route final (still proxyTag) egresses via exit -> entry -> internet. It
- // engages only when both endpoints resolve to distinct regular outbounds this
- // config actually built — an AmneziaWG endpoint or a dropped/invalid node leaves
- // its tag out of outs — so a stale or unsupported selection degrades to the
- // normal selector rather than emitting a dangling detour, which sing-box accepts
- // at check time and then silently misroutes. sing-box's detour is a plain
+ // requires distinct regular outbounds this config actually built. An enabled
+ // chain that cannot be built is an error, never permission to use one hop.
+ // sing-box's detour is a plain
// top-level outbound field naming the tag to dial through (verified against the
// bundled 1.13 schema).
- if ro.Multihop && ro.MultihopEntry != "" && ro.MultihopExit != "" && ro.MultihopEntry != ro.MultihopExit {
- _, entryOK := outboundByTag(outs, ro.MultihopEntry)
- exitObj, exitOK := outboundByTag(outs, ro.MultihopExit)
- if entryOK && exitOK {
- // The entry outbound needs no change: it is dialed as an ordinary
- // outbound and only referenced by the exit's detour.
- exitObj["detour"] = ro.MultihopEntry
- selOutbounds = []string{ro.MultihopExit}
- def = ro.MultihopExit
+ if ro.Multihop {
+ if err := validateMultihopOutbounds(outs, ro.MultihopEntry, ro.MultihopExit); err != nil {
+ return nil, err
}
+ exitObj, _ := outboundByTag(outs, ro.MultihopExit)
+ exitObj["detour"] = ro.MultihopEntry
+ selOutbounds = []string{ro.MultihopExit}
+ def = ro.MultihopExit
}
// Shared outbounds: the selector over the eligible nodes, plus direct/block.
diff --git a/core/singbox/multihop.go b/core/singbox/multihop.go
new file mode 100644
index 00000000..db2edfb5
--- /dev/null
+++ b/core/singbox/multihop.go
@@ -0,0 +1,33 @@
+package singbox
+
+import (
+ "fmt"
+
+ "github.com/Divaaaan/tenebra/core/model"
+)
+
+// ValidateMultihop checks the same rendered outbound capabilities as Build.
+// Controllers use it before accepting settings or replacing a working tunnel.
+func ValidateMultihop(nodes []model.Node, entryTag, exitTag string) error {
+ outs, _, _, err := buildNodes(nodes)
+ if err != nil {
+ return err
+ }
+ return validateMultihopOutbounds(outs, entryTag, exitTag)
+}
+
+func validateMultihopOutbounds(outs []map[string]any, entryTag, exitTag string) error {
+ if entryTag == "" || exitTag == "" {
+ return fmt.Errorf("multihop: entry and exit nodes are required")
+ }
+ if entryTag == exitTag {
+ return fmt.Errorf("multihop: entry and exit nodes must differ")
+ }
+ if _, ok := outboundByTag(outs, entryTag); !ok {
+ return fmt.Errorf("multihop: entry node is missing or does not support chaining")
+ }
+ if _, ok := outboundByTag(outs, exitTag); !ok {
+ return fmt.Errorf("multihop: exit node is missing or does not support chaining")
+ }
+ return nil
+}
diff --git a/core/singbox/multihop_test.go b/core/singbox/multihop_test.go
index a66cfb7a..da4c74ca 100644
--- a/core/singbox/multihop_test.go
+++ b/core/singbox/multihop_test.go
@@ -9,8 +9,7 @@ import (
// These tests cover the multihop chain the builder emits: the exit outbound gains
// a detour through the entry outbound, the selector collapses to the exit so the
-// route final egresses via exit -> entry, and — crucially — the whole thing degrades
-// to the normal single-hop selector for any selection that can't form a real chain
+// route final egresses via exit -> entry, and the build rejects any selection that can't form a real chain
// (missing tag, equal endpoints, an AmneziaWG endpoint that isn't a regular
// outbound), never a config carrying a dangling detour. TestMultihopPassesSingBoxCheck
// validates the emitted shape against a real sing-box.
@@ -81,10 +80,8 @@ func TestMultihopDefaultsOff(t *testing.T) {
}
}
-// TestMultihopInertOnUnresolvableSelection: a selection the builder can't turn into
-// a real two-hop chain must leave the normal single-hop selector untouched rather
-// than emit a dangling detour (which sing-box accepts and then silently misroutes).
-func TestMultihopInertOnUnresolvableSelection(t *testing.T) {
+// An explicitly enabled two-hop chain must never degrade silently to one hop.
+func TestMultihopRejectsUnresolvableSelection(t *testing.T) {
cases := []struct {
name string
entry, exit string
@@ -102,25 +99,17 @@ func TestMultihopInertOnUnresolvableSelection(t *testing.T) {
MultihopEntry: c.entry,
MultihopExit: c.exit,
}, TunOptions{})
- if err != nil {
- t.Fatalf("Build() error: %v", err)
- }
- for tag, o := range outboundsByTag(t, cfg) {
- if _, ok := o["detour"]; ok {
- t.Errorf("outbound %q carries a detour for an unresolvable multihop selection", tag)
- }
- }
- if outs, _ := selectorOf(t, cfg)["outbounds"].([]string); len(outs) != 2 {
- t.Errorf("selector narrowed to %d outbounds; an unresolvable selection must keep the full selector", len(outs))
+ if err == nil || cfg != nil {
+ t.Error("invalid multihop must return an error and no config")
}
})
}
}
-// TestMultihopInertWhenEndpointIsWireGuard: an AmneziaWG node is emitted as a
+// TestMultihopRejectsWireGuardEndpoint: an AmneziaWG node is emitted as a
// top-level endpoint, not a regular outbound, so it can neither carry a detour nor
-// be one. Selecting it as the exit leaves the config single-hop.
-func TestMultihopInertWhenEndpointIsWireGuard(t *testing.T) {
+// be one. Selecting it must fail rather than build a single-hop config.
+func TestMultihopRejectsWireGuardEndpoint(t *testing.T) {
nodes := []model.Node{
{
Protocol: model.VLESS, Name: "vless-ws", Server: "ws.example.test", Port: 443,
@@ -138,11 +127,8 @@ func TestMultihopInertWhenEndpointIsWireGuard(t *testing.T) {
MultihopEntry: "vless-ws",
MultihopExit: "awg",
}, TunOptions{})
- if err != nil {
- t.Fatalf("Build() error: %v", err)
- }
- if _, ok := outboundsByTag(t, cfg)["vless-ws"]["detour"]; ok {
- t.Error("a WireGuard-endpoint exit must not chain: no detour should be set")
+ if err == nil || cfg != nil {
+ t.Error("WireGuard multihop must return an error and no config")
}
}
From d4d482d4f4dc4a7dd92433892ef87e49927f75ff Mon Sep 17 00:00:00 2001
From: DivanMe <48186011+Divaaaan@users.noreply.github.com>
Date: Fri, 11 Sep 2026 18:27:15 +0300
Subject: [PATCH 05/56] fix(control): validate effective topology and bound
automatic recovery
---
core/control/connect.go | 35 +-
core/control/core_audit_regression_test.go | 388 +++++++++++++++++++++
core/control/daemon.go | 50 ++-
core/control/fake_runner_test.go | 6 +
core/control/health.go | 72 ++--
core/control/health_test.go | 5 +-
core/control/hotswitch.go | 47 ++-
core/control/hotswitch_test.go | 1 +
core/control/zapret.go | 9 +-
9 files changed, 551 insertions(+), 62 deletions(-)
create mode 100644 core/control/core_audit_regression_test.go
diff --git a/core/control/connect.go b/core/control/connect.go
index 4b25bf9e..97f9c857 100644
--- a/core/control/connect.go
+++ b/core/control/connect.go
@@ -211,6 +211,21 @@ func (d *Daemon) logConnectPlan(p profile.Profile, m *fallback.Machine, explicit
// reconnecting the one it just abandoned. It is ignored for an explicit-node
// connect (the user pinned that exact exit) and when empty.
func (d *Daemon) startConnect(ctx context.Context, p profile.Profile, explicitNode string, auto, remember bool, avoid string) (State, error) {
+ d.mu.Lock()
+ mh := d.multihop
+ d.mu.Unlock()
+ if err := validateMultihopProfile(p, mh); err != nil {
+ return State{}, err
+ }
+ requestedNode := explicitNode
+ if mh.Enabled {
+ // A chain has one effective exit. Use it for the attempt, state, last-good
+ // and leak check, while keeping the user's original connect intent.
+ if avoid == mh.ExitID {
+ return State{}, fmt.Errorf("connect: multihop has no alternative exit; select another chain")
+ }
+ explicitNode = mh.ExitID
+ }
// Build the fallback candidates. An explicit node request collapses the walk
// to that single node: the user asked for a specific exit, so we honour it and
// do not silently wander to another protocol behind their back. Without an
@@ -275,7 +290,6 @@ func (d *Daemon) startConnect(ctx context.Context, p profile.Profile, explicitNo
d.mu.Lock()
ro := d.routing
tun := d.tun
- mh := d.multihop
d.mu.Unlock()
nodes := profileNodes(p)
@@ -283,8 +297,7 @@ func (d *Daemon) startConnect(ctx context.Context, p profile.Profile, explicitNo
tags := serverTags(p)
// Resolve the multihop selection (server IDs) into the builder-facing outbound
// tags now that the profile's tag map is in hand, so every per-candidate config
- // this loop builds carries the same chain. An unresolvable pair (a node that
- // vanished, or one the builder won't render) leaves ro untouched — a single hop.
+ // this loop builds carries the same chain, validated before any teardown.
ro = resolveMultihop(ro, mh, tags)
// Say it out loud when smart mode is about to run without its geodata. The
// connect still succeeds — the geo rules are simply not emitted and everything
@@ -325,7 +338,7 @@ func (d *Daemon) startConnect(ctx context.Context, p profile.Profile, explicitNo
tun: tun,
machine: m,
remember: remember,
- requestedNode: explicitNode,
+ requestedNode: requestedNode,
}
d.wg.Add(1)
go func() {
@@ -911,8 +924,8 @@ func (d *Daemon) probeUntilUp(ctx context.Context, gen uint64, wantTag string) (
// not name — and then the probe measures one exit while the state reports
// another. Pinning the selector to this config's default closes that, at the
// cost of one loopback call. It is retried alongside the probe because the API
- // may not be listening yet, and it is best-effort: a runner that cannot select
- // is the runner whose probe is about to fail anyway.
+ // may not be listening yet. A successful probe is meaningful only after this
+ // pin succeeds; an unconfirmed selector may still carry a cached old exit.
pinned := wantTag == ""
for {
@@ -932,6 +945,16 @@ func (d *Daemon) probeUntilUp(ctx context.Context, gen uint64, wantTag string) (
pinErr := d.runner.Select(pinCtx, proxySelectorTag, wantTag)
cancelPin()
pinned = pinErr == nil
+ if !pinned {
+ select {
+ case <-budget.Done():
+ return false, false
+ case <-done:
+ return false, false
+ case <-time.After(d.probeRetry):
+ continue
+ }
+ }
}
probeCtx, cancelProbe := context.WithTimeout(budget, d.probeTimeout)
diff --git a/core/control/core_audit_regression_test.go b/core/control/core_audit_regression_test.go
new file mode 100644
index 00000000..492f3dbb
--- /dev/null
+++ b/core/control/core_audit_regression_test.go
@@ -0,0 +1,388 @@
+package control
+
+import (
+ "context"
+ "errors"
+ "net"
+ "net/http"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Divaaaan/tenebra/core/fallback"
+ "github.com/Divaaaan/tenebra/core/model"
+ "github.com/Divaaaan/tenebra/core/profile"
+ "github.com/Divaaaan/tenebra/core/zapret"
+)
+
+// No Server/Daemon.Close: those cleanups can stop the host's real bypass.
+// All engine, network and bypass operations used below are injected.
+func coreAuditDaemon(t *testing.T) (*Daemon, *fakeRunner, profile.Profile) {
+ t.Helper()
+ s, err := profile.Open(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ p, err := profile.NewProfile("audit", profile.SourceManual, "", []model.Node{
+ {Protocol: model.VLESS, Name: "Entry", Server: "192.0.2.1", Port: 443, UUID: "123e4567-e89b-12d3-a456-426614174000"},
+ {Protocol: model.VLESS, Name: "Exit", Server: "192.0.2.2", Port: 443, UUID: "123e4567-e89b-12d3-a456-426614174001"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = s.Add(p); err != nil {
+ t.Fatal(err)
+ }
+ r := newFakeRunner()
+ d := NewDaemon(s, r)
+ d.localAddrs = func() []net.Addr { return nil }
+ d.tunWatchInterval, d.healthInterval, d.bypassVerifyDelay = 0, 0, 0
+ d.proxy = &fakeProxyController{}
+ d.classify = func(context.Context, model.Node, bool) fallback.FailureClass { return fallback.Unknown }
+ d.probeWarmup, d.probeRetry, d.probeTimeout, d.probeBudget = time.Millisecond, time.Millisecond, 20*time.Millisecond, 30*time.Millisecond
+ d.lastGood = fallback.NewMemLastGood()
+ d.netFingerprint = func() string { return "test-network" }
+ d.zapretExclude = func(string, []string, []zapret.Lookup) (zapret.ExcludeReport, error) {
+ return zapret.ExcludeReport{}, nil
+ }
+ stubStarts(d)
+ t.Cleanup(func() {
+ d.connMu.Lock()
+ d.teardown(StateIdle, "", "")
+ d.connMu.Unlock()
+ d.relaunchWG.Wait()
+ d.entCancel()
+ })
+ return d, r, p
+}
+
+func coreAuditConnect(t *testing.T, d *Daemon, p profile.Profile, node string) State {
+ t.Helper()
+ d.connMu.Lock()
+ _, err := d.startConnect(context.Background(), p, node, false, false, "")
+ d.connMu.Unlock()
+ if err != nil {
+ t.Fatal(err)
+ }
+ deadline := time.Now().Add(time.Second)
+ for time.Now().Before(deadline) {
+ s := d.snapshotState()
+ if s.State == StateConnected {
+ return s
+ }
+ if s.State == StateError {
+ t.Fatal(s.Error)
+ }
+ time.Sleep(time.Millisecond)
+ }
+ t.Fatal("no connected state")
+ return State{}
+}
+
+func TestCoreAuditMultihopUsesEffectiveExit(t *testing.T) {
+ d, r, p := coreAuditDaemon(t)
+ d.multihop = model.Multihop{Enabled: true, EntryID: p.Servers[0].ID, ExitID: p.Servers[1].ID}
+ s := coreAuditConnect(t, d, p, p.Servers[0].ID)
+ exit, entry := selectedOutboundDetour(t, r.startCfgs()[0])
+ if exit != "Exit" || entry != "Entry" {
+ t.Fatalf("wrong topology %s via %s", exit, entry)
+ }
+ if s.Node != p.Servers[1].ID {
+ t.Errorf("state node=%s, want exit %s", s.Node, p.Servers[1].ID)
+ }
+ if id, _ := d.lastGood.Get(p.ID); id != p.Servers[1].ID {
+ t.Errorf("last-good=%s, want exit", id)
+ }
+ if got := d.exitServer(s); got != "192.0.2.2" {
+ t.Errorf("leak-check exit=%s, want 192.0.2.2", got)
+ }
+}
+
+func TestCoreAuditMultihopRejectsStaleOrUnsupportedConnect(t *testing.T) {
+ for _, bad := range []string{"missing-entry", "missing-exit", "unsupported-entry", "unsupported-exit"} {
+ t.Run(bad, func(t *testing.T) {
+ d, r, p := coreAuditDaemon(t)
+ d.multihop = model.Multihop{Enabled: true, EntryID: p.Servers[0].ID, ExitID: p.Servers[1].ID}
+ switch bad {
+ case "missing-entry":
+ p.Servers = p.Servers[1:]
+ case "missing-exit":
+ p.Servers = p.Servers[:1]
+ case "unsupported-entry":
+ p.Servers[0].Protocol = model.AmneziaWG
+ case "unsupported-exit":
+ p.Servers[1].Protocol = model.AmneziaWG
+ }
+ d.connMu.Lock()
+ _, err := d.startConnect(context.Background(), p, "", false, false, "")
+ d.connMu.Unlock()
+ if err == nil {
+ t.Error("connect accepted an invalid multihop chain")
+ }
+ if r.starts() != 0 || r.stops() != 0 {
+ t.Errorf("invalid chain touched engine: starts=%d stops=%d", r.starts(), r.stops())
+ }
+ })
+ }
+}
+
+func TestCoreAuditMultihopRejectsUnsupportedCommand(t *testing.T) {
+ d, _, p := coreAuditDaemon(t)
+ p.Servers[0].Protocol = model.AmneziaWG
+ if err := d.store.Update(p); err != nil {
+ t.Fatal(err)
+ }
+ r := d.handleSetMultihop(Request{ID: 1, Enabled: true, Profile: p.ID, EntryID: p.Servers[0].ID, ExitID: p.Servers[1].ID})
+ if r.Ok || d.multihop.Enabled {
+ t.Fatal("unsupported entry accepted and persisted")
+ }
+}
+
+func TestCoreAuditRefreshRejectsLossOfSelectedMultihop(t *testing.T) {
+ d, _, p := coreAuditDaemon(t)
+ p.Source, p.URL = profile.SourceSubscription, "https://example.test/sub"
+ if err := d.store.Update(p); err != nil {
+ t.Fatal(err)
+ }
+ d.multihop = model.Multihop{Enabled: true, EntryID: p.Servers[0].ID, ExitID: p.Servers[1].ID}
+ d.fetch = func(context.Context, string) ([]byte, http.Header, error) {
+ return []byte("vless://123e4567-e89b-12d3-a456-426614174001@192.0.2.2:443#Exit"), http.Header{}, nil
+ }
+ r := d.handleRefreshSubscription(context.Background(), Request{ID: 1, Profile: p.ID})
+ if r.Ok {
+ t.Error("refresh accepted loss of enabled entry")
+ }
+ stored, _ := d.store.Get(p.ID)
+ if len(stored.Servers) != 2 {
+ t.Errorf("refresh replaced valid stored chain: nodes=%d", len(stored.Servers))
+ }
+}
+
+func TestCoreAuditSelectorPinMustSucceed(t *testing.T) {
+ d, r, _ := coreAuditDaemon(t)
+ _ = r.Start(context.Background(), nil)
+ r.selectErr = errors.New("selector unavailable")
+ up, _ := d.probeUntilUp(context.Background(), 0, "desired-node")
+ if up {
+ t.Fatal("successful probe of unconfirmed selector accepted")
+ }
+ if len(r.selectCalls()) < 2 {
+ t.Error("failed selector was not retried")
+ }
+}
+
+func TestCoreAuditStartupBypassHonorsOffAfterMutexWait(t *testing.T) {
+ d, _, _ := coreAuditDaemon(t)
+ seedBypassBundle(t, d.store.Dir(), "general (FAKE TLS AUTO)")
+ starts := stubStarts(d)
+ d.zapretOpMu.Lock()
+ done := make(chan bool, 1)
+ go func() { done <- d.autoStartZapret(context.Background(), false) }()
+ deadline := time.Now().Add(time.Second)
+ waiting := false
+ for time.Now().Before(deadline) {
+ b := make([]byte, 65536)
+ n := runtime.Stack(b, true)
+ s := string(b[:n])
+ if strings.Contains(s, "(*Daemon).acquireZapretOp") && strings.Contains(s, "(*Daemon).autoStartZapret") {
+ waiting = true
+ break
+ }
+ time.Sleep(time.Millisecond)
+ }
+ if !waiting {
+ d.zapretOpMu.Unlock()
+ t.Fatal("startup did not wait for operation mutex")
+ }
+ d.recordZapretWish(false)
+ d.zapretOpMu.Unlock()
+ select {
+ case up := <-done:
+ if up || len(starts.names()) != 0 {
+ t.Fatal("startup raised bypass after authoritative OFF")
+ }
+ case <-time.After(time.Second):
+ t.Fatal("startup remained blocked")
+ }
+ if !d.zapretSwitchedOff() {
+ t.Fatal("OFF wish lost")
+ }
+}
+
+func TestCoreAuditHealthRecoveryRespectsCooldownAndBudget(t *testing.T) {
+ for _, steerable := range []bool{false, true} {
+ for _, exhausted := range []bool{false, true} {
+ d, r, p := coreAuditDaemon(t)
+ _ = r.Start(context.Background(), nil)
+ d.generation = 1
+ d.setState(State{State: StateConnected, Profile: p.ID, Node: p.Servers[0].ID})
+ if steerable {
+ d.setLiveConfig(1, p.ID, serverTags(p), selectorShape{Default: "Entry", Members: []string{"Entry", "Exit"}})
+ }
+ d.autoSwitches = []time.Time{d.now()}
+ if exhausted {
+ d.autoSwitches = nil
+ for i := 0; i < d.maxAutoSwitches; i++ {
+ d.autoSwitches = append(d.autoSwitches, d.now().Add(-d.autoSwitchCooldown-time.Second))
+ }
+ }
+ d.healthInterval, d.healthFailThreshold = time.Millisecond, 1
+ d.healthProbe = func(context.Context) error { return errors.New("synthetic unhealthy tunnel") }
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+ d.healthWatch(ctx, 1, p.ID, p.Servers[0].ID)
+ cancel()
+ d.relaunchWG.Wait()
+ if r.starts() != 1 {
+ t.Errorf("steerable=%v exhausted=%v: recovery bypassed policy, starts=%d", steerable, exhausted, r.starts())
+ }
+ if d.snapshotState().Node != p.Servers[0].ID {
+ t.Errorf("steerable=%v exhausted=%v: recovery switched exit despite policy", steerable, exhausted)
+ }
+ }
+ }
+}
+
+func TestCoreAuditFallbackReconnectConsumesSharedBudget(t *testing.T) {
+ d, r, p := coreAuditDaemon(t)
+ _ = r.Start(context.Background(), nil)
+ r.selectErr, r.selectErrThroughStart = errors.New("old API unavailable"), 1
+ d.generation = 1
+ d.setState(State{State: StateConnected, Profile: p.ID, Node: p.Servers[0].ID})
+ if got := d.healthFailover(1, p.ID, p.Servers[0].ID); got != failoverStarted {
+ t.Fatalf("reconnect not scheduled: %v", got)
+ }
+ d.relaunchWG.Wait()
+ deadline := time.Now().Add(time.Second)
+ for time.Now().Before(deadline) {
+ if d.snapshotState().State == StateConnected {
+ break
+ }
+ time.Sleep(time.Millisecond)
+ }
+ s := d.snapshotState()
+ if s.State != StateConnected || s.Node != p.Servers[1].ID {
+ t.Fatalf("fallback did not connect alternative: %+v", s)
+ }
+ d.mu.Lock()
+ gen := d.generation
+ spent := len(d.autoSwitches)
+ d.mu.Unlock()
+ if spent != 1 {
+ t.Errorf("fallback consumed %d budget entries, want 1", spent)
+ }
+ if d.allowAutoSwitch(gen, p.ID, p.Servers[1].ID) {
+ t.Error("fallback reconnect did not constrain subsequent live switch")
+ }
+ if got := d.healthFailover(gen, p.ID, p.Servers[1].ID); got == failoverStarted {
+ t.Error("second fallback scheduled inside cooldown")
+ }
+}
+
+func TestCoreAuditQueuedRecoveryYieldsToManualSwitch(t *testing.T) {
+ d, r, p := coreAuditDaemon(t)
+ _ = r.Start(context.Background(), nil)
+ d.generation = 1
+ d.setState(State{State: StateConnected, Profile: p.ID, Node: p.Servers[0].ID})
+ queued, release := make(chan struct{}), make(chan struct{})
+ d.beforeReconnect = func() { close(queued); <-release }
+ if d.healthFailover(1, p.ID, p.Servers[0].ID) != failoverStarted {
+ t.Fatal("recovery not scheduled")
+ }
+ <-queued
+ d.setState(State{State: StateConnected, Profile: p.ID, Node: p.Servers[1].ID})
+ close(release)
+ d.relaunchWG.Wait()
+ if r.starts() != 1 || d.snapshotState().Node != p.Servers[1].ID {
+ t.Fatal("queued recovery overruled manual switch")
+ }
+ if len(d.autoSwitches) != 0 {
+ t.Fatal("cancelled recovery spent a budget entry")
+ }
+}
+
+func TestCoreAuditMultihopHasNoAutomaticAlternativeExit(t *testing.T) {
+ d, r, p := coreAuditDaemon(t)
+ d.multihop = model.Multihop{Enabled: true, EntryID: p.Servers[0].ID, ExitID: p.Servers[1].ID}
+ _ = r.Start(context.Background(), nil)
+ d.generation = 1
+ d.setState(State{State: StateConnected, Profile: p.ID, Node: p.Servers[1].ID})
+ if got := d.healthFailover(1, p.ID, p.Servers[1].ID); got != failoverNoAlternative {
+ t.Errorf("fixed chain scheduled another exit: %v", got)
+ }
+ d.relaunchWG.Wait()
+ if len(d.autoSwitches) != 0 {
+ t.Error("unavailable chain failover spent budget")
+ }
+}
+
+type retryPinRunner struct {
+ *fakeRunner
+ failures int
+}
+
+func (r *retryPinRunner) Select(ctx context.Context, group, tag string) error {
+ if err := r.fakeRunner.Select(ctx, group, tag); err != nil {
+ return err
+ }
+ if r.failures > 0 {
+ r.failures--
+ return errors.New("selector not ready")
+ }
+ return nil
+}
+
+func TestCoreAuditSelectorRetriesBeforeTestingTraffic(t *testing.T) {
+ d, r, _ := coreAuditDaemon(t)
+ _ = r.Start(context.Background(), nil)
+ d.runner = &retryPinRunner{fakeRunner: r, failures: 2}
+ up, _ := d.probeUntilUp(context.Background(), 0, "Exit")
+ if !up {
+ t.Fatal("eventual successful selector pin did not connect")
+ }
+ if got := len(r.selectCalls()); got != 3 {
+ t.Errorf("pin calls=%d, want 3", got)
+ }
+ r.mu.Lock()
+ probes := r.probeN
+ r.mu.Unlock()
+ if probes != 1 {
+ t.Errorf("traffic tested %d times, want only after confirmed pin", probes)
+ }
+}
+
+func TestCoreAuditHealthWatchSurvivesCancelledQueuedRecovery(t *testing.T) {
+ d, r, p := coreAuditDaemon(t)
+ _ = r.Start(context.Background(), nil)
+ d.generation = 1
+ d.setState(State{State: StateConnected, Profile: p.ID, Node: p.Servers[0].ID})
+ d.healthInterval, d.healthFailThreshold = time.Millisecond, 1
+ queued, release, probedAgain := make(chan struct{}), make(chan struct{}), make(chan struct{}, 1)
+ d.beforeReconnect = func() { close(queued); <-release }
+ calls := 0
+ d.healthProbe = func(context.Context) error {
+ calls++
+ if calls == 1 {
+ return errors.New("unhealthy")
+ }
+ select {
+ case probedAgain <- struct{}{}:
+ default:
+ }
+ return nil
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ go func() { d.healthWatch(ctx, 1, p.ID, p.Servers[0].ID); close(done) }()
+ <-queued
+ d.setState(State{State: StateConnected, Profile: p.ID, Node: p.Servers[1].ID})
+ close(release)
+ d.relaunchWG.Wait()
+ select {
+ case <-probedAgain:
+ case <-time.After(40 * time.Millisecond):
+ t.Error("watchdog stopped after queued recovery yielded to user")
+ }
+ cancel()
+ <-done
+}
diff --git a/core/control/daemon.go b/core/control/daemon.go
index 7fd072cc..cdb601ef 100644
--- a/core/control/daemon.go
+++ b/core/control/daemon.go
@@ -1362,6 +1362,17 @@ func (d *Daemon) refreshProfile(ctx context.Context, p profile.Profile) (profile
before := p
p.Servers = rebuilt.Servers
p.UpdatedAt = rebuilt.UpdatedAt
+ // Keep the last valid profile if this refresh removes an enabled hop. The
+ // refresh command returns the error, and background refresh logs it; neither
+ // may replace a promised chain with a silently downgraded next connection.
+ d.mu.Lock()
+ mh := d.multihop
+ d.mu.Unlock()
+ if mh.Enabled && (hasServer(before, mh.EntryID) || hasServer(before, mh.ExitID)) {
+ if err := validateMultihopProfile(p, mh); err != nil {
+ return profile.Profile{}, false, fmt.Errorf("refresh refused: %w", err)
+ }
+ }
// Only refresh traffic/expiry when this response actually carries a user-info
// header. A refresh that returns the node list but no Subscription-Userinfo
// (some panels send it only intermittently) must preserve the known quota and
@@ -1696,7 +1707,7 @@ func (d *Daemon) handleSetTLSFragment(req Request) Response {
// so a bad pick is rejected whole rather than half-applied; disabling ignores the
// IDs but keeps them recorded so the UI can re-enable the last pick. The IDs are
// resolved to outbound tags against the connecting profile later (resolveMultihop),
-// so a selection that no longer resolves simply degrades to a single hop.
+// and revalidated on connect and subscription refresh.
func (d *Daemon) handleSetMultihop(req Request) Response {
mh := model.Multihop{Enabled: req.Enabled, EntryID: req.EntryID, ExitID: req.ExitID}
if mh.Enabled {
@@ -1716,6 +1727,9 @@ func (d *Daemon) handleSetMultihop(req Request) Response {
if !hasServer(p, mh.ExitID) {
return newError(req.ID, "set_multihop: exit node not in profile")
}
+ if err := validateMultihopProfile(p, mh); err != nil {
+ return newError(req.ID, "set_multihop: "+err.Error())
+ }
}
d.mu.Lock()
@@ -1747,24 +1761,30 @@ func hasServer(p profile.Profile, id string) bool {
return false
}
+func validateMultihopProfile(p profile.Profile, mh model.Multihop) error {
+ if !mh.Enabled {
+ return nil
+ }
+ if !mh.Valid() {
+ return fmt.Errorf("multihop: entry and exit must name distinct nodes")
+ }
+ if !hasServer(p, mh.EntryID) || !hasServer(p, mh.ExitID) {
+ return fmt.Errorf("multihop: selected entry or exit is no longer in this profile")
+ }
+ tags := serverTags(p)
+ return singbox.ValidateMultihop(profileNodes(p), tags[mh.EntryID], tags[mh.ExitID])
+}
+
// resolveMultihop folds a stored multihop selection (server IDs) into the routing
// options the builder consumes (outbound tags), using the tag map the connecting
// profile produces (serverTags). It engages only for a valid, distinct pair whose
-// IDs both resolve to a tag the builder will actually emit; anything else leaves
-// the options untouched so the build degrades to a normal single hop rather than
-// carrying a dangling detour. tags maps a server ID to its outbound tag.
+// IDs both resolve to a tag the builder will actually emit. Validation happens
+// before connect; even an invalid enabled selection remains enabled here so the
+// builder rejects it. tags maps a server ID to its outbound tag.
func resolveMultihop(ro routing.Options, mh model.Multihop, tags map[string]string) routing.Options {
- if !mh.Valid() {
- return ro
- }
- entryTag := tags[mh.EntryID]
- exitTag := tags[mh.ExitID]
- if entryTag == "" || exitTag == "" || entryTag == exitTag {
- return ro
- }
- ro.Multihop = true
- ro.MultihopEntry = entryTag
- ro.MultihopExit = exitTag
+ ro.Multihop = mh.Enabled
+ ro.MultihopEntry = tags[mh.EntryID]
+ ro.MultihopExit = tags[mh.ExitID]
return ro
}
diff --git a/core/control/fake_runner_test.go b/core/control/fake_runner_test.go
index 32113f0f..34260311 100644
--- a/core/control/fake_runner_test.go
+++ b/core/control/fake_runner_test.go
@@ -75,6 +75,9 @@ type fakeRunner struct {
// cannot steer, which must degrade to a full reconnect.
selects []selectCall
selectErr error
+ // Positive limits selectErr to the first N processes, allowing tests to model
+ // a broken live API that recovers after the process is restarted.
+ selectErrThroughStart int
// viaDelays and viaErrs script ProbeVia per outbound tag: a tag present in
// viaErrs fails, otherwise the delay from viaDelays (or viaDefault) is
@@ -176,6 +179,9 @@ func (f *fakeRunner) Select(ctx context.Context, group, tag string) error {
f.mu.Lock()
f.selects = append(f.selects, selectCall{group: group, tag: tag})
err := f.selectErr
+ if f.selectErrThroughStart > 0 && f.startN > f.selectErrThroughStart {
+ err = nil
+ }
f.mu.Unlock()
if cerr := ctx.Err(); cerr != nil {
return cerr
diff --git a/core/control/health.go b/core/control/health.go
index 4444a24d..e504d3b8 100644
--- a/core/control/health.go
+++ b/core/control/health.go
@@ -83,14 +83,18 @@ func (d *Daemon) healthWatch(ctx context.Context, gen uint64, profileID, nodeID
// session, and everything already open finishes on the old exit instead of
// being cut. Only when that is impossible or does not hold up does this
// fall through to the reconnect-based failover.
- if d.autoSwitchAway(ctx, gen, profileID, active) {
+ switch d.autoSwitchAway(ctx, gen, profileID, active) {
+ case autoSwitchSucceeded, autoSwitchSuppressed:
fails, warnedNoAlt = 0, false
continue
}
switch d.healthFailover(gen, profileID, active) {
case failoverStarted:
- return // the reconnect owns the connection from here
+ // Scheduling does not yet transfer ownership: the queued reconnect
+ // can yield to a manual switch or fail validation. Keep monitoring
+ // until an actual teardown cancels this context/generation.
+ fails, warnedNoAlt = 0, false
case failoverNoAlternative:
// A single-node profile has nowhere to fail over to. Keep monitoring so
// a later subscription refresh or a recovery is still picked up, but warn
@@ -133,8 +137,8 @@ func (d *Daemon) defaultHealthProbe(ctx context.Context) error {
type failoverResult int
const (
- // failoverStarted: a reconnect to another node was launched; it now owns the
- // connection and the watchdog should return.
+ // failoverStarted: a reconnect was queued. The current watchdog remains until
+ // that reconnect actually cancels its generation.
failoverStarted failoverResult = iota
// failoverNoAlternative: the profile has no other node to move to, so the
// current connection is left as-is and the watchdog keeps monitoring.
@@ -148,10 +152,21 @@ const (
// connect walk with that node excluded so it lands on a different exit. It first
// confirms another renderable node exists — otherwise there is nothing to fail
// over to and the (possibly recoverable) tunnel is left running. The reconnect
-// goes through startConnectIfCurrent so it runs off the watchdog's own stack (its
-// teardown waits on d.wg, which the watchdog is part of), re-checks the generation
-// under connMu, and yields cleanly to any user command that raced it.
+// runs off the watchdog's own stack (teardown waits on d.wg, which the watchdog
+// is part of), re-checks generation and policy under connMu, and yields to user
+// commands that raced it.
func (d *Daemon) healthFailover(gen uint64, profileID, nodeID string) failoverResult {
+ if !d.allowAutoRecovery(gen, profileID, nodeID) {
+ return failoverAborted
+ }
+ d.mu.Lock()
+ chain := d.multihop.Enabled
+ d.mu.Unlock()
+ if chain {
+ // The selected chain has exactly one exit. Other stored nodes are not
+ // authorization to change that chain or fall back to a single hop.
+ return failoverNoAlternative
+ }
p, ok := d.store.Get(profileID)
if !ok {
d.emitLog(LogWarn, "health: cannot fail over, profile no longer stored")
@@ -169,23 +184,34 @@ func (d *Daemon) healthFailover(gen uint64, profileID, nodeID string) failoverRe
}
d.emitLog(LogWarn, fmt.Sprintf("health: active node failed %d health probes in a row; failing over to another node", d.healthFailThreshold))
- d.startConnectIfCurrent(gen, p, "", nodeID,
- func() {
- // Runs under connMu with the generation confirmed current: announce the
- // health-driven switch before the reconnect's teardown moves the state to
- // connecting, so a UI can tell an automatic failover from a manual connect.
- // If a user command already superseded us this never runs and no
- // health_reconnecting is emitted.
- d.setState(State{State: StateHealthReconnecting, Profile: profileID, Node: nodeID,
- Routing: d.snapshotState().Routing})
- },
- func(err error) {
- // startConnect only errors before it tears the old tunnel down (a build or
- // no-alternative failure, reachable here only if the last other node
- // vanished in the meantime), so the degraded tunnel is still up: log rather
- // than forcing an error state over a live connection.
+ // Run off the watchdog stack: teardown waits for that watchdog. Re-check and
+ // spend the shared budget under connMu, where no user or automatic switch can
+ // interleave. A queued reconnect must yield if the exit changed during its wait.
+ d.relaunchWG.Add(1)
+ go func() {
+ defer d.relaunchWG.Done()
+ if d.beforeReconnect != nil {
+ d.beforeReconnect()
+ }
+ d.connMu.Lock()
+ defer d.connMu.Unlock()
+ if !d.allowAutoRecovery(gen, profileID, nodeID) || d.liveNode("") != nodeID {
+ return
+ }
+ latest, ok := d.store.Get(profileID)
+ if !ok {
+ return
+ }
+ d.recordAutoSwitch()
+ d.setState(State{State: StateHealthReconnecting, Profile: profileID, Node: nodeID,
+ Routing: d.snapshotState().Routing})
+ if _, err := d.startConnect(context.Background(), latest, "", false, false, nodeID); err != nil {
+ // Validation errors leave the old engine up. Preserve that state while
+ // counting the failed recovery attempt so repeated failures cannot churn.
+ d.setState(State{State: StateConnected, Profile: profileID, Node: nodeID})
d.emitLog(LogWarn, fmt.Sprintf("health: failover reconnect could not start: %v", err))
- })
+ }
+ }()
return failoverStarted
}
diff --git a/core/control/health_test.go b/core/control/health_test.go
index 7fa06102..03ec3677 100644
--- a/core/control/health_test.go
+++ b/core/control/health_test.go
@@ -124,10 +124,11 @@ func TestHealthWatchReconnectsWhenTheExitCannotBeSteered(t *testing.T) {
t.Fatalf("initial connect landed on %v, want vless-id", c["node"])
}
- // From here the clash API refuses every selection, so the live switch is not
- // available and the watchdog must still get the user off the degraded exit.
+ // The current process refuses selections; a restarted API recovers. A
+ // permanently failing selector must never reach Connected, even after restart.
h.runner.mu.Lock()
h.runner.selectErr = errSelectRefused
+ h.runner.selectErrThroughStart = 1
h.runner.mu.Unlock()
if hr := h.awaitState(StateHealthReconnecting); hr["node"] != "vless-id" {
diff --git a/core/control/hotswitch.go b/core/control/hotswitch.go
index fcce02f0..db24e588 100644
--- a/core/control/hotswitch.go
+++ b/core/control/hotswitch.go
@@ -269,22 +269,32 @@ func (d *Daemon) emitSwitchAttempt(gen uint64, profileID, nodeID string) {
})
}
-// autoSwitchAway moves the tunnel off a degraded exit onto one that is measurably
-// working, without a reconnect. It reports whether it took ownership; false leaves
-// the caller to fall back to the reconnect-based failover.
+type autoSwitchResult int
+
+const (
+ autoSwitchReconnect autoSwitchResult = iota
+ autoSwitchSucceeded
+ autoSwitchSuppressed
+)
+
+// autoSwitchAway distinguishes an unavailable live switch from a policy refusal.
+// Only the former permits reconnect-based recovery.
//
// It is the automatic counterpart of a user tapping another node, and it is gated
// by the hysteresis in allowAutoSwitch: the tunnel must be steerable, the daemon
// must not have moved the exit too recently or too often, and the candidate must
// pass a real measurement before anything moves.
-func (d *Daemon) autoSwitchAway(ctx context.Context, gen uint64, profileID, degraded string) bool {
+func (d *Daemon) autoSwitchAway(ctx context.Context, gen uint64, profileID, degraded string) autoSwitchResult {
+ if !d.allowAutoRecovery(gen, profileID, degraded) {
+ return autoSwitchSuppressed
+ }
if !d.allowAutoSwitch(gen, profileID, degraded) {
- return false
+ return autoSwitchReconnect
}
target, ok := d.scanForExit(ctx, profileID, degraded)
if !ok {
- return false
+ return autoSwitchReconnect
}
// TryLock, not Lock. This runs on the health watchdog's goroutine, which
@@ -295,20 +305,20 @@ func (d *Daemon) autoSwitchAway(ctx context.Context, gen uint64, profileID, degr
// miss simply falls through to the reconnect-based failover, which needs no
// lock of its own (see startConnectIfCurrent).
if !d.connMu.TryLock() {
- return false
+ return autoSwitchReconnect
}
defer d.connMu.Unlock()
// The generation is re-checked under connMu for the same reason every
// off-command connect re-checks it: a user command may have landed while the
// scan ran, and it wins.
- if !d.isCurrent(gen) {
- return false
+ if !d.allowAutoRecovery(gen, profileID, degraded) || d.liveNode("") != degraded {
+ return autoSwitchSuppressed
}
if !d.switchNode(ctx, profileID, target, "the previous exit stopped carrying traffic", false) {
- return false
+ return autoSwitchReconnect
}
d.recordAutoSwitch()
- return true
+ return autoSwitchSucceeded
}
// allowAutoSwitch is the hysteresis gate. It marks the degraded node so nothing
@@ -318,6 +328,17 @@ func (d *Daemon) autoSwitchAway(ctx context.Context, gen uint64, profileID, degr
// about restraint rather than capability are logged once, because a user whose
// exit is degraded and is NOT being moved deserves to know that is a decision.
func (d *Daemon) allowAutoSwitch(gen uint64, profileID, degraded string) bool {
+ if !d.allowAutoRecovery(gen, profileID, degraded) {
+ return false
+ }
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ return d.live != nil && d.live.gen == gen && d.live.profileID == profileID
+}
+
+// allowAutoRecovery applies one cooldown and window budget to live switches and
+// full health reconnects. Capability is deliberately outside this policy gate.
+func (d *Daemon) allowAutoRecovery(gen uint64, profileID, degraded string) bool {
now := d.now()
d.mu.Lock()
@@ -327,7 +348,7 @@ func (d *Daemon) allowAutoSwitch(gen uint64, profileID, degraded string) bool {
if degraded != "" {
d.degradedAt[degraded] = now
}
- steerable := d.live != nil && d.live.gen == gen && d.live.gen == d.generation && d.live.profileID == profileID
+ current := d.generation == gen && d.state.Profile == profileID && d.state.State == StateConnected && d.autoFailover
// Only the switches inside the window count, so a quiet session recovers its
// full budget without anything having to reset it.
recent := d.autoSwitches[:0:0]
@@ -344,7 +365,7 @@ func (d *Daemon) allowAutoSwitch(gen uint64, profileID, degraded string) bool {
}
d.mu.Unlock()
- if !steerable {
+ if !current {
return false
}
if spent > 0 && sinceLast < d.autoSwitchCooldown {
diff --git a/core/control/hotswitch_test.go b/core/control/hotswitch_test.go
index 6b1b37e2..20ccb2d7 100644
--- a/core/control/hotswitch_test.go
+++ b/core/control/hotswitch_test.go
@@ -106,6 +106,7 @@ func TestSwitchFallsBackToReconnectWhenTheSelectorRefuses(t *testing.T) {
h.runner.mu.Lock()
h.runner.selectErr = errSelectRefused
+ h.runner.selectErrThroughStart = 1 // restart restores the selector API
h.runner.mu.Unlock()
h.send(Request{ID: 2, Cmd: CmdConnect, Profile: p.ID, Node: "hy2-id"})
diff --git a/core/control/zapret.go b/core/control/zapret.go
index 534e691b..c66a901f 100644
--- a/core/control/zapret.go
+++ b/core/control/zapret.go
@@ -1033,9 +1033,8 @@ func (d *Daemon) autoStartZapret(ctx context.Context, tunnelUp bool) bool {
// Asked here rather than in each caller: both automatic raises funnel through
// this function, so a switch obeyed at this point cannot be forgotten by the
// next path that wants a bypass up. Read before the bypass lock, because this
- // is a decision NOT to run and nothing the lock protects can change it —
- // queueing it behind a probe run of several minutes would only delay the
- // answer the connect is waiting on.
+ // is a fast refusal; re-check after taking the lock because OFF may win while
+ // this automatic raise waits behind another operation.
if d.zapretSwitchedOff() {
// Debug, for the same reason the missing-bundle branch below is: the caller
// states at info where the censored services ended up, and this only adds
@@ -1055,6 +1054,10 @@ func (d *Daemon) autoStartZapret(ctx context.Context, tunnelUp bool) bool {
return false
}
defer d.zapretOpMu.Unlock()
+ if d.zapretSwitchedOff() {
+ d.emitDebug("zapret: the switch was turned off while waiting — leaving the bypass down")
+ return false
+ }
dir := filepath.Join(d.store.Dir(), zapretDirName)
entries, err := os.ReadDir(dir)
From 94c481910fa1016c539209541bc3691c8433221a Mon Sep 17 00:00:00 2001
From: DivanMe <48186011+Divaaaan@users.noreply.github.com>
Date: Fri, 11 Sep 2026 18:28:59 +0300
Subject: [PATCH 06/56] fix(control): reject multihop incompatible with the
live profile
---
core/control/core_audit_regression_test.go | 23 ++++++++++++++++++++++
core/control/daemon.go | 9 +++++++++
2 files changed, 32 insertions(+)
diff --git a/core/control/core_audit_regression_test.go b/core/control/core_audit_regression_test.go
index 492f3dbb..8b9d1761 100644
--- a/core/control/core_audit_regression_test.go
+++ b/core/control/core_audit_regression_test.go
@@ -386,3 +386,26 @@ func TestCoreAuditHealthWatchSurvivesCancelledQueuedRecovery(t *testing.T) {
cancel()
<-done
}
+
+func TestCoreAuditMultihopRejectsChainForAnotherLiveProfile(t *testing.T) {
+ d, r, p := coreAuditDaemon(t)
+ _ = r.Start(context.Background(), nil)
+ d.generation = 1
+ d.setState(State{State: StateConnected, Profile: p.ID, Node: p.Servers[0].ID})
+ nodes := profileNodes(p)
+ nodes[0].Server, nodes[1].Server = "192.0.2.3", "192.0.2.4"
+ other, err := profile.NewProfile("other", profile.SourceManual, "", nodes)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = d.store.Add(other); err != nil {
+ t.Fatal(err)
+ }
+ resp := d.handleSetMultihop(Request{ID: 1, Enabled: true, Profile: other.ID, EntryID: other.Servers[0].ID, ExitID: other.Servers[1].ID})
+ if resp.Ok {
+ t.Error("accepted chain incompatible with the live profile")
+ }
+ if d.multihop.Enabled || d.snapshotState().State != StateConnected || r.starts() != 1 || r.stops() != 0 {
+ t.Error("rejected chain changed current tunnel/state")
+ }
+}
diff --git a/core/control/daemon.go b/core/control/daemon.go
index cdb601ef..b2d23efa 100644
--- a/core/control/daemon.go
+++ b/core/control/daemon.go
@@ -1730,6 +1730,15 @@ func (d *Daemon) handleSetMultihop(req Request) Response {
if err := validateMultihopProfile(p, mh); err != nil {
return newError(req.ID, "set_multihop: "+err.Error())
}
+ // The setting applies to the active tunnel immediately. Validating only
+ // req.Profile could advertise its chain over a different live profile.
+ cur := d.snapshotState()
+ if (cur.State == StateConnected || cur.State == StateConnecting) && cur.Profile != p.ID {
+ liveProfile, ok := d.store.Get(cur.Profile)
+ if !ok || validateMultihopProfile(liveProfile, mh) != nil {
+ return newError(req.ID, "set_multihop: selected chain is incompatible with the current connection")
+ }
+ }
}
d.mu.Lock()
From b3cecc1edb065ec3d920a893afae720780aa1b0b Mon Sep 17 00:00:00 2001
From: DivanMe <48186011+Divaaaan@users.noreply.github.com>
Date: Fri, 11 Sep 2026 18:41:27 +0300
Subject: [PATCH 07/56] fix: bound IPC requests and validate installed Windows
service
---
scripts/test-wire-isolated.ps1 | 25 ++
ui-desktop/src-tauri/Cargo.toml | 9 +-
ui-desktop/src-tauri/installer-hooks.nsh | 82 +++-
ui-desktop/src-tauri/src/backend/mod.rs | 3 +
ui-desktop/src-tauri/src/backend/pipe.rs | 164 +++----
ui-desktop/src-tauri/src/backend/pipe_io.rs | 400 ++++++++++++++++++
.../src-tauri/src/backend/service_policy.rs | 57 +++
ui-desktop/src-tauri/src/backend/unix.rs | 19 +-
ui-desktop/src-tauri/src/backend/wire.rs | 123 ++++--
.../src/backend/wire_deadline_tests.rs | 128 ++++++
ui-desktop/src-tauri/src/lib.rs | 144 ++-----
ui-desktop/src-tauri/src/main.rs | 11 +
12 files changed, 907 insertions(+), 258 deletions(-)
create mode 100644 scripts/test-wire-isolated.ps1
create mode 100644 ui-desktop/src-tauri/src/backend/pipe_io.rs
create mode 100644 ui-desktop/src-tauri/src/backend/service_policy.rs
create mode 100644 ui-desktop/src-tauri/src/backend/wire_deadline_tests.rs
diff --git a/scripts/test-wire-isolated.ps1 b/scripts/test-wire-isolated.ps1
new file mode 100644
index 00000000..3c39012a
--- /dev/null
+++ b/scripts/test-wire-isolated.ps1
@@ -0,0 +1,25 @@
+param(
+ [Parameter(Mandatory=$true)][string]$DependencyDirectory,
+ [Parameter(Mandatory=$true)][string]$OutputDirectory
+)
+$ErrorActionPreference = 'Stop'
+# Build the real protocol implementation without Tauri/WebView or OS pipes.
+# Only unrelated event/backend mappings are excluded. No network or daemon runs.
+$repoRoot = Split-Path $PSScriptRoot -Parent
+$wireSource = Get-Content -LiteralPath (Join-Path $repoRoot 'ui-desktop/src-tauri/src/backend/wire.rs') -Raw
+$prefix = $wireSource.Substring(0, $wireSource.IndexOf('/// Read the stream to EOF'))
+$prefix = [regex]::Replace($prefix, '(?s)use super::\{.*?\};', '')
+$start = $wireSource.IndexOf('fn fail_all_pending(')
+$end = $wireSource.IndexOf('/// Forward a protocol event')
+$helpers = $wireSource.Substring($start, $end - $start)
+$testsPath = (Join-Path $repoRoot 'ui-desktop/src-tauri/src/backend/wire_deadline_tests.rs').Replace('\', '/')
+New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
+$harnessPath = Join-Path $OutputDirectory 'wire-isolated.rs'
+[IO.File]::WriteAllText($harnessPath, $prefix + $helpers + "`n#[cfg(test)]`n#[path = `"$testsPath`"]`nmod deadline_tests;`n")
+$jsonLib = Get-ChildItem -LiteralPath $DependencyDirectory -Filter 'libserde_json-*.rlib' | Sort-Object LastWriteTime -Descending | Select-Object -First 1
+$serdeLib = Get-ChildItem -LiteralPath $DependencyDirectory -Filter 'libserde-*.rlib' | Sort-Object LastWriteTime -Descending | Select-Object -First 1
+$exe = Join-Path $OutputDirectory 'wire-isolated.exe'
+& rustc --edition 2021 --test $harnessPath -L "dependency=$DependencyDirectory" --extern "serde_json=$($jsonLib.FullName)" --extern "serde=$($serdeLib.FullName)" -o $exe
+if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+& $exe --test-threads=1
+exit $LASTEXITCODE
diff --git a/ui-desktop/src-tauri/Cargo.toml b/ui-desktop/src-tauri/Cargo.toml
index 7b88507e..fe8df556 100644
--- a/ui-desktop/src-tauri/Cargo.toml
+++ b/ui-desktop/src-tauri/Cargo.toml
@@ -39,17 +39,16 @@ url = "2"
open = "5"
[target.'cfg(windows)'.dependencies]
-# Raw Win32 declarations for the named-pipe transport: PeekNamedPipe lets the
-# client poll a synchronous pipe handle without wedging writes (see
-# backend/pipe.rs), and the tests stand up an in-process pipe server with
-# CreateNamedPipeW/ConnectNamedPipe. Already in the tree via tauri; pinned here
-# as a direct use.
+# Cancellable overlapped pipe I/O and read-only SCM/process identity checks.
+# No service mutation or token rights are used by the GUI.
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_IO",
"Win32_System_Pipes",
+ "Win32_System_Services",
+ "Win32_System_Threading",
] }
[profile.release]
diff --git a/ui-desktop/src-tauri/installer-hooks.nsh b/ui-desktop/src-tauri/installer-hooks.nsh
index b5bee944..d5e01f4a 100644
--- a/ui-desktop/src-tauri/installer-hooks.nsh
+++ b/ui-desktop/src-tauri/installer-hooks.nsh
@@ -24,16 +24,42 @@
; installer without UI. External binaries are invoked by absolute path — the
; installer inherits the invoking user's PATH, which elevation must not trust.
-!macro NSIS_HOOK_PREINSTALL
- ; Stop a service left by a previous version so its binaries can be
- ; replaced. `net stop` (unlike `sc stop`) waits for the service to report
- ; stopped; on a first install the query fails and everything is skipped.
- nsExec::Exec '"$SYSDIR\sc.exe" query tenebra'
+!macro TenebraServiceFailure step
+ DetailPrint "Tenebra service ${step} failed (code $0)."
+ MessageBox MB_ICONSTOP|MB_OK "Tenebra could not ${step} its Windows service (code $0). Installation needs repair.$\r$\nRerun this installer as administrator. Check %ProgramData%\Tenebra\service.log and Windows Event Viewer. Existing profiles are preserved." /SD IDOK
+ SetErrorLevel 1
+ Abort
+!macroend
+
+!macro TenebraRequireSuccess step
+ ${If} $0 != 0
+ !insertmacro TenebraServiceFailure "${step}"
+ ${EndIf}
+!macroend
+
+!macro TenebraStopService
+ ; 1060 means first install. Other query failures (including denied access)
+ ; must stop installation before replacing a live service's files.
+ nsExec::Exec /TIMEOUT=35000 '"$SYSDIR\sc.exe" query tenebra'
Pop $0
${If} $0 = 0
- nsExec::Exec '"$SYSDIR\net.exe" stop tenebra /y'
+ nsExec::Exec /TIMEOUT=35000 '"$SYSDIR\sc.exe" stop tenebra'
+ Pop $0
+ ${If} $0 != 1062
+ !insertmacro TenebraRequireSuccess "stop"
+ ${EndIf}
+ ; sc stop is asynchronous. WaitForStatus uses SCM's numeric state and is
+ ; independent of the localized sc.exe output. No PATH or profile scripts.
+ nsExec::Exec /TIMEOUT=35000 `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -Command "try { (New-Object System.ServiceProcess.ServiceController('tenebra')).WaitForStatus([System.ServiceProcess.ServiceControllerStatus]::Stopped,[TimeSpan]::FromSeconds(30)); exit 0 } catch { exit 1 }"`
Pop $0
+ !insertmacro TenebraRequireSuccess "wait for stopped state of"
+ ${ElseIf} $0 != 1060
+ !insertmacro TenebraServiceFailure "query"
${EndIf}
+!macroend
+
+!macro NSIS_HOOK_PREINSTALL
+ !insertmacro TenebraStopService
; The stopped state can precede the process exit by a moment, and the file
; stays locked until then: probe the old binary with an append-mode open
; (a write-lock test) before letting the template overwrite it. Bounded so
@@ -51,6 +77,10 @@
Sleep 500
IntOp $1 $1 - 1
${LoopUntil} $1 < 1
+ ${If} $1 < 1
+ StrCpy $0 "binary still locked"
+ !insertmacro TenebraServiceFailure "replace files for"
+ ${EndIf}
${EndIf}
!macroend
@@ -129,28 +159,40 @@
; ""..."" on the wire, which CommandLineToArgvW splits at the path's space:
; sc then sees binPath= C:\Program and answers with its usage text (1639),
; silently, and the service never exists.
- nsExec::Exec '"$SYSDIR\sc.exe" create tenebra binPath= "\$\"$INSTDIR\tenebra-core.exe\$\"" start= auto DisplayName= "Tenebra"'
+ nsExec::Exec /TIMEOUT=35000 '"$SYSDIR\sc.exe" create tenebra binPath= "\$\"$INSTDIR\tenebra-core.exe\$\"" start= auto DisplayName= "Tenebra"'
+ Pop $0
+ ${If} $0 != 1073
+ !insertmacro TenebraRequireSuccess "register"
+ ${EndIf}
+ nsExec::Exec /TIMEOUT=35000 '"$SYSDIR\sc.exe" config tenebra binPath= "\$\"$INSTDIR\tenebra-core.exe\$\"" start= auto obj= LocalSystem DisplayName= "Tenebra"'
Pop $0
- nsExec::Exec '"$SYSDIR\sc.exe" config tenebra binPath= "\$\"$INSTDIR\tenebra-core.exe\$\"" start= auto DisplayName= "Tenebra"'
+ !insertmacro TenebraRequireSuccess "configure"
+ nsExec::Exec /TIMEOUT=35000 '"$SYSDIR\sc.exe" description tenebra "Runs the Tenebra VPN tunnel and serves the local control endpoint."'
Pop $0
- nsExec::Exec '"$SYSDIR\sc.exe" description tenebra "Runs the Tenebra VPN tunnel and serves the local control endpoint."'
+ !insertmacro TenebraRequireSuccess "describe"
+ nsExec::Exec /TIMEOUT=35000 '"$SYSDIR\sc.exe" start tenebra'
Pop $0
- ; A failed start is not an installer failure: the service logs the reason
- ; to %ProgramData%\Tenebra\service.log and start=auto retries at boot.
- nsExec::Exec '"$SYSDIR\sc.exe" start tenebra'
+ ${If} $0 != 1056
+ !insertmacro TenebraRequireSuccess "start"
+ ${EndIf}
+ ; This executable has just been installed in the administrator-owned install
+ ; directory. The helper runs BEFORE Tauri initialization: no window, sidecar,
+ ; autostart, updater or imports. It authenticates SCM PID, LocalSystem account and registered image,
+ ; requires RUNNING and a status response matching its compiled-in version.
+ nsExec::Exec /TIMEOUT=35000 '"$INSTDIR\${MAINBINARYNAME}.exe" --service-check'
Pop $0
+ !insertmacro TenebraRequireSuccess "verify readiness of"
!macroend
!macro NSIS_HOOK_PREUNINSTALL
- ; Stop before the files go away; net stop waits, so tenebra-core.exe is
- ; deletable when the section runs. During an update ($UpdateMode — the new
- ; installer runs this uninstaller with /UPDATE before laying its own files)
- ; the registration is kept: POSTINSTALL re-points and restarts it, and not
- ; deleting avoids the marked-for-deletion limbo an open SCM handle causes.
- nsExec::Exec '"$SYSDIR\net.exe" stop tenebra /y'
- Pop $0
+ ; The same checked stop applies before both update and real uninstall.
+ ; Keep the registration through updates; POSTINSTALL reconfigures it.
+ !insertmacro TenebraStopService
${If} $UpdateMode <> 1
- nsExec::Exec '"$SYSDIR\sc.exe" delete tenebra'
+ nsExec::Exec /TIMEOUT=35000 '"$SYSDIR\sc.exe" delete tenebra'
Pop $0
+ ${If} $0 != 1060
+ !insertmacro TenebraRequireSuccess "unregister"
+ ${EndIf}
${EndIf}
!macroend
diff --git a/ui-desktop/src-tauri/src/backend/mod.rs b/ui-desktop/src-tauri/src/backend/mod.rs
index 8d79be72..cfc90c8e 100644
--- a/ui-desktop/src-tauri/src/backend/mod.rs
+++ b/ui-desktop/src-tauri/src/backend/mod.rs
@@ -21,7 +21,10 @@
pub mod mock;
#[cfg(windows)]
pub mod pipe;
+#[cfg(windows)]
+pub(crate) mod pipe_io;
pub mod sidecar;
+pub(crate) mod service_policy;
#[cfg(test)]
pub mod testutil;
pub mod unavailable;
diff --git a/ui-desktop/src-tauri/src/backend/pipe.rs b/ui-desktop/src-tauri/src/backend/pipe.rs
index bfe33827..5d660490 100644
--- a/ui-desktop/src-tauri/src/backend/pipe.rs
+++ b/ui-desktop/src-tauri/src/backend/pipe.rs
@@ -25,36 +25,24 @@
//! back well inside the window and never reads as a failure. While
//! disconnected, commands fail fast instead of timing out.
//!
-//! # Why the reader polls
-//!
-//! The pipe handle is opened synchronously (no `FILE_FLAG_OVERLAPPED`), and
-//! Windows serializes I/O on a synchronous file object: a `ReadFile` parked
-//! waiting for data holds the file-object lock and blocks any `WriteFile` on
-//! the same object — including one through a duplicated handle, which shares
-//! it. A thread camping in a blocking read would deadlock every request. So
-//! the reader never blocks in `read`: it asks `PeekNamedPipe` how many bytes
-//! are ready and only reads that fast path, sleeping a short tick otherwise.
-//! Reads then always complete immediately, writes only ever wait out a quick
-//! read, and the tick doubles as a prompt shutdown check. (The overlapped
-//! alternative is a pile of unsafe I/O plumbing for the same result; the Go
-//! side needs go-winio for exactly this reason.)
+//! Reads and writes use separate OVERLAPPED operations on one pipe handle.
+//! Cancellation is shared with WireClient, whose deadline includes queued writes.
use std::fs::{File, OpenOptions};
use std::io::{self, Read, Write};
use std::os::windows::fs::OpenOptionsExt;
-use std::os::windows::io::AsRawHandle;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
-use windows_sys::Win32::Foundation::{
- ERROR_BROKEN_PIPE, ERROR_FILE_NOT_FOUND, ERROR_NO_DATA, ERROR_PIPE_BUSY,
- ERROR_PIPE_NOT_CONNECTED,
+use super::pipe_io::{authenticate_service, PipeIo, CLIENT_ACCESS};
+use windows_sys::Win32::Foundation::{ERROR_FILE_NOT_FOUND, ERROR_PIPE_BUSY};
+use windows_sys::Win32::Storage::FileSystem::{
+ FILE_FLAG_OVERLAPPED, SECURITY_IDENTIFICATION, SECURITY_SQOS_PRESENT,
};
-use windows_sys::Win32::Storage::FileSystem::{SECURITY_IDENTIFICATION, SECURITY_SQOS_PRESENT};
-use windows_sys::Win32::System::Pipes::{PeekNamedPipe, WaitNamedPipeW, NMPWAIT_NOWAIT};
+use windows_sys::Win32::System::Pipes::{WaitNamedPipeW, NMPWAIT_NOWAIT};
use super::wire::{obj, read_loop, WireClient, WireSession};
use super::{ConnectionState, EventSink, State};
@@ -62,11 +50,6 @@ use super::{ConnectionState, EventSink, State};
/// The well-known control pipe, mirroring `control.PipeName` on the Go side.
pub const PIPE_NAME: &str = r"\\.\pipe\tenebra";
-/// How often the reader re-peeks an idle pipe (and rechecks shutdown). Events
-/// and responses arrive at most this much late — imperceptible next to the
-/// commands' own latency — and an idle GUI costs one no-op syscall per tick.
-const POLL_INTERVAL: Duration = Duration::from_millis(20);
-
/// Reconnect backoff: first retry comes quickly (the common loss is a service
/// restart or a displaced session, both back within a second), then doubles to
/// a ceiling so a stopped service is probed gently, not hammered.
@@ -152,6 +135,7 @@ pub fn is_listening(name: &str) -> bool {
struct Conn {
reader: Box,
writer: Box,
+ cancel: Arc,
}
/// How the supervisor re-establishes a connection. The real implementation
@@ -453,7 +437,7 @@ fn wait_until(stop_rx: &Receiver<()>, stop: &AtomicBool, until: Instant) -> bool
/// re-sync, and wait the reader out. On return the session is already cleared,
/// so the supervisor's loss report never races a command onto a dead client.
fn serve_session(conn: Conn, shared: &Arc, sink: &Arc) {
- let client = WireClient::new(conn.writer);
+ let client = WireClient::new_cancellable(conn.writer, conn.cancel);
*shared.session.lock().unwrap() = Some(Arc::clone(&client));
let reader_client = Arc::clone(&client);
@@ -506,15 +490,15 @@ impl Dial for PipeDialer {
let absent_wait = std::mem::take(&mut self.absent_wait);
let file = open_pipe(&self.name, &self.stop, absent_wait)
.map_err(|e| format!("open {}: {e}", self.name))?;
- let writer = file
- .try_clone()
- .map_err(|e| format!("clone the pipe handle: {e}"))?;
+ if self.name.eq_ignore_ascii_case(PIPE_NAME) {
+ authenticate_service(&file)
+ .map_err(|e| format!("authenticate Tenebra service: {e}"))?;
+ }
+ let (reader, writer, cancel) = PipeIo::pair(file, Arc::clone(&self.stop));
Ok(Conn {
- reader: Box::new(PollReader {
- file,
- stop: Arc::clone(&self.stop),
- }),
+ reader: Box::new(reader),
writer: Box::new(writer),
+ cancel,
})
}
}
@@ -529,16 +513,7 @@ impl Dial for PipeDialer {
fn open_pipe(name: &str, stop: &Arc, absent_wait: Duration) -> io::Result {
let started = Instant::now();
loop {
- let attempt = OpenOptions::new()
- .read(true)
- .write(true)
- // GENERIC_READ|WRITE matches the GRGW the pipe's DACL grants
- // interactive users. The SQOS flags cap impersonation at
- // identification: if something else ever squats an instance of the
- // name (the DACL admits any interactive user), it may learn who we
- // are but cannot act as us.
- .custom_flags(SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION)
- .open(name);
+ let attempt = open_once(name);
match attempt {
Err(e)
if dial_wait_for(&e, absent_wait)
@@ -552,6 +527,13 @@ fn open_pipe(name: &str, stop: &Arc, absent_wait: Duration) -> io::R
}
}
+fn open_once(name: &str) -> io::Result {
+ OpenOptions::new()
+ .access_mode(CLIENT_ACCESS)
+ .custom_flags(FILE_FLAG_OVERLAPPED | SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION)
+ .open(name)
+}
+
/// How long a dial keeps re-attempting after a failure like this one, or `None`
/// when the failure is not one to wait out. Only the two transient shapes get a
/// window: `ERROR_PIPE_BUSY` (every instance is taken this instant) and
@@ -567,63 +549,53 @@ fn dial_wait_for(e: &io::Error, absent_wait: Duration) -> Option {
}
}
-/// `Read` over the pipe that never parks in `ReadFile` — see the module docs
-/// for why that would deadlock writes. EOF (`Ok(0)`) covers both the peer
-/// closing the pipe and our own shutdown flag, which is exactly the signal
-/// `read_loop` ends on.
-struct PollReader {
- file: File,
- stop: Arc,
-}
-
-impl Read for PollReader {
- fn read(&mut self, buf: &mut [u8]) -> io::Result {
- loop {
- if self.stop.load(Ordering::SeqCst) {
- return Ok(0);
- }
- match pipe_bytes_available(&self.file) {
- // Data is ready, so this read returns immediately with some of
- // it; the brief file-object lock is exactly what keeps writers
- // safe alongside us.
- Ok(n) if n > 0 => return self.file.read(buf),
- Ok(_) => thread::sleep(POLL_INTERVAL),
- Err(e) if pipe_is_gone(&e) => return Ok(0),
- Err(e) => return Err(e),
- }
+/// Installer-only read-only handshake. No Tauri, backend supervisor, sidecar,
+/// imports or user store access. Both connection and request share one budget.
+pub fn check_service_readiness(expected: &str) -> Result<(), String> {
+ let deadline = Instant::now() + Duration::from_secs(30);
+ let mut last_error = "service has not answered".to_string();
+ while Instant::now() < deadline {
+ let stop = Arc::new(AtomicBool::new(false));
+ let result = (|| {
+ let file = open_once(PIPE_NAME).map_err(|e| e.to_string())?;
+ authenticate_service(&file).map_err(|e| e.to_string())?;
+ let (reader, writer, cancel) = PipeIo::pair(file, stop);
+ let client = WireClient::new_cancellable(writer, cancel);
+ let reader_client = Arc::clone(&client);
+ let reader =
+ thread::spawn(move || read_loop(reader, reader_client, Arc::new(QuietSink)));
+ let reply = client.request_with_timeout(
+ "status",
+ obj([]),
+ deadline
+ .saturating_duration_since(Instant::now())
+ .min(Duration::from_secs(3)),
+ );
+ client.close();
+ let _ = reader.join();
+ let state: State = serde_json::from_value(reply?)
+ .map_err(|e| format!("invalid service status: {e}"))?;
+ super::service_policy::verify_version(state.daemon_version.as_deref(), expected)
+ })();
+ match result {
+ Ok(()) => return Ok(()),
+ Err(e) => last_error = e,
}
+ thread::sleep(Duration::from_millis(100));
}
+ Err(format!(
+ "Tenebra service did not become ready: {last_error}"
+ ))
}
-/// How many bytes a read could take right now without blocking.
-fn pipe_bytes_available(file: &File) -> io::Result {
- let mut available: u32 = 0;
- // SAFETY: the handle is owned by `file` and outlives the call; a null
- // buffer with zero length is the documented way to only query availability.
- let ok = unsafe {
- PeekNamedPipe(
- file.as_raw_handle(),
- std::ptr::null_mut(),
- 0,
- std::ptr::null_mut(),
- &mut available,
- std::ptr::null_mut(),
- )
- };
- if ok == 0 {
- Err(io::Error::last_os_error())
- } else {
- Ok(available)
- }
-}
-
-/// Whether an error from the pipe means the peer is gone (EOF for our
-/// purposes) rather than something being wrong with the call itself.
-fn pipe_is_gone(e: &io::Error) -> bool {
- matches!(
- e.raw_os_error().map(|code| code as u32),
- Some(ERROR_BROKEN_PIPE) | Some(ERROR_PIPE_NOT_CONNECTED) | Some(ERROR_NO_DATA)
- )
+struct QuietSink;
+impl EventSink for QuietSink {
+ fn state(&self, _: &State) {}
+ fn traffic(&self, _: u64, _: u64, _: u64, _: u64) {}
+ fn log(&self, _: &str, _: &str) {}
+ fn profiles(&self) {}
+ fn attempts(&self, _: &super::AttemptsSnapshot) {}
+ fn pick_progress(&self, _: &super::PickProgress) {}
}
#[cfg(test)]
@@ -687,6 +659,7 @@ mod tests {
Conn {
reader: Box::new(end.reader),
writer: Box::new(end.writer),
+ cancel: Arc::new(|| {}),
}
}
@@ -1382,6 +1355,7 @@ mod tests {
/// that case (FILE_FLAG_FIRST_PIPE_INSTANCE) and the test surfaces it
/// rather than silently driving the wrong daemon.
#[test]
+ #[ignore = "requires disposable Windows service VM; never run against a desktop service"]
fn real_core_serves_the_well_known_pipe() {
let Some(program) = core_binary() else {
eprintln!("SKIP: tenebra-core binary not built; see tests/sidecar_e2e.rs");
diff --git a/ui-desktop/src-tauri/src/backend/pipe_io.rs b/ui-desktop/src-tauri/src/backend/pipe_io.rs
new file mode 100644
index 00000000..13f40358
--- /dev/null
+++ b/ui-desktop/src-tauri/src/backend/pipe_io.rs
@@ -0,0 +1,400 @@
+//! Cancellable Windows pipe I/O and service authentication. No GUI dependency.
+use std::fs::File;
+use std::io::{self, Read, Write};
+use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+
+use windows_sys::Win32::Foundation::{ERROR_IO_PENDING, WAIT_OBJECT_0, WAIT_TIMEOUT};
+use windows_sys::Win32::Storage::FileSystem::{ReadFile, WriteFile};
+use windows_sys::Win32::System::Pipes::GetNamedPipeServerProcessId;
+use windows_sys::Win32::System::Services::{
+ CloseServiceHandle, OpenSCManagerW, OpenServiceW, QueryServiceConfigW, QueryServiceStatusEx,
+ QUERY_SERVICE_CONFIGW, SC_HANDLE, SC_MANAGER_CONNECT, SC_STATUS_PROCESS_INFO,
+ SERVICE_QUERY_CONFIG, SERVICE_QUERY_STATUS, SERVICE_RUNNING, SERVICE_STATUS_PROCESS,
+ SERVICE_WIN32_OWN_PROCESS,
+};
+use windows_sys::Win32::System::Threading::{
+ CreateEventW, OpenProcess, QueryFullProcessImageNameW, WaitForSingleObject,
+ PROCESS_QUERY_LIMITED_INFORMATION,
+};
+use windows_sys::Win32::System::IO::{CancelIoEx, GetOverlappedResult, OVERLAPPED};
+
+// Mirrored by core/control/pipe_windows.go. Generic write also grants 0x4,
+// FILE_CREATE_PIPE_INSTANCE: an interactive client must never request that.
+pub const CLIENT_ACCESS: u32 = 0x0012_0083;
+
+pub struct PipeIo {
+ file: Arc,
+ stop: Arc,
+ cancelled: Arc,
+}
+
+impl PipeIo {
+ pub fn pair(file: File, stop: Arc) -> (Self, Self, Arc) {
+ let file = Arc::new(file);
+ let cancelled = Arc::new(AtomicBool::new(false));
+ let cancel_flag = Arc::clone(&cancelled);
+ (
+ Self {
+ file: Arc::clone(&file),
+ stop: Arc::clone(&stop),
+ cancelled: Arc::clone(&cancelled),
+ },
+ Self {
+ file,
+ stop,
+ cancelled,
+ },
+ Arc::new(move || {
+ cancel_flag.store(true, Ordering::SeqCst);
+ }),
+ )
+ }
+
+ fn cancelled(&self) -> bool {
+ self.stop.load(Ordering::SeqCst) || self.cancelled.load(Ordering::SeqCst)
+ }
+
+ fn transfer(&self, buf: *mut u8, len: usize, writing: bool) -> io::Result {
+ if self.cancelled() {
+ return Err(io::Error::new(
+ io::ErrorKind::ConnectionAborted,
+ "pipe session cancelled",
+ ));
+ }
+ // Each concurrent operation owns its event and OVERLAPPED. The event,
+ // structure and caller buffer stay alive until completion is reaped,
+ // INCLUDING after CancelIoEx (cancellation alone is not completion).
+ unsafe {
+ let event = CreateEventW(std::ptr::null(), 1, 0, std::ptr::null());
+ if event.is_null() {
+ return Err(io::Error::last_os_error());
+ }
+ let event = OwnedHandle::from_raw_handle(event);
+ let mut op: OVERLAPPED = std::mem::zeroed();
+ op.hEvent = event.as_raw_handle();
+ let mut count = 0;
+ let length = len.min(u32::MAX as usize) as u32;
+ let handle = self.file.as_raw_handle();
+ let ok = if writing {
+ WriteFile(handle, buf, length, &mut count, &mut op)
+ } else {
+ ReadFile(handle, buf, length, &mut count, &mut op)
+ };
+ if ok != 0 {
+ return Ok(count as usize);
+ }
+ let error = io::Error::last_os_error();
+ if error.raw_os_error() != Some(ERROR_IO_PENDING as i32) {
+ return Err(error);
+ }
+ loop {
+ if self.cancelled() {
+ CancelIoEx(handle, &op);
+ // A racing successful completion is fine, but the session
+ // is already cancelled and its reply must not be reused.
+ GetOverlappedResult(handle, &op, &mut count, 1);
+ return Err(io::Error::new(
+ io::ErrorKind::ConnectionAborted,
+ "pipe session cancelled",
+ ));
+ }
+ match WaitForSingleObject(event.as_raw_handle(), 20) {
+ WAIT_OBJECT_0 => {
+ return if GetOverlappedResult(handle, &op, &mut count, 0) != 0 {
+ Ok(count as usize)
+ } else {
+ Err(io::Error::last_os_error())
+ };
+ }
+ WAIT_TIMEOUT => continue,
+ _ => {
+ let error = io::Error::last_os_error();
+ CancelIoEx(handle, &op);
+ GetOverlappedResult(handle, &op, &mut count, 1);
+ return Err(error);
+ }
+ }
+ }
+ }
+ }
+}
+
+impl Read for PipeIo {
+ fn read(&mut self, buf: &mut [u8]) -> io::Result {
+ if buf.is_empty() || self.cancelled() {
+ return Ok(0);
+ }
+ self.transfer(buf.as_mut_ptr(), buf.len(), false)
+ }
+}
+impl Write for PipeIo {
+ fn write(&mut self, buf: &[u8]) -> io::Result {
+ if buf.is_empty() {
+ return Ok(0);
+ }
+ self.transfer(buf.as_ptr() as *mut u8, buf.len(), true)
+ }
+ // WriteFile completes transfer to the pipe buffer. FlushFileBuffers waits
+ // for the peer to read and is not cancellable; it must never be used here.
+ fn flush(&mut self) -> io::Result<()> {
+ Ok(())
+ }
+}
+
+fn identity_matches(server_pid: u32, service_pid: u32, running: bool, local_system: bool) -> bool {
+ running && local_system && server_pid != 0 && server_pid == service_pid
+}
+
+struct ServiceHandle(SC_HANDLE);
+impl Drop for ServiceHandle {
+ fn drop(&mut self) {
+ unsafe {
+ CloseServiceHandle(self.0);
+ }
+ }
+}
+
+unsafe fn wide_string(ptr: *const u16) -> String {
+ if ptr.is_null() {
+ return String::new();
+ }
+ let mut length = 0;
+ while *ptr.add(length) != 0 {
+ length += 1;
+ }
+ String::from_utf16_lossy(std::slice::from_raw_parts(ptr, length))
+}
+
+/// Validate the connected kernel object's server, before sending any payload.
+/// SCM configuration is admin protected. Match its LocalSystem own-process
+/// service, PID and image; no process-token rights or GUI elevation are needed.
+pub fn authenticate_service(file: &File) -> io::Result<()> {
+ unsafe {
+ let mut server_pid = 0;
+ if GetNamedPipeServerProcessId(file.as_raw_handle(), &mut server_pid) == 0 {
+ return Err(io::Error::last_os_error());
+ }
+ let manager = OpenSCManagerW(std::ptr::null(), std::ptr::null(), SC_MANAGER_CONNECT);
+ if manager.is_null() {
+ return Err(io::Error::last_os_error());
+ }
+ let name: Vec = "tenebra\0".encode_utf16().collect();
+ let service = OpenServiceW(
+ manager,
+ name.as_ptr(),
+ SERVICE_QUERY_STATUS | SERVICE_QUERY_CONFIG,
+ );
+ let open_error = io::Error::last_os_error();
+ CloseServiceHandle(manager);
+ if service.is_null() {
+ return Err(open_error);
+ }
+ let service = ServiceHandle(service);
+ let mut status: SERVICE_STATUS_PROCESS = std::mem::zeroed();
+ let mut needed = 0;
+ let ok = QueryServiceStatusEx(
+ service.0,
+ SC_STATUS_PROCESS_INFO,
+ &mut status as *mut _ as *mut u8,
+ std::mem::size_of_val(&status) as u32,
+ &mut needed,
+ );
+ let query_error = io::Error::last_os_error();
+ if ok == 0 {
+ return Err(query_error);
+ }
+ // QueryServiceConfig is readable by ordinary authenticated users. A
+ // LocalSystem token itself need not grant TOKEN_QUERY to those users.
+ let mut config_buffer = [0usize; 1024];
+ let config_ptr = config_buffer.as_mut_ptr() as *mut QUERY_SERVICE_CONFIGW;
+ let ok = QueryServiceConfigW(
+ service.0,
+ config_ptr,
+ std::mem::size_of_val(&config_buffer) as u32,
+ &mut needed,
+ );
+ let config_error = io::Error::last_os_error();
+ if ok == 0 {
+ return Err(config_error);
+ }
+ let config = &*config_ptr;
+ let account = wide_string(config.lpServiceStartName);
+ let configured_image = wide_string(config.lpBinaryPathName);
+ let system = account.eq_ignore_ascii_case("LocalSystem")
+ && status.dwServiceType & SERVICE_WIN32_OWN_PROCESS != 0;
+
+ if status.dwCurrentState != SERVICE_RUNNING
+ || status.dwProcessId != server_pid
+ || server_pid == 0
+ {
+ return Err(io::Error::new(
+ io::ErrorKind::PermissionDenied,
+ "pipe server is not the running Tenebra service",
+ ));
+ }
+ let process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, server_pid);
+ if process.is_null() {
+ return Err(io::Error::last_os_error());
+ }
+ let process = OwnedHandle::from_raw_handle(process);
+ let mut image_path = vec![0u16; 32768];
+ let mut length = image_path.len() as u32;
+ if QueryFullProcessImageNameW(
+ process.as_raw_handle(),
+ 0,
+ image_path.as_mut_ptr(),
+ &mut length,
+ ) == 0
+ {
+ return Err(io::Error::last_os_error());
+ }
+ let actual_image = String::from_utf16_lossy(&image_path[..length as usize]);
+ let registered =
+ super::service_policy::registered_image(&configured_image).ok_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::PermissionDenied,
+ "Tenebra service has an ambiguous executable path",
+ )
+ })?;
+ if !actual_image.eq_ignore_ascii_case(registered) {
+ return Err(io::Error::new(
+ io::ErrorKind::PermissionDenied,
+ "Tenebra service image differs from its registered executable",
+ ));
+ }
+ // Re-read the connected object's PID while retaining the process
+ // handle, preventing PID reuse from validating a replacement process.
+ let mut final_status: SERVICE_STATUS_PROCESS = std::mem::zeroed();
+ if QueryServiceStatusEx(
+ service.0,
+ SC_STATUS_PROCESS_INFO,
+ &mut final_status as *mut _ as *mut u8,
+ std::mem::size_of_val(&final_status) as u32,
+ &mut needed,
+ ) == 0
+ {
+ return Err(io::Error::last_os_error());
+ }
+ let mut final_pid = 0;
+ if GetNamedPipeServerProcessId(file.as_raw_handle(), &mut final_pid) == 0
+ || final_status.dwProcessId != server_pid
+ || !identity_matches(
+ final_pid,
+ final_status.dwProcessId,
+ final_status.dwCurrentState == SERVICE_RUNNING,
+ system && final_status.dwServiceType & SERVICE_WIN32_OWN_PROCESS != 0,
+ )
+ {
+ return Err(io::Error::new(
+ io::ErrorKind::PermissionDenied,
+ "Tenebra pipe server identity could not be verified",
+ ));
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs::OpenOptions;
+ use std::os::windows::fs::OpenOptionsExt;
+ use std::sync::mpsc;
+ use std::thread;
+ use std::time::{Duration, Instant};
+ use windows_sys::Win32::Foundation::{ERROR_PIPE_CONNECTED, INVALID_HANDLE_VALUE};
+ use windows_sys::Win32::Storage::FileSystem::{
+ FILE_FLAG_FIRST_PIPE_INSTANCE, FILE_FLAG_OVERLAPPED, PIPE_ACCESS_DUPLEX,
+ };
+ use windows_sys::Win32::System::Pipes::{
+ ConnectNamedPipe, CreateNamedPipeW, PIPE_TYPE_BYTE, PIPE_WAIT,
+ };
+ #[test]
+ fn only_running_registered_system_process_is_trusted() {
+ assert!(identity_matches(123, 123, true, true));
+ assert!(!identity_matches(124, 123, true, true));
+ assert!(!identity_matches(0, 0, true, true));
+ assert!(!identity_matches(123, 123, false, true));
+ assert!(!identity_matches(123, 123, true, false));
+ }
+ #[test]
+ fn interactive_access_excludes_instance_creation_and_dacl_mutation() {
+ assert_eq!(CLIENT_ACCESS & (0x4 | 0x40000 | 0x80000), 0);
+ assert_eq!(CLIENT_ACCESS & 3, 3);
+ }
+
+ // Isolated kernel pipe only. No service, real core, routes or privileged
+ // operations. Deliberately unread input fills the small server buffer.
+ fn blocked_operation_is_cancelled(writing: bool) {
+ let name = format!(
+ r"\\.\pipe\tenebra-cancel-test-{}-{}",
+ std::process::id(),
+ writing
+ );
+ let server_name = name.clone();
+ let (ready_tx, ready_rx) = mpsc::channel();
+ let (release_tx, release_rx) = mpsc::channel();
+ let server = thread::spawn(move || unsafe {
+ let wide: Vec = server_name.encode_utf16().chain(Some(0)).collect();
+ let handle = CreateNamedPipeW(
+ wide.as_ptr(),
+ PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE,
+ PIPE_TYPE_BYTE | PIPE_WAIT,
+ 1,
+ 4096,
+ 4096,
+ 0,
+ std::ptr::null(),
+ );
+ assert_ne!(handle, INVALID_HANDLE_VALUE);
+ let file = File::from_raw_handle(handle);
+ ready_tx.send(()).unwrap();
+ if ConnectNamedPipe(file.as_raw_handle(), std::ptr::null_mut()) == 0 {
+ assert_eq!(
+ io::Error::last_os_error().raw_os_error(),
+ Some(ERROR_PIPE_CONNECTED as i32)
+ );
+ }
+ let _ = release_rx.recv_timeout(Duration::from_secs(3));
+ });
+ ready_rx.recv_timeout(Duration::from_secs(2)).unwrap();
+ let file = OpenOptions::new()
+ .access_mode(CLIENT_ACCESS)
+ .custom_flags(FILE_FLAG_OVERLAPPED)
+ .open(name)
+ .unwrap();
+ let (mut reader, mut writer, cancel) = PipeIo::pair(file, Arc::new(AtomicBool::new(false)));
+ let (done_tx, done_rx) = mpsc::channel();
+ let caller = thread::spawn(move || {
+ let result = if writing {
+ writer.write_all(&vec![42; 1024 * 1024])
+ } else {
+ reader.read(&mut [0u8; 1]).map(|_| ())
+ };
+ let _ = done_tx.send(result);
+ });
+ assert!(
+ done_rx.recv_timeout(Duration::from_millis(40)).is_err(),
+ "I/O must actually be blocked before cancellation"
+ );
+ let started = Instant::now();
+ cancel();
+ let result = done_rx.recv_timeout(Duration::from_secs(1));
+ drop(release_tx);
+ server.join().unwrap();
+ caller.join().unwrap();
+ assert!(result.unwrap().is_err());
+ assert!(started.elapsed() < Duration::from_secs(1));
+ }
+
+ #[test]
+ fn overlapped_backpressure_write_is_cancelled_and_reaped() {
+ blocked_operation_is_cancelled(true);
+ }
+ #[test]
+ fn overlapped_idle_read_is_cancelled_and_reaped() {
+ blocked_operation_is_cancelled(false);
+ }
+}
diff --git a/ui-desktop/src-tauri/src/backend/service_policy.rs b/ui-desktop/src-tauri/src/backend/service_policy.rs
new file mode 100644
index 00000000..68c7b7cf
--- /dev/null
+++ b/ui-desktop/src-tauri/src/backend/service_policy.rs
@@ -0,0 +1,57 @@
+//! Pure readiness/transport policy, shared by the installer helper and GUI.
+pub fn allow_windows_sidecar(debug_build: bool, pipe_override: Option<&str>) -> bool {
+ debug_build && matches!(pipe_override, Some("off" | "0"))
+}
+
+pub fn verify_version(actual: Option<&str>, expected: &str) -> Result<(), String> {
+ if actual == Some(expected) {
+ return Ok(());
+ }
+ Err(format!("Tenebra service version {} does not match app {expected}; rerun the installer to repair the service", actual.unwrap_or("unknown")))
+}
+
+// Installer registrations contain one quoted absolute executable and no args.
+pub fn registered_image(command: &str) -> Option<&str> {
+ let image = command.strip_prefix('"')?.strip_suffix('"')?;
+ if image.contains('"') || !std::path::Path::new(image).is_absolute() {
+ return None;
+ }
+ Some(image)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn installed_build_never_switches_profile_store() {
+ for value in [None, Some(""), Some("off"), Some("0"), Some("other-pipe")] {
+ assert!(!allow_windows_sidecar(false, value));
+ }
+ assert!(!allow_windows_sidecar(true, None));
+ assert!(allow_windows_sidecar(true, Some("off")));
+ }
+ #[test]
+ fn readiness_requires_exact_known_version() {
+ assert!(verify_version(Some("0.5.11"), "0.5.11").is_ok());
+ assert!(verify_version(Some("0.5.10"), "0.5.11").is_err());
+ assert!(verify_version(None, "0.5.11").is_err());
+ assert!(verify_version(Some("0.5.11-beta.1"), "0.5.11").is_err());
+ }
+
+ #[test]
+ #[cfg(windows)]
+ fn registered_image_rejects_ambiguous_or_relative_commands() {
+ assert_eq!(
+ registered_image(r#""C:\Program Files\Tenebra\tenebra-core.exe""#),
+ Some(r"C:\Program Files\Tenebra\tenebra-core.exe")
+ );
+ for command in [
+ r"C:\Program Files\Tenebra\tenebra-core.exe",
+ r#""relative.exe""#,
+ r#""C:\core.exe" --pipe"#,
+ "",
+ ] {
+ assert!(registered_image(command).is_none());
+ }
+ }
+}
diff --git a/ui-desktop/src-tauri/src/backend/unix.rs b/ui-desktop/src-tauri/src/backend/unix.rs
index 5b3fb5a8..ba8f09b9 100644
--- a/ui-desktop/src-tauri/src/backend/unix.rs
+++ b/ui-desktop/src-tauri/src/backend/unix.rs
@@ -506,7 +506,24 @@ fn serve_session(conn: Conn, shared: &Arc, sink: &Arc
writer,
wake,
} = conn;
- let client = WireClient::new(writer);
+ let cancel_wake = match wake.as_ref().map(UnixStream::try_clone).transpose() {
+ Ok(wake) => wake,
+ Err(error) => {
+ sink.log(
+ "error",
+ &format!("cannot create socket cancellation handle: {error}"),
+ );
+ return;
+ }
+ };
+ let client = WireClient::new_cancellable(
+ writer,
+ Arc::new(move || {
+ if let Some(stream) = &cancel_wake {
+ let _ = stream.shutdown(Shutdown::Both);
+ }
+ }),
+ );
*shared.session.lock().unwrap() = Some(Arc::clone(&client));
*shared.wake.lock().unwrap() = wake;
diff --git a/ui-desktop/src-tauri/src/backend/wire.rs b/ui-desktop/src-tauri/src/backend/wire.rs
index 6f89b364..d0f0d693 100644
--- a/ui-desktop/src-tauri/src/backend/wire.rs
+++ b/ui-desktop/src-tauri/src/backend/wire.rs
@@ -18,7 +18,7 @@ use std::io::{BufRead, BufReader, Read, Write};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex};
-use std::time::Duration;
+use std::time::{Duration, Instant};
use serde::de::DeserializeOwned;
use serde_json::{json, Value};
@@ -42,13 +42,19 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
pub type ReplyResult = Result;
type Pending = Arc>>>;
+struct Outbound {
+ line: Vec,
+ deadline: Instant,
+}
+
/// One live protocol session over some byte stream: the write half plus the
/// request-correlation state the reader completes. Created per connection; a
/// client that reconnects builds a fresh one per session.
pub struct WireClient {
- /// The stream's write half, guarded so concurrent command calls can't
- /// interleave two half-written lines.
- writer: Mutex>,
+ /// One worker owns the stream; callers never wait on its write lock.
+ writer: mpsc::SyncSender,
+ /// Wakes transport I/O without acquiring the writer's lock.
+ cancel: Arc,
/// In-flight requests awaiting a response, keyed by request id.
pending: Pending,
/// Monotonic request-id source. Starts at 1 so ids match the protocol's
@@ -58,7 +64,7 @@ pub struct WireClient {
/// Set once the stream is gone (reader hit EOF/error, or the owner closed
/// the session); further requests fail fast instead of blocking until the
/// timeout.
- closed: AtomicBool,
+ closed: Arc,
}
impl WireClient {
@@ -66,11 +72,56 @@ impl WireClient {
/// [`read_loop`] with the matching read half for responses and events to
/// flow.
pub fn new(writer: impl Write + Send + 'static) -> Arc {
+ Self::new_cancellable(writer, Arc::new(|| {}))
+ }
+
+ pub fn new_cancellable(
+ mut writer: impl Write + Send + 'static,
+ cancel: Arc,
+ ) -> Arc {
+ // Bounded queue: a wedged peer cannot cause unbounded request buffers or
+ // one OS thread per caller. try_send never waits for queue capacity.
+ let (tx, rx) = mpsc::sync_channel::(32);
+ let pending: Pending = Arc::new(Mutex::new(HashMap::new()));
+ let closed = Arc::new(AtomicBool::new(false));
+ let worker_pending = Arc::clone(&pending);
+ let worker_closed = Arc::clone(&closed);
+ let worker_cancel = Arc::clone(&cancel);
+ let spawned = std::thread::Builder::new()
+ .name("tenebra-wire-writer".into())
+ .spawn(move || {
+ while let Ok(outbound) = rx.recv() {
+ if worker_closed.load(Ordering::SeqCst) {
+ break;
+ }
+ if Instant::now() >= outbound.deadline {
+ worker_closed.store(true, Ordering::SeqCst);
+ worker_cancel();
+ fail_all_pending(&worker_pending);
+ break;
+ }
+ if writer
+ .write_all(&outbound.line)
+ .and_then(|_| writer.flush())
+ .is_err()
+ {
+ worker_closed.store(true, Ordering::SeqCst);
+ worker_cancel();
+ fail_all_pending(&worker_pending);
+ break;
+ }
+ }
+ });
+ if spawned.is_err() {
+ closed.store(true, Ordering::SeqCst);
+ cancel();
+ }
Arc::new(Self {
- writer: Mutex::new(Box::new(writer)),
- pending: Arc::new(Mutex::new(HashMap::new())),
+ writer: tx,
+ pending,
next_id: AtomicU64::new(1),
- closed: AtomicBool::new(false),
+ closed,
+ cancel,
})
}
@@ -78,7 +129,9 @@ impl WireClient {
/// Idempotent. The reader calls this on every exit path; owners call it
/// when tearing a session down so no caller waits out the full timeout.
pub fn close(&self) {
- self.closed.store(true, Ordering::SeqCst);
+ if !self.closed.swap(true, Ordering::SeqCst) {
+ (self.cancel)();
+ }
fail_all_pending(&self.pending);
}
@@ -86,26 +139,38 @@ impl WireClient {
/// must serialize to a JSON object; the `id` and `cmd` are spliced in. The
/// returned value is the response's `data` payload (or `null`).
pub fn request(&self, cmd: &str, params: Value) -> Result {
- if self.closed.load(Ordering::SeqCst) {
- return Err("the connection to tenebra-core is closed".into());
- }
+ self.request_with_timeout(cmd, params, REQUEST_TIMEOUT)
+ }
+ /// The deadline includes queueing, writing and response wait. A timeout
+ /// closes this session: a possibly partial frame cannot safely be reused.
+ pub fn request_with_timeout(&self, cmd: &str, params: Value, timeout: Duration) -> ReplyResult {
+ let deadline = Instant::now() + timeout;
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let line = build_request(id, cmd, params)?;
-
- let (tx, rx): (Sender, Receiver) = mpsc::channel();
- self.pending.lock().unwrap().insert(id, tx);
-
- if let Err(e) = self.write_line(&line) {
+ let (tx, rx) = mpsc::channel();
+ {
+ // Serialize registration with close's drain. Either close sees the
+ // waiter or the waiter sees closed; no insert-after-drain race.
+ let mut pending = self.pending.lock().unwrap();
+ if self.closed.load(Ordering::SeqCst) {
+ return Err("the connection to tenebra-core is closed".into());
+ }
+ pending.insert(id, tx);
+ }
+ if self.writer.try_send(Outbound { line, deadline }).is_err() {
self.pending.lock().unwrap().remove(&id);
- return Err(e);
+ return Err(
+ "tenebra-core request queue is unavailable or full; retry the command".into(),
+ );
}
-
- match rx.recv_timeout(REQUEST_TIMEOUT) {
+ match rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) {
Ok(reply) => reply,
Err(mpsc::RecvTimeoutError::Timeout) => {
- self.pending.lock().unwrap().remove(&id);
- Err(format!("tenebra-core did not respond to {cmd} in time"))
+ self.close();
+ Err(format!(
+ "tenebra-core did not respond to {cmd} in time; session closed"
+ ))
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
self.pending.lock().unwrap().remove(&id);
@@ -120,13 +185,11 @@ impl WireClient {
serde_json::from_value(data)
.map_err(|e| format!("malformed {cmd} response from tenebra-core: {e}"))
}
+}
- fn write_line(&self, line: &[u8]) -> Result<(), String> {
- let mut writer = self.writer.lock().unwrap();
- writer
- .write_all(line)
- .and_then(|_| writer.flush())
- .map_err(|e| format!("failed to send request to tenebra-core: {e}"))
+impl Drop for WireClient {
+ fn drop(&mut self) {
+ self.close();
}
}
@@ -1620,3 +1683,7 @@ Core version: 0.5.0
);
}
}
+
+#[cfg(test)]
+#[path = "wire_deadline_tests.rs"]
+mod deadline_tests;
diff --git a/ui-desktop/src-tauri/src/backend/wire_deadline_tests.rs b/ui-desktop/src-tauri/src/backend/wire_deadline_tests.rs
new file mode 100644
index 00000000..87888be4
--- /dev/null
+++ b/ui-desktop/src-tauri/src/backend/wire_deadline_tests.rs
@@ -0,0 +1,128 @@
+use super::*;
+use std::io;
+use std::thread;
+
+struct BlockedWriter {
+ entered: Sender<()>,
+ release: Receiver<()>,
+}
+
+impl Write for BlockedWriter {
+ fn write(&mut self, buf: &[u8]) -> io::Result {
+ let _ = self.entered.send(());
+ let _ = self.release.recv();
+ Ok(buf.len())
+ }
+ fn flush(&mut self) -> io::Result<()> {
+ Ok(())
+ }
+}
+
+#[test]
+fn close_releases_caller_while_writer_is_blocked() {
+ let (entered_tx, entered_rx) = mpsc::channel();
+ let (release_tx, release_rx) = mpsc::channel();
+ let client = WireClient::new(BlockedWriter {
+ entered: entered_tx,
+ release: release_rx,
+ });
+ let request_client = Arc::clone(&client);
+ let (done_tx, done_rx) = mpsc::channel();
+ let handle = thread::spawn(move || {
+ let _ = done_tx.send(request_client.request("status", json!({})));
+ });
+ entered_rx.recv_timeout(Duration::from_secs(2)).unwrap();
+ client.close();
+ let result = done_rx.recv_timeout(Duration::from_millis(200));
+ // Release the controlled writer even on RED: this test never leaks a thread.
+ drop(release_tx);
+ handle.join().unwrap();
+ assert!(
+ result.is_ok(),
+ "close must release the request without waiting for the writer"
+ );
+ assert!(result.unwrap().is_err());
+}
+
+#[test]
+fn deadline_bounds_blocked_write_and_queued_commands() {
+ let (entered_tx, entered_rx) = mpsc::channel();
+ let (release_tx, release_rx) = mpsc::channel();
+ let cancelled = Arc::new(AtomicBool::new(false));
+ let cancel_flag = Arc::clone(&cancelled);
+ let client = WireClient::new_cancellable(
+ BlockedWriter {
+ entered: entered_tx,
+ release: release_rx,
+ },
+ Arc::new(move || {
+ cancel_flag.store(true, Ordering::SeqCst);
+ }),
+ );
+ let first = Arc::clone(&client);
+ let caller = thread::spawn(move || {
+ first.request_with_timeout("import_links", json!({}), Duration::from_millis(100))
+ });
+ entered_rx.recv_timeout(Duration::from_secs(2)).unwrap();
+ let started = Instant::now();
+ let queued = client.request_with_timeout("disconnect", json!({}), Duration::from_millis(300));
+ drop(release_tx);
+ assert!(caller.join().unwrap().unwrap_err().contains("in time"));
+ assert!(queued.is_err());
+ assert!(started.elapsed() < Duration::from_secs(1));
+ assert!(cancelled.load(Ordering::SeqCst));
+ assert!(client.pending.lock().unwrap().is_empty());
+}
+
+#[test]
+fn close_and_registration_race_never_strands_a_waiter() {
+ for _ in 0..100 {
+ let client = WireClient::new(io::sink());
+ let other = Arc::clone(&client);
+ let caller = thread::spawn(move || {
+ other.request_with_timeout("status", json!({}), Duration::from_secs(2))
+ });
+ client.close();
+ let started = Instant::now();
+ assert!(caller.join().unwrap().is_err());
+ assert!(started.elapsed() < Duration::from_millis(500));
+ }
+}
+
+#[test]
+fn queued_frames_are_not_written_after_cancellation() {
+ let (entered_tx, entered_rx) = mpsc::channel();
+ let (release_tx, release_rx) = mpsc::channel();
+ let client = WireClient::new(BlockedWriter {
+ entered: entered_tx,
+ release: release_rx,
+ });
+ let first = Arc::clone(&client);
+ let caller = thread::spawn(move || first.request("status", json!({})));
+ entered_rx.recv_timeout(Duration::from_secs(2)).unwrap();
+ client
+ .writer
+ .try_send(Outbound {
+ line: b"must not send\n".to_vec(),
+ deadline: Instant::now() + Duration::from_secs(5),
+ })
+ .unwrap();
+ client.close();
+ drop(release_tx);
+ assert!(caller.join().unwrap().is_err());
+ assert!(entered_rx.recv_timeout(Duration::from_millis(200)).is_err());
+}
+
+#[test]
+fn expired_request_never_reaches_the_stream() {
+ let (entered_tx, entered_rx) = mpsc::channel();
+ let (_release_tx, release_rx) = mpsc::channel();
+ let client = WireClient::new(BlockedWriter {
+ entered: entered_tx,
+ release: release_rx,
+ });
+ assert!(client
+ .request_with_timeout("connect", json!({}), Duration::ZERO)
+ .is_err());
+ assert!(entered_rx.recv_timeout(Duration::from_millis(100)).is_err());
+}
diff --git a/ui-desktop/src-tauri/src/lib.rs b/ui-desktop/src-tauri/src/lib.rs
index 2678d717..f4b38bf6 100644
--- a/ui-desktop/src-tauri/src/lib.rs
+++ b/ui-desktop/src-tauri/src/lib.rs
@@ -162,52 +162,12 @@ impl EventSink for TauriSink {
}
// =============================================================================
-// Backend selection.
-//
-// The ONE place a transport is chosen, tried in order:
-//
-// 1. TENEBRA_MOCK=1 forces the in-process demo fake (UI work without the
-// core, or when the sidecar binary isn't built). Read by value, so an
-// explicit `0`/`off`/`false`/`no` — or an empty one — is not a request
-// for it; see mock_requested.
-// 2. On Windows, if a core is already listening on the control pipe (the
-// installed service, or `tenebra-core --pipe` in a console), attach to it.
-// The tunnel then outlives this process and the GUI needs no elevation.
-// TENEBRA_PIPE renames the pipe or (`off`) skips it — see
-// backend::pipe::configured_name.
-// 2'. On macOS and Linux, the same probe over the daemon's unix socket
-// (`/var/run/tenebra.sock` and `/run/tenebra.sock` respectively): if the
-// root daemon — the macOS LaunchDaemon, the Linux systemd service — is
-// listening, attach. TENEBRA_SOCKET renames the path or (`off`) skips it —
-// see backend::unix::configured_path.
-// 3. Otherwise spawn the `tenebra-core` sidecar and own it — today's default
-// and the development path.
-//
-// If the sidecar cannot be located or will not spawn (e.g. the binary is
-// missing), we log and fall back to `backend::unavailable`, which refuses every
-// command with that reason. It used to fall back to the demo mock, and that was
-// a lie the user had no way to see through: the window filled with invented
-// profiles, a connect that "succeeded" on a timer, and a bypass reporting fake
-// strategies — an app telling someone their traffic is protected while nothing
-// at all is running. The refusal surfaces in the UI as "the core cannot be
-// reached, retrying", which is what happened. Every choice implements the same
-// `Backend` trait and is logged on the UI's own log channel, so nothing else in
-// this file or the front end changes.
-//
-// The choice is made once and kept for the life of the process (the front end
-// holds no notion of a transport, and a live sidecar tunnel cannot be handed to
-// the service mid-run), which makes step 3 a consequential place to land by
-// accident: an app-owned core keeps its profiles in the per-user store, so a
-// user whose profiles live in the service's machine store sees an empty list
-// and a Connect button that appears to do nothing. Two things guard against
-// arriving there by mistake rather than by configuration: the dial itself waits
-// out a service that is merely still starting (backend::pipe, and
-// backend::unix where the platform warrants it), and the fallback is reported
-// at warn with a plain description of what changed. Where a listener can be
-// probed without displacing whoever holds it — Windows via WaitNamedPipeW,
-// Linux via /proc/net/unix — we then keep watching for a while and say so if
-// the service turns up late, so a user in that state is told a restart is all
-// it takes. macOS has no such probe, so there the warning stands alone.
+// Backend selection. Explicit mock mode is reserved for UI development.
+// Windows release builds always attach to the authenticated machine service;
+// debug builds can opt into their own sidecar with TENEBRA_PIPE=off. A failed
+// service connection preserves the machine profile store and offers repair.
+// Unix builds attach to their root daemon where available, with the existing
+// explicit/logged development-sidecar path. Missing bundled binaries fail closed.
// =============================================================================
fn make_backend(app: &AppHandle, sink: Arc) -> Arc {
if mock_requested(std::env::var("TENEBRA_MOCK").ok().as_deref()) {
@@ -215,31 +175,26 @@ fn make_backend(app: &AppHandle, sink: Arc) -> Arc {
}
#[cfg(windows)]
- if let Some(name) = backend::pipe::configured_name() {
- match backend::pipe::PipeBackend::connect(&name, Arc::clone(&sink)) {
- Ok(backend) => {
- sink.log(
- "info",
- &format!("attached to the Tenebra service on {name}"),
- );
- return Arc::new(backend);
- }
- // Falling through to the sidecar is a working configuration (it is
- // the development path), but on an installed machine it is a
- // downgrade the user never asked for and cannot see from the UI, so
- // it is reported as a warning that names the consequences rather
- // than as a note about spawning a process.
- Err(e) => {
- sink.log(
- "warn",
- &format!(
- "could not reach the Tenebra service on {name} ({e}); \
- running this app's own core instead — profiles saved by the service \
- are not visible here, and connecting in tun mode needs \
- administrator rights"
- ),
- );
- watch_for_a_late_service(name, Arc::clone(&sink));
+ {
+ let override_value = std::env::var("TENEBRA_PIPE").ok();
+ let explicit_sidecar = backend::service_policy::allow_windows_sidecar(
+ cfg!(debug_assertions),
+ override_value.as_deref(),
+ );
+ if !explicit_sidecar {
+ // Release builds always use the authenticated machine service.
+ // Development may explicitly select an alternate pipe.
+ let name = if cfg!(debug_assertions) {
+ backend::pipe::configured_name().unwrap_or_else(|| backend::pipe::PIPE_NAME.into())
+ } else {
+ backend::pipe::PIPE_NAME.into()
+ };
+ match backend::pipe::PipeBackend::connect(&name, Arc::clone(&sink)) {
+ Ok(service) => return Arc::new(service),
+ Err(e) => return no_core(&sink, format!(
+ "Tenebra service is unavailable ({e}). Start the Tenebra service in Windows Services, \
+ then restart the app. If that fails, rerun the Tenebra installer as administrator \
+ and inspect %ProgramData%\\Tenebra\\service.log. Your service profiles remain in their original store.")),
}
}
}
@@ -308,49 +263,14 @@ fn no_core(sink: &Arc, reason: String) -> Arc {
/// should not carry a polling thread for the life of the process. The tick is
/// deliberately lazy; nothing here depends on catching the transition promptly,
/// only on catching it at all.
-#[cfg(any(windows, target_os = "linux"))]
+#[cfg(target_os = "linux")]
const LATE_SERVICE_WATCH: Duration = Duration::from_secs(60);
-#[cfg(any(windows, target_os = "linux"))]
+#[cfg(target_os = "linux")]
const LATE_SERVICE_TICK: Duration = Duration::from_secs(2);
-/// Watch for a service that comes up after this app already committed to its own
-/// core, and say so once if it does.
-///
-/// This app cannot promote itself onto the service mid-run: the sidecar it
-/// spawned may be carrying a live tunnel, and dropping that to attach elsewhere
-/// would take the user's connection down without being asked. What it can do is
-/// stop the state from being silent — a relaunch is all it takes, and the user
-/// has no way to know that from a UI that simply shows no profiles. The watch
-/// lives on its own thread, ends with [`LATE_SERVICE_WATCH`], and probes without
-/// dialing (see [`backend::pipe::is_listening`]) so it never displaces the
-/// session of whatever client the service is actually serving.
-#[cfg(windows)]
-fn watch_for_a_late_service(name: String, sink: Arc) {
- // A thread that cannot be spawned costs the user nothing but this notice.
- let _ = std::thread::Builder::new()
- .name("tenebra-service-watch".into())
- .spawn(move || {
- let appeared = await_probe(
- || backend::pipe::is_listening(&name),
- LATE_SERVICE_TICK,
- LATE_SERVICE_WATCH,
- );
- if appeared {
- sink.log(
- "warn",
- &format!(
- "the Tenebra service is listening on {name} now, but this session is \
- already running the app's own core; restart Tenebra to control the \
- service and see the profiles saved there"
- ),
- );
- }
- });
-}
-
/// Watch for a daemon that comes up after this app already committed to its own
/// core, and say so once if it does. The Linux half of
-/// [`watch_for_a_late_service`], for the same reason and with the same limits;
+/// the Windows service startup check, for the same reason and with the same limits;
/// it probes the kernel's socket table rather than dialing (see
/// [`backend::unix::is_listening`]), so it never displaces the session of
/// whatever client the daemon is actually serving.
@@ -1628,3 +1548,9 @@ mod tests {
}
}
}
+
+/// Called by the trusted installed executable before initializing Tauri.
+#[cfg(windows)]
+pub fn check_installed_service() -> Result<(), String> {
+ backend::pipe::check_service_readiness(env!("CARGO_PKG_VERSION"))
+}
diff --git a/ui-desktop/src-tauri/src/main.rs b/ui-desktop/src-tauri/src/main.rs
index a79303b3..51164240 100644
--- a/ui-desktop/src-tauri/src/main.rs
+++ b/ui-desktop/src-tauri/src/main.rs
@@ -2,5 +2,16 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
+ #[cfg(windows)]
+ if std::env::args_os().nth(1).as_deref() == Some(std::ffi::OsStr::new("--service-check")) {
+ let code = match tenebra_desktop_lib::check_installed_service() {
+ Ok(()) => 0,
+ Err(error) => {
+ eprintln!("{error}");
+ 1
+ }
+ };
+ std::process::exit(code);
+ }
tenebra_desktop_lib::run();
}
From 9e233f852f9f03d866a0b9709b2ad4f02c90aa21 Mon Sep 17 00:00:00 2001
From: DivanMe <48186011+Divaaaan@users.noreply.github.com>
Date: Fri, 11 Sep 2026 18:41:32 +0300
Subject: [PATCH 08/56] fix: publish beta atomically after complete release
delivery
---
.github/scripts/publish-release.mjs | 66 ++--------
.github/scripts/workflows.test.mjs | 27 ++++
.github/workflows/android.yml | 7 +-
.github/workflows/ci.yml | 15 ++-
.github/workflows/release.yml | 60 +++++----
.go-version | 1 +
docs/delivery-acceptance.md | 100 +++++++++++++++
docs/development.md | 11 +-
packaging/arch/PKGBUILD | 5 +
scripts/publish-beta-manifest.mjs | 137 +--------------------
scripts/release-api.mjs | 78 ++++++++++++
scripts/release-lifecycle.mjs | 90 ++++++++++++++
scripts/release-lifecycle.test.mjs | 111 +++++++++++++++++
scripts/verify-core-build.mjs | 26 ++++
scripts/verify-core-build.test.mjs | 12 ++
ui-desktop/src-tauri/src/update_channel.rs | 42 +++++--
16 files changed, 551 insertions(+), 237 deletions(-)
create mode 100644 .go-version
create mode 100644 docs/delivery-acceptance.md
create mode 100644 scripts/release-api.mjs
create mode 100644 scripts/release-lifecycle.mjs
create mode 100644 scripts/release-lifecycle.test.mjs
create mode 100644 scripts/verify-core-build.mjs
create mode 100644 scripts/verify-core-build.test.mjs
diff --git a/.github/scripts/publish-release.mjs b/.github/scripts/publish-release.mjs
index 47afcc7b..f790c58b 100644
--- a/.github/scripts/publish-release.mjs
+++ b/.github/scripts/publish-release.mjs
@@ -23,7 +23,6 @@
// it is built by a separate workflow (.github/workflows/android.yml) on its own
// schedule, and that workflow answers for itself when it cannot produce one.
-import { execFileSync } from "node:child_process";
import { fileURLToPath, pathToFileURL } from "node:url";
function escapeRegExp(s) {
@@ -80,70 +79,19 @@ export function missingAssets(expected, attached) {
);
}
-/** The release for `tag`, looked up in a way that also finds it while a draft. */
-function fetchRelease(repo, tag) {
- // gh falls back to a list-and-match when the by-tag endpoint 404s, which is
- // what it does for a draft: GitHub only exposes drafts by id.
- const out = execFileSync(
- "gh",
- ["release", "view", tag, "--repo", repo, "--json", "isDraft,isPrerelease,assets"],
- { encoding: "utf8" },
- );
- return JSON.parse(out);
-}
-
-function main() {
+async function main() {
const [tag] = process.argv.slice(2);
- if (!tag) {
- console.error("usage: node .github/scripts/publish-release.mjs ");
- process.exit(1);
- }
const repo = process.env.GITHUB_REPOSITORY;
- if (!repo) {
- console.error("publish-release: GITHUB_REPOSITORY is not set");
- process.exit(1);
- }
-
- const version = tag.replace(/^v/, "");
- // Same rule the build jobs resolve the channel with: a SemVer prerelease
- // suffix marks the release prerelease.
- const prerelease = tag.includes("-");
-
- const release = fetchRelease(repo, tag);
- const attached = release.assets.map((a) => a.name).sort();
- const missing = missingAssets(expectedAssets({ version, prerelease }), attached);
-
- if (missing.length > 0) {
- console.error(
- `publish-release: ${tag} is missing ${missing.length} expected asset(s); leaving it a draft`,
- );
- for (const asset of missing) {
- console.error(` missing: ${asset.label} (${asset.want})`);
- }
- console.error(` attached: ${attached.join(", ") || "(nothing)"}`);
- process.exit(1);
- }
-
- if (!release.isDraft) {
- // A re-run of a release that already went out: the set is complete, so
- // there is nothing to publish and nothing to complain about.
- console.log(
- `publish-release: ${tag} is already published, with all ${attached.length} expected assets`,
- );
- return;
- }
-
- execFileSync("gh", ["release", "edit", tag, "--repo", repo, "--draft=false"], {
- stdio: "inherit",
- });
- console.log(
- `publish-release: ${tag} published with ${attached.length} assets: ${attached.join(", ")}`,
- );
+ if (!tag || !repo) throw new Error('usage: GITHUB_REPOSITORY=owner/repo node .github/scripts/publish-release.mjs ');
+ const { publishCompleteRelease } = await import('../../scripts/release-lifecycle.mjs');
+ const { githubReleaseApi } = await import('../../scripts/release-api.mjs');
+ const result = await publishCompleteRelease({ tag, repo, api: githubReleaseApi(repo, tag) });
+ console.log(`publish-release: ${tag} complete and public; beta ${result.switched ? 'updated atomically' : 'already at this or a newer version'}`);
}
// Run only when invoked as a script, so the pure helpers can be unit-tested.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
- main();
+ main().catch(error => { console.error(error.message); process.exitCode = 1; });
}
// Referenced by the test runner without triggering main().
diff --git a/.github/scripts/workflows.test.mjs b/.github/scripts/workflows.test.mjs
index 5f7b5194..1380846b 100644
--- a/.github/scripts/workflows.test.mjs
+++ b/.github/scripts/workflows.test.mjs
@@ -103,6 +103,33 @@ function stepNamed(text, name) {
return found[0];
}
+test("only the final release job can publish either updater channel", () => {
+ const releaseJobs = jobs(workflow("release.yml"));
+ const publishers = [...releaseJobs].filter(([, body]) => steps(body).some(s => /run:.*publish-release\.mjs/.test(s)));
+ assert.deepEqual(publishers.map(([name]) => name), ["publish"]);
+ const required = /needs:\s*\[([^\]]+)\]/.exec(releaseJobs.get("publish"))?.[1].split(',').map(s => s.trim());
+ assert.deepEqual(new Set(required), new Set(['windows', 'macos', 'linux', 'arch-package']));
+ for (const [name, body] of releaseJobs) {
+ if (name === 'publish') continue;
+ for (const step of steps(body)) assert.doesNotMatch(step, /run:.*publish-(?:beta-manifest|release)\.mjs/, name);
+ }
+});
+
+test("every Go setup resolves one exact committed patch", () => {
+ const expected = readFileSync(new URL('../../.go-version', import.meta.url), 'utf8').trim();
+ assert.match(expected, /^\d+\.\d+\.\d+$/);
+ let checked = 0;
+ for (const { name, text } of allWorkflows()) {
+ for (const step of steps(text).filter(s => /uses: actions\/setup-go@/.test(s))) {
+ const file = /go-version-file:\s*['"]?([^'"\s]+)/.exec(step)?.[1];
+ assert.equal(file, '.go-version', name);
+ assert.doesNotMatch(step, /go-version:/, name);
+ checked++;
+ }
+ }
+ assert.ok(checked >= 3);
+});
+
test("the Arch attach step names the repository instead of asking git", () => {
// The build step chowns the checkout to `builder` so makepkg can run, and this
// step runs as root: gh's own repository resolution shells out to git, git
diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index 90b0da0a..0b877f85 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -36,6 +36,9 @@ concurrency:
permissions:
contents: read
+env:
+ GOTOOLCHAIN: local
+
jobs:
debug:
# Every Android-relevant push/PR (path-filtered above). Tag pushes skip this
@@ -48,7 +51,7 @@ jobs:
with:
# >= the sing-box v1.13.14 floor (go 1.24.7) and the same version the
# rest of CI pins, so every job builds the Go core with one toolchain.
- go-version: '1.26'
+ go-version-file: '.go-version'
cache: false
- uses: actions/setup-java@v4
with:
@@ -161,7 +164,7 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
- go-version: '1.26'
+ go-version-file: '.go-version'
cache: false
- uses: actions/setup-java@v4
with:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9d69ee17..bd3a55af 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,6 +6,9 @@ on:
pull_request:
workflow_call:
+env:
+ GOTOOLCHAIN: local
+
jobs:
workflows:
# The pipeline's own checks. Every other job here tests the product; this one
@@ -24,14 +27,14 @@ jobs:
# Quoted so node expands the pattern itself: its test-file discovery
# walks past directories whose name begins with a dot, so handing it
# .github/scripts as a directory finds nothing.
- run: node --test ".github/scripts/*.test.mjs"
+ run: node --test ".github/scripts/*.test.mjs" "scripts/*.test.mjs"
core:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
- go-version: '1.26'
+ go-version-file: '.go-version'
cache: false
- run: go vet ./...
# gofmt reports rather than rewrites here, and the diff is printed: a
@@ -80,7 +83,7 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
- go-version: '1.26'
+ go-version-file: '.go-version'
cache: false
- uses: actions/setup-node@v6
with:
@@ -133,7 +136,7 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
- go-version: '1.26'
+ go-version-file: '.go-version'
cache: false
- uses: actions/setup-node@v6
with:
@@ -216,7 +219,7 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
- go-version: '1.26'
+ go-version-file: '.go-version'
cache: false
- run: go vet ./...
- run: go build ./...
@@ -241,7 +244,7 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
- go-version: '1.26'
+ go-version-file: '.go-version'
cache: false
- run: go vet ./...
- run: go build ./...
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index acea638d..45171097 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -8,6 +8,15 @@ on:
permissions:
contents: write
+# Channel commits use optimistic concurrency as well; serializing publish runs
+# prevents older overlapping platform jobs from racing release visibility.
+concurrency:
+ group: tenebra-release
+ cancel-in-progress: false
+
+env:
+ GOTOOLCHAIN: local
+
jobs:
# Gate the release on the full CI suite so a tag can never point at an
# untested commit: the signed build below only runs once these pass.
@@ -29,7 +38,7 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
- go-version: '1.26'
+ go-version-file: '.go-version'
cache: false
- uses: actions/setup-node@v6
with:
@@ -39,6 +48,13 @@ jobs:
run: powershell -ExecutionPolicy Bypass -File scripts/fetch-resources.ps1
- name: Build core sidecar
run: go build -o ui-desktop/src-tauri/binaries/tenebra-core-x86_64-pc-windows-msvc.exe ./cmd/tenebra-core
+ - name: Verify and retain Windows core build evidence
+ run: node scripts/verify-core-build.mjs ui-desktop/src-tauri/binaries/tenebra-core-x86_64-pc-windows-msvc.exe windows amd64 core-buildinfo-windows.json
+ - uses: actions/upload-artifact@v6
+ with:
+ name: core-buildinfo-windows
+ path: core-buildinfo-windows.json
+ if-no-files-found: error
- name: Install front-end dependencies
working-directory: ui-desktop
run: npm ci
@@ -109,15 +125,6 @@ jobs:
Updates are delivered in-app and verified against the project's
minisign key before they install (on macOS the updater refreshes the
app, not the hand-installed daemon).
- - name: Publish the beta channel manifest
- shell: bash
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- # tauri-action published latest.json on this release; mirror it to
- # beta.json on whichever release backs /releases/latest/download/ so beta
- # clients pick up this build (a prerelease, or a newer stable) while
- # stable clients keep reading latest.json untouched.
- run: node scripts/publish-beta-manifest.mjs "$GITHUB_REF_NAME" "${{ steps.channel.outputs.prerelease }}"
macos:
# Runs after the Windows job on purpose: both jobs upload assets to the same
# tag release and tauri-action merges its platform entries into the release's
@@ -130,7 +137,7 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
- go-version: '1.26'
+ go-version-file: '.go-version'
cache: false
- uses: actions/setup-node@v6
with:
@@ -156,8 +163,15 @@ jobs:
mkdir -p "$bins"
GOOS=darwin GOARCH=arm64 go build -o "$bins/tenebra-core-aarch64-apple-darwin" ./cmd/tenebra-core
GOOS=darwin GOARCH=amd64 go build -o "$bins/tenebra-core-x86_64-apple-darwin" ./cmd/tenebra-core
+ node scripts/verify-core-build.mjs "$bins/tenebra-core-aarch64-apple-darwin" darwin arm64 core-buildinfo-macos-arm64.json
+ node scripts/verify-core-build.mjs "$bins/tenebra-core-x86_64-apple-darwin" darwin amd64 core-buildinfo-macos-amd64.json
lipo -create "$bins/tenebra-core-aarch64-apple-darwin" "$bins/tenebra-core-x86_64-apple-darwin" \
-output "$bins/tenebra-core-universal-apple-darwin"
+ - uses: actions/upload-artifact@v6
+ with:
+ name: core-buildinfo-macos
+ path: core-buildinfo-macos-*.json
+ if-no-files-found: error
- name: Install front-end dependencies
working-directory: ui-desktop
run: npm ci
@@ -217,14 +231,6 @@ jobs:
Updates are delivered in-app and verified against the project's
minisign key before they install (on macOS the updater refreshes the
app, not the hand-installed daemon).
- - name: Publish the beta channel manifest
- shell: bash
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- # Re-run after the macOS assets land so beta.json mirrors the final
- # latest.json carrying both platforms; the Windows job already published
- # a Windows-only interim copy, which this overwrite supersedes.
- run: node scripts/publish-beta-manifest.mjs "$GITHUB_REF_NAME" "${{ steps.channel.outputs.prerelease }}"
linux:
# Third in the chain for the same reason macOS is second: all three jobs
# upload to one release and tauri-action merges its platform entries into
@@ -240,7 +246,7 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
- go-version: '1.26'
+ go-version-file: '.go-version'
cache: false
- uses: actions/setup-node@v6
with:
@@ -256,6 +262,13 @@ jobs:
run: bash scripts/fetch-resources.sh --arch amd64
- name: Build core sidecar
run: go build -o ui-desktop/src-tauri/binaries/tenebra-core-x86_64-unknown-linux-gnu ./cmd/tenebra-core
+ - name: Verify Linux core build evidence
+ run: node scripts/verify-core-build.mjs ui-desktop/src-tauri/binaries/tenebra-core-x86_64-unknown-linux-gnu linux amd64 core-buildinfo-linux.json
+ - uses: actions/upload-artifact@v6
+ with:
+ name: core-buildinfo-linux
+ path: core-buildinfo-linux.json
+ if-no-files-found: error
- name: Install front-end dependencies
working-directory: ui-desktop
run: npm ci
@@ -317,13 +330,6 @@ jobs:
Updates are delivered in-app and verified against the project's
minisign key before they install (on macOS the updater refreshes the
app, not the hand-installed daemon).
- - name: Publish the beta channel manifest
- shell: bash
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- # Final overwrite of beta.json, now that latest.json carries all three
- # platforms; the Windows and macOS jobs published interim copies.
- run: node scripts/publish-beta-manifest.mjs "$GITHUB_REF_NAME" "${{ steps.channel.outputs.prerelease }}"
arch-package:
# Builds the pacman package the Arch users actually want, so they do not have
# to run makepkg themselves. It is a separate job from the Tauri bundles
diff --git a/.go-version b/.go-version
new file mode 100644
index 00000000..25691b4f
--- /dev/null
+++ b/.go-version
@@ -0,0 +1 @@
+1.26.8
diff --git a/docs/delivery-acceptance.md b/docs/delivery-acceptance.md
new file mode 100644
index 00000000..79b5625a
--- /dev/null
+++ b/docs/delivery-acceptance.md
@@ -0,0 +1,100 @@
+# Delivery acceptance after the September audit fixes
+
+The code changes are not an installation or live-tunnel acceptance result.
+Run the following acceptance only in disposable machines or the hosted release
+runners; never point tests at a developer's well-known production pipe.
+
+## Windows
+
+The NSIS installer accepts only service-not-found (1060), already-exists (1073),
+already-stopped (1062) and already-running (1056) in their respective steps.
+Other failures stop the installer with a nonzero exit and repair instructions.
+It waits for STOPPED before replacing binaries and for a RUNNING, authenticated
+service returning the exact installed app version after startup. The installed
+GUI's `--service-check` mode exits before Tauri initialization and sends only
+`status`, using a 30-second overall handshake budget. It does not initialize
+profiles, a sidecar, the updater, autostart or a window.
+
+The GUI matches the kernel pipe server PID to an SCM RUNNING/OWN_PROCESS service,
+its LocalSystem account and strictly quoted registered image. It retains a
+process handle and rechecks SCM state/PID before trusting the stream. This uses
+read-only SCM queries and PROCESS_QUERY_LIMITED_INFORMATION, not TOKEN_QUERY
+or administrator elevation. Interactive pipe rights are `0x120083` on both
+sides; they exclude instance creation, owner changes and DACL writes.
+
+Acceptance matrix: first install; same-version repair; update retaining machine
+profiles; intentionally slow service startup; denied registration/configuration;
+failed startup; stale daemon version; silent updater failures; uninstall and
+reinstall. Test both a standard account and an unelevated administrator account.
+A fake pipe on a unique test name must fail the production identity check before
+any profile/import payload is sent. Verify blocked overlapped reads/writes
+cancel and complete without outstanding OVERLAPPED buffers. Native test names:
+`overlapped_backpressure_write_is_cancelled_and_reaped` and
+`overlapped_idle_read_is_cancelled_and_reaped`.
+
+GUI release builds never silently fall back to a sidecar/profile store. Debug
+builds may opt in using `TENEBRA_PIPE=off`; custom debug pipe names remain a
+development facility. Repair instructions preserve profiles and require a GUI
+restart after repairing the service.
+
+## Release channels
+
+All platform jobs upload into one draft. Only `publish` may open the release,
+after Windows, macOS, Linux and Arch complete. The gate requires every expected
+uploaded, nonempty asset; all nine updater platform entries; the exact tag
+version; same-release URLs; matching attached signatures; a public release and
+public asset downloads. No new release is published from this audit workspace.
+
+`beta.json` moves to the `update-channels` branch. GitHub Contents API updates it
+with the previous blob SHA, giving one atomic Git commit rather than deleting
+and replacing a public release asset. A concurrent publisher retries at most
+three times and compares SemVer on each read: an older release cannot replace a
+newer pointer. Network/API errors preserve the previous committed manifest.
+
+Bootstrap is performed by the final publish job only, after the release is
+public and complete. It creates `update-channels` from the release commit if
+needed. Repository branch rules must permit the workflow's `contents: write`
+token to create/update that branch. A denied bootstrap leaves the new release
+public but the old beta pointer untouched and fails the job; repair permissions
+and rerun that final job. The raw-content endpoint can cache the previous valid
+manifest briefly; this affects freshness, not artifact completeness.
+
+New clients query the atomic beta endpoint, with stable as a network-failure
+fallback. Older clients continue querying the old latest-release `beta.json`:
+that legacy file is populated only inside new stable drafts, and is never
+clobbered on a public stable release. Thus old clients receive future stable
+versions, but new prereleases require upgrading to a client with the new endpoint.
+No current public refs or manifests have been changed by the audit fixes.
+
+## Toolchain and platform boundaries
+
+`.go-version` pins Go 1.26.8 across CI, desktop release and Android jobs; Arch
+selects the same exact toolchain instead of its rolling distribution compiler.
+Go's [release history](https://go.dev/doc/devel/release#go1.26.8) records this
+supported 1.26 patch as released on 2026-09-01. Other jobs prohibit automatic
+Go toolchain switching. Desktop release builds inspect binary build metadata
+without running the binary: exact toolchain, OS/architecture, source revision
+and clean source tree; retained reports include binary SHA-256. macOS validates
+both slices before lipo. These reports do not replace a vulnerability scan.
+
+- Windows Authenticode needs a signing certificate and trusted signing setup.
+ Updater Minisign verification does not provide Authenticode trust.
+- macOS remains an unsigned/unnotarized app with a manually installed daemon;
+ app updates do not update that daemon. A Developer ID, notarization credentials,
+ packaged privileged-helper lifecycle and actual macOS tunnel/update acceptance
+ remain external/product prerequisites.
+- Linux AppImage/deb still require the documented daemon setup; Arch installs
+ the packaged systemd unit. Test package upgrade, service restart and retained
+ profiles on supported distributions.
+- Android requires the existing signing secrets (`ANDROID_KEYSTORE_B64` and the
+ configured alias/password secrets), a signed artifact, and device acceptance
+ for install/update/revoke/reconnect/Doze. No signing material is generated or
+ imported by these fixes; there is no new signed APK or parity claim.
+- iOS remains a scaffold: Apple team/entitlements, Network Extension provisioning,
+ framework/Xcode build and real-device validation are prerequisites. These
+ delivery changes do not turn the scaffold into a supported product.
+
+API references: [Windows pipe access](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights),
+[CancelIoEx completion lifetime](https://learn.microsoft.com/en-us/windows/win32/api/ioapiset/nf-ioapiset-cancelioex),
+[token object access checks](https://learn.microsoft.com/en-us/windows/win32/secauthz/access-rights-for-access-token-objects),
+[GitHub file updates](https://docs.github.com/en/rest/repos/contents#create-or-update-file-contents).
diff --git a/docs/development.md b/docs/development.md
index a67deee2..a1af5dc3 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -14,7 +14,7 @@ wire format, [control-protocol.md](control-protocol.md). This document is the
| [Rust](https://rustup.rs/) (stable) | latest stable | the Tauri desktop shell |
| PowerShell | Windows built-in / [PS 7+](https://github.com/PowerShell/PowerShell) | `scripts/fetch-resources.ps1` |
-CI builds the core on Go 1.26 and the desktop bundle with Node 24, so those are
+CI builds the core on the exact Go patch in `.go-version` (currently 1.26.8) and the desktop bundle with Node 24, so those are
known-good; the minimums above are what `go.mod` and the front end actually
require. The desktop app builds for **Windows, macOS and Linux** (this guide is
written from the Windows side; the platform-specific parts are in
@@ -435,3 +435,12 @@ For contributors deciding where to dig in, the honest open items:
See [CONTRIBUTING.md](../CONTRIBUTING.md) for how to pick something up and propose
a change.
+
+
+### Windows service development and delivery
+
+Release builds use the authenticated per-machine service exclusively. For a
+standalone development core, set `TENEBRA_PIPE=off` with a debug build. A missing
+service now leaves the GUI unavailable with repair instructions; it never opens
+a different profile store. Installer and beta channel acceptance are documented
+in [delivery acceptance](delivery-acceptance.md).
diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD
index bd2fcadf..40c27f14 100644
--- a/packaging/arch/PKGBUILD
+++ b/packaging/arch/PKGBUILD
@@ -113,6 +113,10 @@ prepare() {
build() {
cd "${pkgname}"
+ # Use exactly the patch tested by CI; the system Go only bootstraps it.
+ export GOTOOLCHAIN="go$(cat .go-version)"
+ go version
+
export GOPATH="${srcdir}/gopath"
# Arch's Go packaging template builds with cgo and an external linker, which
@@ -126,6 +130,7 @@ build() {
# go.mod mid-build, and -modcacherw leaves the module cache deletable.
export GOFLAGS="-trimpath -mod=readonly -modcacherw -buildmode=pie"
go build -o build/tenebra-core ./cmd/tenebra-core
+ node scripts/verify-core-build.mjs build/tenebra-core linux amd64 build/core-buildinfo.json
# Tauri resolves an externalBin sidecar by target triple at build time, so the
# core has to exist under that name even though the packaged app never spawns
diff --git a/scripts/publish-beta-manifest.mjs b/scripts/publish-beta-manifest.mjs
index 2b7de3f7..5123207e 100644
--- a/scripts/publish-beta-manifest.mjs
+++ b/scripts/publish-beta-manifest.mjs
@@ -1,135 +1,10 @@
-// Publishes the beta-channel updater manifest (beta.json) to the GitHub release
-// whose assets back the /releases/latest/download/ URL the client polls.
-//
-// Tauri's updater serves one manifest per endpoint, and GitHub's
-// /releases/latest/download/ always resolves to the newest NON-prerelease
-// release. So the stable channel reads latest.json (published by tauri-action on
-// the stable release, unchanged) and the beta channel reads beta.json, which we
-// place on that same latest-stable release. beta.json is a copy of the manifest
-// tauri-action generated for THIS build:
-//
-// - stable tag -> this release becomes the new /latest/; copy its latest.json
-// to beta.json on the same release, so a beta user always sees
-// the newest stable (the beta+stable cascade, at publish time).
-// - prerelease -> GitHub keeps this release out of /latest/; copy its manifest
-// to beta.json on the current latest-stable release, so beta
-// users pick up the prerelease while stable users — reading
-// latest.json on that same release — do not.
-//
-// The manifest is byte-for-byte tauri-action's signed output: same version, same
-// minisign signature, same installer URL (a per-tag asset URL that stays
-// reachable even for a prerelease). Verification is therefore identical on both
-// channels, and the stable flow is never touched.
-//
-// node scripts/publish-beta-manifest.mjs
-//
-// where is the pushed git tag (e.g. v0.4.0-beta.1) and is
-// "true" or "false". Authenticates through gh via GITHUB_TOKEN.
-
-import { execFileSync } from "node:child_process";
-import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { fileURLToPath, pathToFileURL } from "node:url";
-
-/**
- * The release whose assets beta.json must be uploaded to, so that
- * /releases/latest/download/beta.json resolves to it.
- *
- * A stable build owns the new "latest" release, so beta.json goes on the tag
- * itself. A prerelease is excluded from "latest" by GitHub, so beta.json goes on
- * the current latest-stable release. When a prerelease is cut before any stable
- * release exists there is nowhere `/releases/latest/` can point, so there is
- * nothing to publish and the caller skips.
- */
+// Deprecated entry point retained for old script consumers. A platform build
+// must never publish beta. Use the complete lifecycle gate in publish-release.
+import { pathToFileURL } from 'node:url';
export function resolveBetaTarget({ tag, prerelease, latestStable }) {
- if (!prerelease) {
- return tag;
- }
- return latestStable ?? null;
+ return prerelease ? latestStable ?? null : tag;
}
-
-/** Look up the latest non-prerelease release tag, or null when none exists. */
-function latestStableTag(repo) {
- try {
- const out = execFileSync(
- "gh",
- ["api", `repos/${repo}/releases/latest`, "--jq", ".tag_name"],
- { encoding: "utf8" },
- );
- const tag = out.trim();
- return tag.length > 0 ? tag : null;
- } catch {
- // 404 when the repo has no full (non-prerelease) release yet.
- return null;
- }
-}
-
-function main() {
- const [tag, prereleaseArg] = process.argv.slice(2);
- if (!tag || prereleaseArg === undefined) {
- console.error(
- "usage: node scripts/publish-beta-manifest.mjs ",
- );
- process.exit(1);
- }
- const prerelease = prereleaseArg === "true";
- const repo = process.env.GITHUB_REPOSITORY;
- if (!repo) {
- console.error("publish-beta-manifest: GITHUB_REPOSITORY is not set");
- process.exit(1);
- }
-
- const latestStable = prerelease ? latestStableTag(repo) : null;
- const target = resolveBetaTarget({ tag, prerelease, latestStable });
- if (!target) {
- // A prerelease with no stable release to attach to: /releases/latest/ has
- // nowhere to resolve, so there is no beta channel to serve yet. Nothing to
- // do — the next stable release seeds it.
- console.log(
- "publish-beta-manifest: no latest-stable release yet; skipping beta.json",
- );
- return;
- }
-
- // Copy the manifest tauri-action just published for THIS build, renamed to
- // beta.json, then attach it to the target release (replacing any prior one).
- const dir = mkdtempSync(join(tmpdir(), "tenebra-beta-"));
- execFileSync(
- "gh",
- [
- "release",
- "download",
- tag,
- "--repo",
- repo,
- "--pattern",
- "latest.json",
- "--dir",
- dir,
- "--clobber",
- ],
- { stdio: "inherit" },
- );
- const manifest = readFileSync(join(dir, "latest.json"));
- const betaPath = join(dir, "beta.json");
- writeFileSync(betaPath, manifest);
- execFileSync(
- "gh",
- ["release", "upload", target, betaPath, "--repo", repo, "--clobber"],
- { stdio: "inherit" },
- );
-
- const version = JSON.parse(manifest.toString("utf8")).version;
- console.log(
- `publish-beta-manifest: beta.json (${version}) published to ${target}`,
- );
-}
-
-// Run only when invoked as a script, so the pure helper can be unit-tested.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
- main();
+ console.error('Standalone beta publication is disabled; use .github/scripts/publish-release.mjs after every platform job.');
+ process.exitCode = 1;
}
-
-// Referenced by the test runner without triggering main().
-export const _scriptPath = fileURLToPath(import.meta.url);
diff --git a/scripts/release-api.mjs b/scripts/release-api.mjs
new file mode 100644
index 00000000..3729ed13
--- /dev/null
+++ b/scripts/release-api.mjs
@@ -0,0 +1,78 @@
+// GitHub adapter. Tests inject an in-memory adapter into release-lifecycle;
+// this module is called only by the final publish job, never platform builds.
+import { execFileSync } from 'node:child_process';
+import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+export function githubReleaseApi(repo, tag) {
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) throw new Error('invalid GitHub repository');
+ const base = `repos/${repo}`;
+ const branch = 'update-channels';
+ function gh(args, input) {
+ try { return execFileSync('gh', args, { encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'pipe'] }); }
+ catch (error) {
+ const detail = String(error.stderr ?? '');
+ const status = Number(/HTTP (\d{3})/.exec(detail)?.[1]);
+ throw Object.assign(new Error(`GitHub release operation failed${status ? ` (HTTP ${status})` : ''}`), { status });
+ }
+ }
+ function request(path, method = 'GET', payload) {
+ const args = ['api', path, '--method', method];
+ if (payload) args.push('--input', '-');
+ const output = gh(args, payload ? JSON.stringify(payload) : undefined);
+ return output.trim() ? JSON.parse(output) : undefined;
+ }
+ async function assetText(tag, name) {
+ const dir = mkdtempSync(join(tmpdir(), 'tenebra-release-'));
+ try {
+ gh(['release', 'download', tag, '--repo', repo, '--pattern', name, '--dir', dir]);
+ return readFileSync(join(dir, name), 'utf8');
+ } finally { rmSync(dir, { recursive: true, force: true }); }
+ }
+ async function ensureBranch() {
+ try { request(`${base}/git/ref/heads/${branch}`); return; }
+ catch (e) { if (e.status !== 404) throw e; }
+ const sha = request(`${base}/commits/${encodeURIComponent(tag)}`).sha;
+ try { request(`${base}/git/refs`, 'POST', { ref: `refs/heads/${branch}`, sha }); }
+ catch (e) { if (e.status !== 422) throw e; }
+ // Recheck a racing bootstrap: a 422 must not mask another API failure.
+ request(`${base}/git/ref/heads/${branch}`);
+ }
+ return {
+ async getRelease(tag) {
+ const { databaseId } = JSON.parse(gh(['release', 'view', tag, '--repo', repo, '--json', 'databaseId']));
+ const release = request(`${base}/releases/${databaseId}`);
+ return { isDraft: release.draft, isPrerelease: release.prerelease, assets: release.assets };
+ },
+ async readManifest(tag) { return JSON.parse(await assetText(tag, 'latest.json')); },
+ readAssetText: assetText,
+ async seedLegacyBeta(tag, manifest) {
+ const dir = mkdtempSync(join(tmpdir(), 'tenebra-legacy-beta-'));
+ try {
+ const path = join(dir, 'beta.json');
+ writeFileSync(path, JSON.stringify(manifest, null, 2) + '\n');
+ gh(['release', 'upload', tag, path, '--repo', repo, '--clobber']);
+ } finally { rmSync(dir, { recursive: true, force: true }); }
+ },
+ async publish(tag) { gh(['release', 'edit', tag, '--repo', repo, '--draft=false']); },
+ async assertPublicAsset(url) {
+ const response = await fetch(url, { method: 'HEAD', redirect: 'follow', signal: AbortSignal.timeout(15000) });
+ if (!response.ok) throw new Error(`release download is not public/ready (HTTP ${response.status})`);
+ },
+ async readChannel() {
+ try {
+ const result = request(`${base}/contents/beta.json?ref=${branch}`);
+ return { sha: result.sha, manifest: JSON.parse(Buffer.from(result.content, 'base64').toString('utf8')) };
+ } catch (error) { if (error.status === 404) return null; throw error; }
+ },
+ async compareAndSwapChannel(manifest, sha) {
+ await ensureBranch();
+ request(`${base}/contents/beta.json`, 'PUT', {
+ branch, message: `release: publish beta channel ${manifest.version}`,
+ content: Buffer.from(JSON.stringify(manifest, null, 2) + '\n').toString('base64'),
+ ...(sha ? { sha } : {}),
+ });
+ },
+ };
+}
diff --git a/scripts/release-lifecycle.mjs b/scripts/release-lifecycle.mjs
new file mode 100644
index 00000000..3fde71a1
--- /dev/null
+++ b/scripts/release-lifecycle.mjs
@@ -0,0 +1,90 @@
+// Release gate and atomic beta channel switch. All mutation goes through the
+// injectable API boundary so ordering, partial delivery and races are tested.
+import { expectedAssets, missingAssets } from '../.github/scripts/publish-release.mjs';
+
+const platforms = ['windows-x86_64', 'windows-x86_64-nsis',
+ 'darwin-x86_64', 'darwin-aarch64', 'darwin-x86_64-app', 'darwin-aarch64-app',
+ 'linux-x86_64', 'linux-x86_64-appimage', 'linux-x86_64-deb'];
+
+function semver(value) {
+ const m = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(value);
+ if (!m) throw new Error(`invalid release version: ${value}`);
+ const pre = m[4]?.split('.');
+ if (pre?.some(p => /^0\d+$/.test(p))) throw new Error(`invalid prerelease version: ${value}`);
+ return { core: m.slice(1, 4).map(BigInt), pre };
+}
+export function compareVersions(a, b) {
+ const x = semver(a), y = semver(b);
+ for (let i = 0; i < 3; i++) if (x.core[i] !== y.core[i]) return x.core[i] > y.core[i] ? 1 : -1;
+ if (!x.pre || !y.pre) return x.pre ? -1 : y.pre ? 1 : 0;
+ for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
+ const l = x.pre[i], r = y.pre[i];
+ if (l === r) continue;
+ if (l === undefined) return -1;
+ if (r === undefined) return 1;
+ const ln = /^\d+$/.test(l), rn = /^\d+$/.test(r);
+ if (ln && rn) return BigInt(l) > BigInt(r) ? 1 : -1;
+ if (ln !== rn) return ln ? -1 : 1;
+ return l > r ? 1 : -1;
+ }
+ return 0;
+}
+
+export function validateManifest(manifest, { tag, repo, assets }) {
+ if (manifest.version !== tag.replace(/^v/, '')) throw new Error('updater manifest version does not match release tag');
+ const attached = new Set(assets.map(a => a.name));
+ const urls = new Set();
+ for (const key of platforms) {
+ const entry = manifest.platforms?.[key];
+ if (!entry || !entry.signature?.trim()) throw new Error(`missing signed updater platform: ${key}`);
+ const url = new URL(entry.url);
+ const prefix = `/${repo}/releases/download/${tag}/`;
+ if (url.origin !== 'https://github.com' || !decodeURIComponent(url.pathname).startsWith(prefix) || url.search || url.hash)
+ throw new Error(`untrusted updater URL for ${key}`);
+ const name = decodeURIComponent(url.pathname).slice(prefix.length);
+ if (name.includes('/') || !attached.has(name) || !attached.has(`${name}.sig`))
+ throw new Error(`updater asset or signature missing for ${key}`);
+ urls.add(entry.url);
+ }
+ return [...urls];
+}
+
+export async function publishCompleteRelease({ tag, repo, api }) {
+ if (!tag.startsWith('v')) throw new Error('release tag must start with v');
+ const version = tag.slice(1); semver(version);
+ const prerelease = Boolean(semver(version).pre);
+ const release = await api.getRelease(tag);
+ if (release.isPrerelease !== prerelease) throw new Error('release channel disagrees with tag');
+ // Legacy beta is staged only on a stable draft, after every platform job.
+ const expected = expectedAssets({ version, prerelease }).filter(a => a.want !== 'beta.json');
+ const missing = missingAssets(expected, release.assets.filter(a => a.state === 'uploaded' && a.size > 0).map(a => a.name));
+ if (missing.length) throw new Error(`incomplete release: ${missing.map(a => a.want).join(', ')}`);
+ const manifest = await api.readManifest(tag);
+ const urls = validateManifest(manifest, { tag, repo, assets: release.assets });
+ for (const url of urls) {
+ const name = decodeURIComponent(new URL(url).pathname.split('/').pop());
+ const signature = (await api.readAssetText(tag, `${name}.sig`)).trim();
+ for (const entry of Object.values(manifest.platforms)) {
+ if (entry.url === url && entry.signature.trim() !== signature) throw new Error(`manifest signature differs from ${name}.sig`);
+ }
+ }
+ if (!prerelease && release.isDraft) await api.seedLegacyBeta(tag, manifest);
+ if (release.isDraft) await api.publish(tag);
+ const visible = await api.getRelease(tag);
+ if (visible.isDraft) throw new Error('release is still a draft; beta pointer preserved');
+ // Unauthenticated probes catch a draft/private/unavailable download before
+ // the public pointer is changed. No platform build may invoke this switch.
+ for (const asset of release.assets) {
+ await api.assertPublicAsset(`https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(asset.name)}`);
+ }
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const current = await api.readChannel();
+ if (current && compareVersions(current.manifest.version, version) >= 0) return { switched: false };
+ try {
+ await api.compareAndSwapChannel(manifest, current?.sha);
+ return { switched: true };
+ } catch (error) {
+ if (![409, 422].includes(error.status) || attempt === 2) throw error;
+ }
+ }
+}
diff --git a/scripts/release-lifecycle.test.mjs b/scripts/release-lifecycle.test.mjs
new file mode 100644
index 00000000..077710b5
--- /dev/null
+++ b/scripts/release-lifecycle.test.mjs
@@ -0,0 +1,111 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import * as lifecycle from './release-lifecycle.mjs';
+
+const version = '0.6.0-beta.1';
+const names = [
+ `Tenebra_${version}_x64-setup.exe`, `Tenebra_${version}_x64-setup.exe.sig`,
+ `Tenebra_${version}_universal.dmg`, 'Tenebra_universal.app.tar.gz', 'Tenebra_universal.app.tar.gz.sig',
+ `Tenebra_${version}_amd64.deb`, `Tenebra_${version}_amd64.deb.sig`,
+ `Tenebra_${version}_amd64.AppImage`, `Tenebra_${version}_amd64.AppImage.sig`,
+ `tenebra-${version}-1-x86_64.pkg.tar.zst`, 'latest.json',
+];
+function fixture(releaseVersion = version) {
+ const actualNames = names.map(n => n.replace(version, releaseVersion));
+ const assets = actualNames.map((name) => ({ name, state: 'uploaded', size: 12 }));
+ const platforms = {};
+ for (const [keys, asset] of [
+ [['windows-x86_64', 'windows-x86_64-nsis'], actualNames[0]],
+ [['darwin-x86_64', 'darwin-aarch64', 'darwin-x86_64-app', 'darwin-aarch64-app'], actualNames[3]],
+ [['linux-x86_64', 'linux-x86_64-appimage'], actualNames[7]],
+ [['linux-x86_64-deb'], actualNames[5]],
+ ]) for (const key of keys) platforms[key] = { url: `https://github.com/owner/repo/releases/download/v${releaseVersion}/${asset}`, signature: 'signed' };
+ let channel = { sha: 'old-sha', manifest: { version: '0.5.11' } };
+ let release = { isDraft: true, isPrerelease: releaseVersion.includes('-'), assets };
+ const events = [];
+ const api = {
+ getRelease: async () => structuredClone(release),
+ readManifest: async () => ({ version: releaseVersion, platforms }),
+ readAssetText: async () => 'signed',
+ assertPublicAsset: async (url) => { assert.equal(release.isDraft, false); events.push('ready'); },
+ seedLegacyBeta: async () => { assert.equal(release.isDraft, true); events.push('legacy'); },
+ publish: async () => { events.push('publish'); release.isDraft = false; },
+ readChannel: async () => structuredClone(channel),
+ compareAndSwapChannel: async (manifest, sha) => {
+ assert.equal(release.isDraft, false);
+ assert.equal(sha, channel.sha);
+ events.push('switch'); channel = { sha: 'new-sha', manifest };
+ },
+ };
+ return { api, events, channel: () => channel, release, setChannel: (value) => { channel = value; } };
+}
+const run = (api) => lifecycle.publishCompleteRelease({ tag: `v${version}`, repo: 'owner/repo', api });
+
+test('all public assets are ready before the only atomic channel switch', async () => {
+ const f = fixture(); await run(f.api);
+ assert.equal(f.events[0], 'publish');
+ assert.equal(f.events.at(-1), 'switch');
+ assert.equal(f.events.filter((e) => e === 'switch').length, 1);
+ assert.equal(f.channel().manifest.version, version);
+});
+test('downstream missing or failed upload preserves draft and previous pointer', async () => {
+ for (const change of [r => r.assets.pop(), r => r.assets[0].state = 'starter']) {
+ const f = fixture(); change(f.release);
+ await assert.rejects(run(f.api));
+ assert.equal(f.release.isDraft, true);
+ assert.equal(f.channel().sha, 'old-sha');
+ assert.deepEqual(f.events, []);
+ }
+});
+test('publication or public-download failure leaves prior channel unchanged', async () => {
+ for (const key of ['publish', 'assertPublicAsset']) {
+ const f = fixture(); f.api[key] = async () => { throw new Error('injected failure'); };
+ await assert.rejects(run(f.api), /injected failure/);
+ assert.equal(f.channel().sha, 'old-sha');
+ }
+});
+test('wrong version, partial platform coverage, and foreign asset URLs fail closed', async () => {
+ for (const mutate of [m => m.version = '0.5.0', m => delete m.platforms['darwin-aarch64'], m => m.platforms['windows-x86_64'].url = 'https://example.com/setup.exe']) {
+ const f = fixture(); const manifest = await f.api.readManifest(); mutate(manifest);
+ f.api.readManifest = async () => manifest;
+ await assert.rejects(run(f.api));
+ assert.equal(f.channel().sha, 'old-sha'); assert.deepEqual(f.events, []);
+ }
+});
+test('older concurrent publisher cannot roll the channel back', async () => {
+ const f = fixture(); f.setChannel({ sha: 'newer', manifest: { version: '0.7.0' } });
+ await run(f.api); assert.equal(f.channel().manifest.version, '0.7.0');
+ assert.ok(!f.events.includes('switch'));
+});
+test('CAS collision re-reads pointer and yields to newer publication', async () => {
+ const f = fixture(); let calls = 0;
+ f.api.compareAndSwapChannel = async () => { calls++; f.setChannel({ sha: 'race', manifest: { version: '0.7.0' } }); throw Object.assign(new Error('conflict'), { status: 409 }); };
+ await run(f.api); assert.equal(calls, 1); assert.equal(f.channel().sha, 'race');
+});
+test('CAS retry is bounded and never deletes the existing pointer', async () => {
+ const f = fixture(); let calls = 0;
+ f.api.compareAndSwapChannel = async () => { calls++; throw Object.assign(new Error('conflict'), { status: 409 }); };
+ await assert.rejects(run(f.api), /conflict/); assert.equal(calls, 3); assert.equal(f.channel().sha, 'old-sha');
+});
+test('numeric prerelease ordering and stable promotion obey SemVer', () => {
+ assert.ok(lifecycle.compareVersions('0.6.0-beta.10', '0.6.0-beta.2') > 0);
+ assert.ok(lifecycle.compareVersions('0.6.0', '0.6.0-rc.9') > 0);
+ assert.equal(lifecycle.compareVersions('0.6.0+one', '0.6.0+two'), 0);
+});
+
+test('stable legacy manifest is prepared inside the draft before publication', async () => {
+ const f = fixture('0.6.0');
+ await lifecycle.publishCompleteRelease({ tag: 'v0.6.0', repo: 'owner/repo', api: f.api });
+ assert.deepEqual(f.events.slice(0, 2), ['legacy', 'publish']);
+ assert.equal(f.events.at(-1), 'switch');
+});
+test('rerunning a published stable never clobbers its live legacy asset', async () => {
+ const f = fixture('0.6.0'); f.release.isDraft = false;
+ await lifecycle.publishCompleteRelease({ tag: 'v0.6.0', repo: 'owner/repo', api: f.api });
+ assert.ok(!f.events.includes('legacy')); assert.ok(!f.events.includes('publish'));
+});
+test('mismatched updater signature fails before publication', async () => {
+ const f = fixture(); f.api.readAssetText = async () => 'different-signature';
+ await assert.rejects(run(f.api), /signature differs/);
+ assert.deepEqual(f.events, []); assert.equal(f.channel().sha, 'old-sha');
+});
diff --git a/scripts/verify-core-build.mjs b/scripts/verify-core-build.mjs
new file mode 100644
index 00000000..0b2d5452
--- /dev/null
+++ b/scripts/verify-core-build.mjs
@@ -0,0 +1,26 @@
+// Inspect a binary's embedded Go build metadata without executing the binary.
+import { execFileSync } from 'node:child_process';
+import { readFileSync, writeFileSync } from 'node:fs';
+import { createHash } from 'node:crypto';
+import { pathToFileURL } from 'node:url';
+export function verifyBuildInfo(text, { goVersion, revision, os, arch }) {
+ const actualGo = /^.+:\s+go(\S+)$/m.exec(text)?.[1];
+ if (actualGo !== goVersion) throw new Error(`core Go version ${actualGo} differs from pinned ${goVersion}`);
+ const fields = new Map([...text.matchAll(/^\s*build\s+([^=\s]+)=(.+)$/gm)].map(m => [m[1], m[2]]));
+ for (const [key, want] of [['GOOS', os], ['GOARCH', arch], ['vcs.revision', revision], ['vcs.modified', 'false']]) {
+ if (fields.get(key) !== want) throw new Error(`core ${key}=${fields.get(key)}; expected ${want}`);
+ }
+}
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ try {
+ const [binary, os, arch, output] = process.argv.slice(2);
+ if (!binary || !os || !arch || !output) throw new Error('usage: verify-core-build.mjs ');
+ const goVersion = readFileSync('.go-version', 'utf8').trim();
+ const revision = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
+ const metadata = execFileSync('go', ['version', '-m', binary], { encoding: 'utf8' });
+ verifyBuildInfo(metadata, { goVersion, revision, os, arch });
+ const sha256 = createHash('sha256').update(readFileSync(binary)).digest('hex');
+ writeFileSync(output, JSON.stringify({ binary, goVersion, revision, os, arch, sha256, metadata }, null, 2) + '\n');
+ console.log(`verified core ${os}/${arch}: Go ${goVersion}, source ${revision}, sha256 ${sha256}`);
+ } catch (error) { console.error(error.message); process.exitCode = 1; }
+}
diff --git a/scripts/verify-core-build.test.mjs b/scripts/verify-core-build.test.mjs
new file mode 100644
index 00000000..b6d1a724
--- /dev/null
+++ b/scripts/verify-core-build.test.mjs
@@ -0,0 +1,12 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { verifyBuildInfo } from './verify-core-build.mjs';
+const info = 'core.exe: go1.26.8\n\tpath\tgithub.com/Divaaaan/tenebra/cmd/tenebra-core\n\tbuild\tGOOS=windows\n\tbuild\tGOARCH=amd64\n\tbuild\tvcs.revision=abc123\n\tbuild\tvcs.modified=false\n';
+const expected = { goVersion: '1.26.8', revision: 'abc123', os: 'windows', arch: 'amd64' };
+test('build evidence validates exact toolchain, target, and clean source revision', () => {
+ assert.doesNotThrow(() => verifyBuildInfo(info, expected));
+ for (const [before, after] of [['go1.26.8', 'go1.26.7'], ['GOARCH=amd64', 'GOARCH=arm64'], ['GOOS=windows', 'GOOS=linux'], ['abc123', 'old123'], ['vcs.modified=false', 'vcs.modified=true']]) {
+ assert.throws(() => verifyBuildInfo(info.replace(before, after), expected));
+ }
+ assert.throws(() => verifyBuildInfo('', expected));
+});
diff --git a/ui-desktop/src-tauri/src/update_channel.rs b/ui-desktop/src-tauri/src/update_channel.rs
index 977bf0f5..e87446aa 100644
--- a/ui-desktop/src-tauri/src/update_channel.rs
+++ b/ui-desktop/src-tauri/src/update_channel.rs
@@ -14,9 +14,9 @@ use tauri::{AppHandle, Manager, ResourceId, Webview};
use tauri_plugin_updater::UpdaterExt;
use url::Url;
-/// Base location the signed channel manifests are published to. Both manifests
-/// live beside the installer on the latest GitHub release, so the stable one
-/// keeps the exact URL that installed 0.3.0 clients already poll.
+/// Beta is one file on an atomic Git ref; stable keeps its existing release URL.
+const BETA_MANIFEST: &str =
+ "https://raw.githubusercontent.com/Divaaaan/tenebra/update-channels/beta.json";
const MANIFEST_BASE: &str = "https://github.com/Divaaaan/tenebra/releases/latest/download";
/// The manifest URL for a release channel. `beta` resolves to `beta.json`;
@@ -24,12 +24,19 @@ const MANIFEST_BASE: &str = "https://github.com/Divaaaan/tenebra/releases/latest
/// `latest.json`, so a stale or malformed channel can only ever fall back to
/// the safe stable manifest, never to an unintended endpoint.
fn manifest_url(channel: &str) -> String {
- let file = if channel == "beta" {
- "beta.json"
+ if channel == "beta" {
+ BETA_MANIFEST.to_string()
} else {
- "latest.json"
- };
- format!("{MANIFEST_BASE}/{file}")
+ format!("{MANIFEST_BASE}/latest.json")
+ }
+}
+
+fn manifest_urls(channel: &str) -> Vec {
+ let mut urls = vec![manifest_url(channel)];
+ if channel == "beta" {
+ urls.push(manifest_url("stable"));
+ }
+ urls
}
/// The `Update` fields the front end needs to rebuild a handle. Mirrors the
@@ -58,10 +65,14 @@ pub async fn check_update_for_channel(
webview: Webview,
channel: String,
) -> Result
)}
-
+ }
{/* What "connected" actually bought: video, voice and game latency,
measured. The status word alone leaves a user watching a spinning
diff --git a/ui-desktop/src/components/TunConflictConfirm.test.tsx b/ui-desktop/src/components/TunConflictConfirm.test.tsx
index f2bb3de2..c64e947a 100644
--- a/ui-desktop/src/components/TunConflictConfirm.test.tsx
+++ b/ui-desktop/src/components/TunConflictConfirm.test.tsx
@@ -56,11 +56,11 @@ describe("TunConflictConfirm", () => {
it("declines when the scrim behind the card is clicked", () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
- const { container } = renderWithProviders(
+ renderWithProviders(
,
);
- const scrim = container.querySelector(".prof-modal-scrim");
+ const scrim = document.querySelector(".prof-modal-scrim");
expect(scrim).not.toBeNull();
fireEvent.mouseDown(scrim as Element);
expect(onCancel).toHaveBeenCalledTimes(1);
diff --git a/ui-desktop/src/components/TunConflictConfirm.tsx b/ui-desktop/src/components/TunConflictConfirm.tsx
index cd8e517d..1c22cb4f 100644
--- a/ui-desktop/src/components/TunConflictConfirm.tsx
+++ b/ui-desktop/src/components/TunConflictConfirm.tsx
@@ -1,3 +1,4 @@
+import { ModalLayer } from "./ModalLayer";
import { useEffect, useRef } from "react";
import { useI18n } from "../i18n/I18nContext";
@@ -49,7 +50,7 @@ export function TunConflictConfirm({
}, [onCancel]);
return (
- {
if (e.target === e.currentTarget) onCancel();
@@ -82,6 +83,6 @@ export function TunConflictConfirm({
-
+
);
}
diff --git a/ui-desktop/src/components/UpdateBanner.tsx b/ui-desktop/src/components/UpdateBanner.tsx
index 220d4c9c..bc38ee13 100644
--- a/ui-desktop/src/components/UpdateBanner.tsx
+++ b/ui-desktop/src/components/UpdateBanner.tsx
@@ -13,6 +13,7 @@ interface UpdateBannerProps {
progress: number | null;
onInstall: () => void;
onDismiss: () => void;
+ waitingForStatus?: boolean;
}
// One-line strip under the top bar offering the release the update check found.
@@ -27,6 +28,7 @@ export function UpdateBanner({
progress,
onInstall,
onDismiss,
+ waitingForStatus = false,
}: UpdateBannerProps) {
const { t } = useI18n();
@@ -34,7 +36,7 @@ export function UpdateBanner({
? progress == null
? t.update.downloading
: `${t.update.downloading} ${progress}%`
- : deferred
+ : waitingForStatus ? t.update.waitingForStatus : deferred
? t.update.deferred
: t.update.available.replace("{version}", version);
@@ -46,7 +48,7 @@ export function UpdateBanner({
type="button"
className="update-banner-install"
onClick={onInstall}
- disabled={installing}
+ disabled={installing || waitingForStatus}
>
▶ {deferred ? t.update.installNow : t.update.install}
diff --git a/ui-desktop/src/components/UpdateConfirm.test.tsx b/ui-desktop/src/components/UpdateConfirm.test.tsx
index 7d76d1e7..b349af51 100644
--- a/ui-desktop/src/components/UpdateConfirm.test.tsx
+++ b/ui-desktop/src/components/UpdateConfirm.test.tsx
@@ -56,11 +56,11 @@ describe("UpdateConfirm", () => {
it("declines when the scrim behind the card is clicked", () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
- const { container } = renderWithProviders(
+ renderWithProviders(
,
);
- const scrim = container.querySelector(".prof-modal-scrim");
+ const scrim = document.querySelector(".prof-modal-scrim");
expect(scrim).not.toBeNull();
fireEvent.mouseDown(scrim as Element);
expect(onCancel).toHaveBeenCalledTimes(1);
diff --git a/ui-desktop/src/components/UpdateConfirm.tsx b/ui-desktop/src/components/UpdateConfirm.tsx
index 0cea8cf4..e6a3bd80 100644
--- a/ui-desktop/src/components/UpdateConfirm.tsx
+++ b/ui-desktop/src/components/UpdateConfirm.tsx
@@ -1,3 +1,4 @@
+import { ModalLayer } from "./ModalLayer";
import { useEffect, useRef } from "react";
import { useI18n } from "../i18n/I18nContext";
@@ -46,7 +47,7 @@ export function UpdateConfirm({
}, [onCancel]);
return (
- {
// A click on the scrim (not the card) cancels — the safe default.
@@ -84,6 +85,6 @@ export function UpdateConfirm({
-
+
);
}
diff --git a/ui-desktop/src/i18n/strings.ts b/ui-desktop/src/i18n/strings.ts
index c2e3fddd..070c43e6 100644
--- a/ui-desktop/src/i18n/strings.ts
+++ b/ui-desktop/src/i18n/strings.ts
@@ -132,6 +132,8 @@ export interface Strings {
* connect is still being attempted, because it is — the core's fallback walk
* runs anyway, and a refusal would be a worse answer than a slow connect.
*/
+ pingStale: string;
+ manualAfterPing: string;
noneUsable: string;
};
@@ -186,6 +188,7 @@ export interface Strings {
/** Update banner shown when a scheduled check finds a newer release. */
update: {
+ waitingForStatus: string;
/** "{version}" interpolated by the caller. */
available: string;
install: string;
@@ -470,6 +473,10 @@ export interface Strings {
};
settings: {
+ groupTraffic: string;
+ groupAdvanced: string;
+ groupHelp: string;
+ groupApp: string;
title: string;
routing: string;
routingSmart: string;
@@ -830,6 +837,13 @@ export interface Strings {
};
errors: {
+ probeFailed: string;
+ connectFailed: string;
+ protocolFailed: string;
+ serviceFailed: string;
+ selectionFailed: string;
+ details: string;
+
generic: string;
nameRequired: string;
urlRequired: string;
@@ -873,7 +887,7 @@ const en: Strings = {
subOff: "traffic unprotected · select a node and connect",
subPending: "establishing tunnel · negotiating · · ·",
subReconnecting: "node failed · switching to a healthy exit on its own",
- subConnected: "no logs",
+ subConnected: "tunnel connected",
wordChecking: "Measuring…",
subChecking: "probing every node · finding one that carries traffic",
measuring: "MEASURING",
@@ -923,6 +937,8 @@ const en: Strings = {
insecureSummary:
"{n} of {m} nodes skip TLS verification — on-path interception possible",
checking: "Checking which nodes actually work…",
+ pingStale: "stale",
+ manualAfterPing: "TCP check failed. Select this node to try connecting manually.",
noneUsable: "No node carried traffic — connecting anyway, node by node",
},
bottom: {
@@ -952,6 +968,7 @@ const en: Strings = {
dismiss: "Dismiss",
},
update: {
+ waitingForStatus: "Update ready — waiting for the background service status.",
available: "Version {version} is available",
install: "Update",
later: "Later",
@@ -1114,6 +1131,10 @@ const en: Strings = {
},
},
settings: {
+ groupTraffic: "Where traffic goes",
+ groupAdvanced: "Advanced connection",
+ groupHelp: "Recovery and help",
+ groupApp: "App and updates",
title: "Settings",
routing: "Routing",
routingSmart: "Smart",
@@ -1222,7 +1243,7 @@ const en: Strings = {
"Game clients and launchers connect directly: no tunnel latency on a match, and no exit-address change for anti-cheat to flag. Game servers see your real IP address.",
presetVoiceDirect: "Real-time UDP skips the tunnel",
presetVoiceDirectHint:
- "UDP ports 50000-65535 connect directly — measured here at 9ms against 239ms through the tunnel. This range carries voice chat, browser calls and torrents, so whoever is on the other end sees your real IP address.",
+ "UDP ports 50000-65535 connect directly. Latency depends on your network and destination. This range carries voice chat, browser calls and torrents, so whoever is on the other end sees your real IP address.",
rules: "Custom rules",
rulesHint:
"Send specific domains direct or through the tunnel, on top of the routing above.",
@@ -1385,6 +1406,13 @@ const en: Strings = {
ms: "ms",
},
errors: {
+ probeFailed: "The node check could not run. Trying a normal connection; you can also choose a node manually.",
+ connectFailed: "Connection failed. Try another node or open the diagnostic report below.",
+ protocolFailed: "The server protocols could not establish a tunnel. Refresh the subscription and try another node; the report keeps the failure details.",
+ serviceFailed: "The background service did not accept the connection. Check the service status, restart Tenebra and retry.",
+ selectionFailed: "The selected profile or node is no longer available. Refresh the subscription and choose a current node.",
+ details: "Technical details",
+
generic: "Something went wrong.",
nameRequired: "Enter a name.",
urlRequired: "Enter a subscription URL.",
@@ -1430,7 +1458,7 @@ const ru: Strings = {
subOff: "трафик не защищён · выберите узел и подключитесь",
subPending: "поднимаю туннель · согласование · · ·",
subReconnecting: "узел отказал · сам переключаюсь на рабочий выход",
- subConnected: "без логов",
+ subConnected: "туннель подключён",
wordChecking: "Замеряю…",
subChecking: "проверяю каждый узел · ищу тот, через который идёт трафик",
measuring: "ЗАМЕР",
@@ -1480,6 +1508,8 @@ const ru: Strings = {
insecureSummary:
"{n} из {m} узлов без проверки TLS — возможен перехват трафика",
checking: "Проверяю, какие узлы реально работают…",
+ pingStale: "устарело",
+ manualAfterPing: "TCP-проверка не прошла. Выберите узел, чтобы попробовать подключиться вручную.",
noneUsable: "Ни один узел не пропустил трафик — подключаюсь перебором",
},
bottom: {
@@ -1509,6 +1539,7 @@ const ru: Strings = {
dismiss: "Закрыть",
},
update: {
+ waitingForStatus: "Обновление готово — ожидаю состояние фоновой службы.",
available: "Доступна версия {version}",
install: "Обновить",
later: "Позже",
@@ -1672,6 +1703,10 @@ const ru: Strings = {
},
},
settings: {
+ groupTraffic: "Куда идёт трафик",
+ groupAdvanced: "Параметры соединения",
+ groupHelp: "Восстановление и помощь",
+ groupApp: "Приложение и обновления",
title: "Настройки",
routing: "Маршрутизация",
routingSmart: "Умная",
@@ -1780,7 +1815,7 @@ const ru: Strings = {
"Игровые клиенты и лаунчеры подключаются напрямую: нет задержки туннеля в матче и нет смены адреса, на которую реагирует анти-чит. Игровые серверы видят ваш реальный IP.",
presetVoiceDirect: "Realtime-UDP мимо туннеля",
presetVoiceDirectHint:
- "UDP-порты 50000-65535 идут напрямую — здесь это 9 мс против 239 мс через туннель. В этом диапазоне живут голосовые чаты, звонки в браузере и торренты, так что собеседник видит ваш реальный IP.",
+ "UDP-порты 50000-65535 идут напрямую. Задержка зависит от вашей сети и адресата. В этом диапазоне живут голосовые чаты, звонки в браузере и торренты, так что собеседник видит ваш реальный IP.",
rules: "Свои правила",
rulesHint:
"Направляйте отдельные домены напрямую или через туннель, поверх маршрутизации выше.",
@@ -1943,6 +1978,13 @@ const ru: Strings = {
ms: "мс",
},
errors: {
+ probeFailed: "Проверка узлов не выполнилась. Пробую обычное подключение; узел также можно выбрать вручную.",
+ connectFailed: "Подключиться не удалось. Попробуйте другой узел или откройте отчёт диагностики ниже.",
+ protocolFailed: "Протоколы сервера не смогли установить туннель. Обновите подписку и попробуйте другой узел; подробности отказа сохранены в отчёте.",
+ serviceFailed: "Фоновая служба не приняла подключение. Проверьте её состояние, перезапустите Tenebra и повторите попытку.",
+ selectionFailed: "Выбранный профиль или узел больше недоступен. Обновите подписку и выберите актуальный узел.",
+ details: "Технические подробности",
+
generic: "Что-то пошло не так.",
nameRequired: "Введите название.",
urlRequired: "Введите ссылку подписки.",
diff --git a/ui-desktop/src/lib/audit-races.test.ts b/ui-desktop/src/lib/audit-races.test.ts
new file mode 100644
index 00000000..7b7f8668
--- /dev/null
+++ b/ui-desktop/src/lib/audit-races.test.ts
@@ -0,0 +1,89 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { beforeEach, expect, it, vi } from "vitest";
+import type { ConnectionState, State, StateEvent, ServiceCheck } from "../api";
+import { useTenebra } from "../state/useTenebra";
+import { useUpdateCheck } from "./useUpdateCheck";
+import { useServiceChecks } from "./useServiceChecks";
+import { setAutoInstallUpdates } from "./settings";
+
+const m = vi.hoisted(() => ({
+ state: undefined as ((e: StateEvent) => void) | undefined,
+ status: vi.fn(), listProfiles: vi.fn(), checkServices: vi.fn(),
+ checkForUpdate: vi.fn(), installUpdate: vi.fn(),
+}));
+vi.mock("../api", () => ({
+ api: { status: m.status, listProfiles: m.listProfiles, checkServices: m.checkServices },
+ onState: vi.fn((handler) => { m.state = handler; return Promise.resolve(() => {}); }),
+ onTraffic: vi.fn(() => Promise.resolve(() => {})),
+ onLog: vi.fn(() => Promise.resolve(() => {})),
+ onAttempts: vi.fn(() => Promise.resolve(() => {})),
+ onPickProgress: vi.fn(() => Promise.resolve(() => {})),
+ onProfilesChanged: vi.fn(() => Promise.resolve(() => {})),
+}));
+vi.mock("./updates", () => ({
+ checkForUpdate: m.checkForUpdate, installUpdate: m.installUpdate,
+ inAppUpdatesSupported: vi.fn(async () => true),
+ notifyUpdateAvailable: vi.fn(async () => {}),
+}));
+beforeEach(() => {
+ localStorage.clear();
+ m.state = undefined;
+ m.listProfiles.mockResolvedValue([]);
+ m.status.mockResolvedValue({ state: "idle" });
+ m.checkForUpdate.mockResolvedValue({ version: "9.9.9" });
+ m.installUpdate.mockImplementation(() => new Promise(() => {}));
+});
+
+it("merges bootstrap metadata without rolling back the latest event phase", async () => {
+ let resolveStatus!: (s: State) => void;
+ m.status.mockImplementation(() => new Promise((resolve) => { resolveStatus = resolve; }));
+ const { result } = renderHook(() => useTenebra());
+ await waitFor(() => expect(m.state).toBeTypeOf("function"));
+ act(() => m.state!({ state: "connected", node: "new-node" }));
+ await act(async () => resolveStatus({ state: "connecting", node: "old-node", profile: "p1", kill_switch: true, routing: "global", daemon_version: "0.5.11" }));
+ await waitFor(() => expect(result.current.ready).toBe(true));
+ expect(result.current.state).toMatchObject({ state: "connected", node: "new-node", profile: "p1", kill_switch: true, routing: "global", daemon_version: "0.5.11" });
+});
+
+it("holds automatic and manual installation until daemon status is ready", async () => {
+ setAutoInstallUpdates(true);
+ let resolveStatus!: (s: State) => void;
+ m.status.mockImplementation(() => new Promise((resolve) => { resolveStatus = resolve; }));
+ const { result } = renderHook(() => {
+ const tenebra = useTenebra();
+ const update = useUpdateCheck(tenebra.state.state, tenebra.ready);
+ return { tenebra, update };
+ });
+ await waitFor(() => expect(result.current.update.available).toBe("9.9.9"));
+ act(() => result.current.update.install());
+ expect(m.installUpdate).not.toHaveBeenCalled();
+ await act(async () => resolveStatus({ state: "connected" }));
+ expect(m.installUpdate).not.toHaveBeenCalled();
+ act(() => m.state!({ state: "idle" }));
+ await waitFor(() => expect(m.installUpdate).toHaveBeenCalledTimes(1));
+});
+
+it("disarms a deferred install immediately when the current preference is OFF", async () => {
+ setAutoInstallUpdates(true);
+ const { result, rerender } = renderHook(({ phase }) => useUpdateCheck(phase, true), { initialProps: { phase: "connected" as ConnectionState } });
+ await waitFor(() => expect(result.current.deferred).toBe(true));
+ act(() => setAutoInstallUpdates(false));
+ expect(result.current.deferred).toBe(false);
+ rerender({ phase: "idle" });
+ expect(m.installUpdate).not.toHaveBeenCalled();
+});
+
+it("ignores a service verdict that returns after disconnect and checks the new session", async () => {
+ let finish!: (r: {checks: ServiceCheck[]}) => void;
+ m.checkServices.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
+ const { result, rerender } = renderHook(({ phase }) => useServiceChecks(phase), { initialProps: { phase: "connected" as ConnectionState } });
+ await waitFor(() => expect(m.checkServices).toHaveBeenCalledTimes(1));
+ rerender({ phase: "idle" });
+ await act(async () => finish({ checks: [{ service: "youtube", ok: true } as ServiceCheck] }));
+ expect(result.current.checks).toEqual([]);
+ expect(result.current.checking).toBe(false);
+ expect(result.current.runs).toBe(0);
+ m.checkServices.mockResolvedValue({ checks: [] });
+ rerender({ phase: "connected" });
+ await waitFor(() => expect(result.current.runs).toBe(1));
+});
diff --git a/ui-desktop/src/lib/settings.ts b/ui-desktop/src/lib/settings.ts
index a5ac2783..2f174e9d 100644
--- a/ui-desktop/src/lib/settings.ts
+++ b/ui-desktop/src/lib/settings.ts
@@ -67,6 +67,7 @@ export function getAutoInstallUpdates(): boolean {
export function setAutoInstallUpdates(on: boolean): void {
localStorage.setItem(AUTO_INSTALL_UPDATES_KEY, on ? "1" : "0");
+ window.dispatchEvent(new Event("tenebra:auto-install"));
}
/**
diff --git a/ui-desktop/src/lib/useIntentQueue.ts b/ui-desktop/src/lib/useIntentQueue.ts
new file mode 100644
index 00000000..7f5d0596
--- /dev/null
+++ b/ui-desktop/src/lib/useIntentQueue.ts
@@ -0,0 +1,46 @@
+import { useRef, useState } from "react";
+
+/** Serialize full-payload commands while retaining each individual edit.
+ * A failed command is removed before the next intent is derived, so it cannot
+ * roll back a later, unrelated edit or silently retry the rejected field.
+ */
+export function useIntentQueue(authoritative: T, onError: (e: unknown) => void) {
+ type Intent = { change: (value: T) => T; save: (value: T) => Promise };
+ const base = useRef(authoritative);
+ const source = useRef(authoritative);
+ const pending = useRef([]);
+ const running = useRef(false);
+ const report = useRef(onError);
+ report.current = onError;
+ const [, redraw] = useState(0);
+ if (!running.current && source.current !== authoritative) {
+ source.current = authoritative;
+ base.current = authoritative;
+ }
+ const value = pending.current.reduce((v, intent) => intent.change(v), base.current);
+
+ function enqueue(change: Intent["change"], save: Intent["save"]) {
+ pending.current.push({ change, save });
+ redraw((n) => n + 1);
+ if (running.current) return;
+ running.current = true;
+ void (async () => {
+ while (pending.current.length > 0) {
+ const intent = pending.current[0];
+ const next = intent.change(base.current);
+ let failed = false;
+ try {
+ await intent.save(next);
+ base.current = next;
+ } catch (e) {
+ failed = true;
+ report.current(e);
+ }
+ pending.current.shift();
+ if (failed) redraw((n) => n + 1);
+ }
+ running.current = false;
+ })();
+ }
+ return { value, enqueue };
+}
diff --git a/ui-desktop/src/lib/useNodeCheck.test.ts b/ui-desktop/src/lib/useNodeCheck.test.ts
index d86e919c..8c8fc5f7 100644
--- a/ui-desktop/src/lib/useNodeCheck.test.ts
+++ b/ui-desktop/src/lib/useNodeCheck.test.ts
@@ -25,7 +25,8 @@ describe("useNodeCheck", () => {
let picked: string | null = null;
await act(async () => {
- picked = await result.current.run("p1");
+ const outcome = await result.current.run("p1");
+ picked = outcome.kind === "checked" ? outcome.best : null;
});
expect(picked).toBe("n2");
@@ -43,7 +44,8 @@ describe("useNodeCheck", () => {
let picked: string | null = "unset";
await act(async () => {
- picked = await result.current.run("p1");
+ const outcome = await result.current.run("p1");
+ picked = outcome.kind === "checked" ? outcome.best : null;
});
expect(picked).toBeNull();
@@ -72,7 +74,7 @@ describe("useNodeCheck", () => {
const { result } = renderHook(() => useNodeCheck());
await act(async () => {
- await result.current.run("p1");
+ expect(await result.current.run("p1")).toEqual({ kind: "failed", error: "core is down" });
});
await waitFor(() => expect(result.current.error).toBe("core is down"));
diff --git a/ui-desktop/src/lib/useNodeCheck.ts b/ui-desktop/src/lib/useNodeCheck.ts
index 62980782..15828d7f 100644
--- a/ui-desktop/src/lib/useNodeCheck.ts
+++ b/ui-desktop/src/lib/useNodeCheck.ts
@@ -2,6 +2,8 @@ import { useCallback, useRef, useState } from "react";
import { api, type NodeCheckResult } from "../api";
+export type NodeCheckOutcome = { kind: "checked"; best: string | null } | { kind: "failed"; error: string };
+
export interface NodeCheckState {
/** node id → what the last check measured through it. */
results: Map;
@@ -16,7 +18,7 @@ export interface NodeCheckState {
/** The error from the last run, if it failed outright. */
error: string | null;
/** Run a check over the given profile. Resolves to the best node, if any. */
- run: (profileId: string) => Promise;
+ run: (profileId: string) => Promise;
/** Drop everything measured, e.g. when the profile changes. */
reset: () => void;
}
@@ -41,9 +43,9 @@ export function useNodeCheck(): NodeCheckState {
const [checking, setChecking] = useState(false);
const [checked, setChecked] = useState(false);
const [error, setError] = useState(null);
- const inFlight = useRef | null>(null);
+ const inFlight = useRef | null>(null);
- const run = useCallback((profileId: string): Promise => {
+ const run = useCallback((profileId: string): Promise => {
if (inFlight.current) return inFlight.current;
setChecking(true);
@@ -63,12 +65,15 @@ export function useNodeCheck(): NodeCheckState {
const winner = check.best || null;
setBest(winner);
setChecked(true);
- return winner;
+ return { kind: "checked" as const, best: winner };
})
.catch((e: unknown) => {
- setError(e instanceof Error ? e.message : String(e));
- setChecked(true);
- return null;
+ const error = e instanceof Error ? e.message : String(e);
+ setError(error);
+ setResults(new Map());
+ setBest(null);
+ setChecked(false);
+ return { kind: "failed" as const, error };
})
.finally(() => {
setChecking(false);
diff --git a/ui-desktop/src/lib/useNodePings.test.ts b/ui-desktop/src/lib/useNodePings.test.ts
new file mode 100644
index 00000000..854a646f
--- /dev/null
+++ b/ui-desktop/src/lib/useNodePings.test.ts
@@ -0,0 +1,29 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { expect, it, vi } from "vitest";
+import { api, type PingResult } from "../api";
+import { useNodePings } from "./useNodePings";
+
+it("marks old measurements stale when a new ping run fails", async () => {
+ const ping = vi.spyOn(api, "ping").mockResolvedValue([{ node: "n1", ok: true, rttMs: 20 }]);
+ const { result } = renderHook(() => useNodePings("p1"));
+ await waitFor(() => expect(result.current.results.size).toBe(1));
+ ping.mockRejectedValue(new Error("probe process failed"));
+ act(() => result.current.refresh());
+ await waitFor(() => expect(result.current.pinging).toBe(false));
+ expect(result.current.stale).toBe(true);
+ expect(result.current.error).toBe("probe process failed");
+});
+
+it("does not replace a new profile's measurements with a late refresh response", async () => {
+ let finish!: (p: PingResult[]) => void;
+ const ping = vi.spyOn(api, "ping").mockResolvedValue([{ node: "old", ok: true, rttMs: 10 }]);
+ const { result, rerender } = renderHook(({ profile }) => useNodePings(profile), { initialProps: { profile: "p1" } });
+ await waitFor(() => expect(result.current.results.has("old")).toBe(true));
+ ping.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
+ act(() => result.current.refresh());
+ ping.mockResolvedValue([{ node: "new", ok: true, rttMs: 30 }]);
+ rerender({ profile: "p2" });
+ await waitFor(() => expect(result.current.results.has("new")).toBe(true));
+ await act(async () => finish([{ node: "old", ok: true, rttMs: 1 }]));
+ expect([...result.current.results.keys()]).toEqual(["new"]);
+});
diff --git a/ui-desktop/src/lib/useNodePings.ts b/ui-desktop/src/lib/useNodePings.ts
index af46c0fa..f3b1eead 100644
--- a/ui-desktop/src/lib/useNodePings.ts
+++ b/ui-desktop/src/lib/useNodePings.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { api, type PingResult } from "../api";
@@ -6,6 +6,8 @@ export interface NodePings {
/** node id → latest probe result. */
results: Map;
pinging: boolean;
+ stale: boolean;
+ error: string | null;
/** Re-probe every node in the active profile. */
refresh: () => void;
}
@@ -19,40 +21,45 @@ export interface NodePings {
export function useNodePings(profileId: string | null): NodePings {
const [results, setResults] = useState