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, String> { - let endpoint = Url::parse(&manifest_url(&channel)).map_err(|e| e.to_string())?; + let endpoints = manifest_urls(&channel) + .iter() + .map(|url| Url::parse(url)) + .collect::, _>>() + .map_err(|e| e.to_string())?; let updater = webview .updater_builder() - .endpoints(vec![endpoint]) + .endpoints(endpoints) .map_err(|e| e.to_string())? .build() .map_err(|e| e.to_string())?; @@ -117,11 +128,20 @@ mod tests { const LATEST: &str = "https://github.com/Divaaaan/tenebra/releases/latest/download/latest.json"; + #[test] + fn beta_keeps_stable_as_network_failure_fallback() { + assert_eq!( + super::manifest_urls("beta"), + vec![super::BETA_MANIFEST.to_string(), LATEST.to_string()] + ); + assert_eq!(super::manifest_urls("stable"), vec![LATEST.to_string()]); + } + #[test] fn beta_resolves_to_the_beta_manifest() { assert_eq!( manifest_url("beta"), - "https://github.com/Divaaaan/tenebra/releases/latest/download/beta.json" + "https://raw.githubusercontent.com/Divaaaan/tenebra/update-channels/beta.json" ); } From a1d6088e6af4bf0aadc58aeaf8ccd5deca23ec07 Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:46:09 +0300 Subject: [PATCH 09/56] fix: preserve protection evidence through the Rust state relay --- scripts/test-wire-isolated.ps1 | 13 +++++++++ ui-desktop/src-tauri/src/backend/mock.rs | 1 + ui-desktop/src-tauri/src/backend/mod.rs | 25 ++++++++++++++-- ui-desktop/src-tauri/src/backend/pipe.rs | 2 ++ .../src/backend/protection_relay_tests.rs | 29 +++++++++++++++++++ ui-desktop/src-tauri/src/backend/unix.rs | 2 ++ ui-desktop/src-tauri/src/lib.rs | 1 + ui-desktop/src-tauri/src/tray.rs | 1 + 8 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 ui-desktop/src-tauri/src/backend/protection_relay_tests.rs diff --git a/scripts/test-wire-isolated.ps1 b/scripts/test-wire-isolated.ps1 index 3c39012a..b953d60b 100644 --- a/scripts/test-wire-isolated.ps1 +++ b/scripts/test-wire-isolated.ps1 @@ -22,4 +22,17 @@ $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 +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +# Also exercise the real State serde bridge, with no Tauri dependency. +$stateSource = Get-Content -LiteralPath (Join-Path $repoRoot 'ui-desktop/src-tauri/src/backend/mod.rs') -Raw +$stateStart = $stateSource.IndexOf('use serde::{') +$nodeStart = $stateSource.LastIndexOf('#[derive(', $stateSource.IndexOf('pub struct Node {')) +$stateTypes = $stateSource.Substring($stateStart, $nodeStart - $stateStart) +$stateTests = (Join-Path $repoRoot 'ui-desktop/src-tauri/src/backend/protection_relay_tests.rs').Replace('\', '/') +$statePath = Join-Path $OutputDirectory 'state-isolated.rs' +[IO.File]::WriteAllText($statePath, $stateTypes + "`n#[cfg(test)]`n#[path = `"$stateTests`"]`nmod protection_tests;`n") +$stateExe = Join-Path $OutputDirectory 'state-isolated.exe' +& rustc --edition 2021 --test $statePath -L "dependency=$DependencyDirectory" --extern "serde_json=$($jsonLib.FullName)" --extern "serde=$($serdeLib.FullName)" -o $stateExe +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +& $stateExe --test-threads=1 exit $LASTEXITCODE diff --git a/ui-desktop/src-tauri/src/backend/mock.rs b/ui-desktop/src-tauri/src/backend/mock.rs index 9773029f..2f6262b7 100644 --- a/ui-desktop/src-tauri/src/backend/mock.rs +++ b/ui-desktop/src-tauri/src/backend/mock.rs @@ -72,6 +72,7 @@ impl MockBackend { // The mock plays the part of a core built alongside this app, so // it reports the app's own version — the skew banner stays off in // mock-driven dev/tests unless a test injects a mismatch itself. + protection: None, daemon_version: Some(env!("CARGO_PKG_VERSION").into()), split: None, split_apps: None, diff --git a/ui-desktop/src-tauri/src/backend/mod.rs b/ui-desktop/src-tauri/src/backend/mod.rs index cfc90c8e..1db72e78 100644 --- a/ui-desktop/src-tauri/src/backend/mod.rs +++ b/ui-desktop/src-tauri/src/backend/mod.rs @@ -1,4 +1,4 @@ -//! The boundary the UI talks to. +//! The boundary the UI talks to. //! //! Every implementation of the [`Backend`] trait drives the same control //! protocol (see `docs/control-protocol.md`); the Tauri command layer in @@ -23,8 +23,8 @@ pub mod mock; pub mod pipe; #[cfg(windows)] pub(crate) mod pipe_io; -pub mod sidecar; pub(crate) mod service_policy; +pub mod sidecar; #[cfg(test)] pub mod testutil; pub mod unavailable; @@ -145,9 +145,24 @@ pub struct Multihop { pub exit_id: String, } +/// Actual protection evidence from the core, separate from the user's request. +/// Keep status as a string so a future core status survives this relay unchanged; +/// the renderer decides how to display unknown statuses conservatively. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProtectionState { + pub status: String, + pub enforced: bool, + pub persistent: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct State { pub state: ConnectionState, + /// Absent on old cores and synthetic reconnect states: never infer active. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub protection: Option, #[serde(skip_serializing_if = "Option::is_none")] pub node: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -1127,6 +1142,7 @@ mod tests { node: Some("demo-nl".into()), profile: Some("demo-sub".into()), routing: Some(RoutingMode::Smart), + protection: None, daemon_version: Some("0.4.4".into()), split: Some(SplitMode::Exclude), split_apps: Some(vec!["chrome.exe".into(), "steam.exe".into()]), @@ -1173,6 +1189,7 @@ mod tests { node: None, profile: None, routing: None, + protection: None, daemon_version: None, split: None, split_apps: None, @@ -1575,3 +1592,7 @@ mod tests { assert_eq!(to_value(&node).unwrap()["insecure"], json!(true)); } } + +#[cfg(test)] +#[path = "protection_relay_tests.rs"] +mod protection_relay_tests; diff --git a/ui-desktop/src-tauri/src/backend/pipe.rs b/ui-desktop/src-tauri/src/backend/pipe.rs index 5d660490..239a2f8f 100644 --- a/ui-desktop/src-tauri/src/backend/pipe.rs +++ b/ui-desktop/src-tauri/src/backend/pipe.rs @@ -270,6 +270,7 @@ fn reconnecting_state() -> State { routing: None, // Unknown while the service is away; the UI's staleness detection only // trusts idle/connected snapshots, so this None cannot read as "old". + protection: None, daemon_version: None, split: None, split_apps: None, @@ -314,6 +315,7 @@ fn lost_state() -> State { profile: None, routing: None, // Unknown while the service is away (see reconnecting_state). + protection: None, daemon_version: None, split: None, split_apps: None, diff --git a/ui-desktop/src-tauri/src/backend/protection_relay_tests.rs b/ui-desktop/src-tauri/src/backend/protection_relay_tests.rs new file mode 100644 index 00000000..4654e6ca --- /dev/null +++ b/ui-desktop/src-tauri/src/backend/protection_relay_tests.rs @@ -0,0 +1,29 @@ +use super::*; +use serde_json::json; + +#[test] +fn protection_survives_state_deserialization_and_ui_serialization() { + for status in [ + "off", + "applying", + "active", + "blocked", + "error", + "unavailable", + ] { + let protection = json!({ "status": status, "enforced": status == "active", "persistent": true, "error": "diagnostic" }); + // This is the same State decode and sink serialization used by the + // status response and wire::forward_event -> TauriSink::state. + let incoming = json!({ "event": "state", "state": "connected", "protection": protection }); + let state: State = serde_json::from_value(incoming).unwrap(); + let outgoing = serde_json::to_value(state).unwrap(); + assert_eq!(outgoing.get("protection"), Some(&protection)); + } +} + +#[test] +fn old_core_without_protection_remains_unknown() { + let state: State = serde_json::from_value(json!({ "state": "connected" })).unwrap(); + let outgoing = serde_json::to_value(state).unwrap(); + assert!(outgoing.get("protection").is_none()); +} diff --git a/ui-desktop/src-tauri/src/backend/unix.rs b/ui-desktop/src-tauri/src/backend/unix.rs index ba8f09b9..7de07ffd 100644 --- a/ui-desktop/src-tauri/src/backend/unix.rs +++ b/ui-desktop/src-tauri/src/backend/unix.rs @@ -328,6 +328,7 @@ fn reconnecting_state() -> State { routing: None, // Unknown while the daemon is away; the UI's staleness detection only // trusts idle/connected snapshots, so this None cannot read as "old". + protection: None, daemon_version: None, split: None, split_apps: None, @@ -375,6 +376,7 @@ fn lost_state() -> State { profile: None, routing: None, // Unknown while the daemon is away (see reconnecting_state). + protection: None, daemon_version: None, split: None, split_apps: None, diff --git a/ui-desktop/src-tauri/src/lib.rs b/ui-desktop/src-tauri/src/lib.rs index f4b38bf6..1e053034 100644 --- a/ui-desktop/src-tauri/src/lib.rs +++ b/ui-desktop/src-tauri/src/lib.rs @@ -1245,6 +1245,7 @@ mod tests { node: None, profile: None, routing: None, + protection: None, daemon_version: None, split: None, split_apps: None, diff --git a/ui-desktop/src-tauri/src/tray.rs b/ui-desktop/src-tauri/src/tray.rs index b2147ef7..c8e5bb95 100644 --- a/ui-desktop/src-tauri/src/tray.rs +++ b/ui-desktop/src-tauri/src/tray.rs @@ -456,6 +456,7 @@ mod tests { node: None, profile: active_profile.map(Into::into), routing: None, + protection: None, daemon_version: None, split: None, split_apps: None, From a66d03d51e2c9f327e21ad6951e886407115d26e Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:06:24 +0300 Subject: [PATCH 10/56] fix(windows): bind engine lifetime to core and surface local setup failures --- adapters/windows/process_lifetime.go | 36 ++++++ adapters/windows/process_lifetime_other.go | 12 ++ adapters/windows/process_lifetime_test.go | 88 ++++++++++++++ adapters/windows/process_lifetime_windows.go | 121 +++++++++++++++++++ adapters/windows/runner.go | 16 ++- core/control/connect.go | 9 ++ core/control/local_setup_failure_test.go | 54 +++++++++ 7 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 adapters/windows/process_lifetime.go create mode 100644 adapters/windows/process_lifetime_other.go create mode 100644 adapters/windows/process_lifetime_test.go create mode 100644 adapters/windows/process_lifetime_windows.go create mode 100644 core/control/local_setup_failure_test.go diff --git a/adapters/windows/process_lifetime.go b/adapters/windows/process_lifetime.go new file mode 100644 index 00000000..5a5d106c --- /dev/null +++ b/adapters/windows/process_lifetime.go @@ -0,0 +1,36 @@ +package windows + +import "errors" + +// suspendedChild keeps the external process boundary injectable. Production +// Windows starts suspended, assigns an owner-only job, then resumes execution. +type suspendedChild interface { + Prepare() error + StartSuspended() error + Assign() error + Resume() error + Kill() error + Wait() error + Close() error +} + +func startWithLifetime(child suspendedChild) (func() error, error) { + if err := child.Prepare(); err != nil { + return nil, errors.Join(err, child.Close()) + } + if err := child.StartSuspended(); err != nil { + return nil, errors.Join(err, child.Close()) + } + if err := child.Assign(); err != nil { + return nil, errors.Join(err, child.Kill(), child.Close(), child.Wait()) + } + if err := child.Resume(); err != nil { + return nil, errors.Join(err, child.Kill(), child.Close(), child.Wait()) + } + return child.Close, nil +} + +type localStartError struct{ error } + +func (localStartError) LocalSetupFailure() bool { return true } +func (e localStartError) Unwrap() error { return e.error } diff --git a/adapters/windows/process_lifetime_other.go b/adapters/windows/process_lifetime_other.go new file mode 100644 index 00000000..00f4c2d6 --- /dev/null +++ b/adapters/windows/process_lifetime_other.go @@ -0,0 +1,12 @@ +//go:build !windows + +package windows + +import "os/exec" + +func startOwnedCommand(cmd *exec.Cmd) (func() error, error) { + if err := cmd.Start(); err != nil { + return nil, err + } + return func() error { return nil }, nil +} diff --git a/adapters/windows/process_lifetime_test.go b/adapters/windows/process_lifetime_test.go new file mode 100644 index 00000000..ffc9a82e --- /dev/null +++ b/adapters/windows/process_lifetime_test.go @@ -0,0 +1,88 @@ +package windows + +import ( + "errors" + "reflect" + "testing" +) + +type fakeSuspendedChild struct { + fail string + steps []string + running bool +} + +func (f *fakeSuspendedChild) step(name string) error { + f.steps = append(f.steps, name) + if f.fail == name { + return errors.New(name + " failed") + } + return nil +} +func (f *fakeSuspendedChild) Prepare() error { return f.step("prepare") } +func (f *fakeSuspendedChild) StartSuspended() error { return f.step("start suspended") } +func (f *fakeSuspendedChild) Assign() error { return f.step("assign") } +func (f *fakeSuspendedChild) Resume() error { + if err := f.step("resume"); err != nil { + return err + } + f.running = true + return nil +} +func (f *fakeSuspendedChild) Kill() error { f.running = false; return f.step("kill") } +func (f *fakeSuspendedChild) Wait() error { return f.step("wait") } +func (f *fakeSuspendedChild) Close() error { f.running = false; return f.step("close") } + +func TestOwnedEngineCannotRunBeforeJobAssignment(t *testing.T) { + f := &fakeSuspendedChild{} + close, err := startWithLifetime(f) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(f.steps, []string{"prepare", "start suspended", "assign", "resume"}) || !f.running { + t.Fatalf("unsafe startup order: %v", f.steps) + } + if err := close(); err != nil { + t.Fatal(err) + } + if f.running { + t.Fatal("closing the owner did not stop its child") + } +} + +func TestOwnedEngineAssignmentFailureNeverResumes(t *testing.T) { + f := &fakeSuspendedChild{fail: "assign"} + if close, err := startWithLifetime(f); err == nil || close != nil { + t.Fatal("unowned engine start accepted") + } + if !reflect.DeepEqual(f.steps, []string{"prepare", "start suspended", "assign", "kill", "close", "wait"}) || f.running { + t.Fatalf("failed assignment escaped cleanup: %v", f.steps) + } +} + +func TestOwnedEngineResumeFailureKillsAndReaps(t *testing.T) { + f := &fakeSuspendedChild{fail: "resume"} + if _, err := startWithLifetime(f); err == nil { + t.Fatal("resume failure accepted") + } + if !reflect.DeepEqual(f.steps, []string{"prepare", "start suspended", "assign", "resume", "kill", "close", "wait"}) { + t.Fatalf("failed resume escaped cleanup: %v", f.steps) + } +} + +func TestOwnedEnginePreStartFailureDoesNotKillOtherProcesses(t *testing.T) { + for _, stage := range []string{"prepare", "start suspended"} { + f := &fakeSuspendedChild{fail: stage} + if _, err := startWithLifetime(f); err == nil { + t.Fatal("startup failure accepted") + } + for _, step := range f.steps { + if step == "kill" || step == "wait" || step == "resume" { + t.Fatalf("nonexistent child operated on: %v", f.steps) + } + } + if f.steps[len(f.steps)-1] != "close" { + t.Fatal("job handle leaked") + } + } +} diff --git a/adapters/windows/process_lifetime_windows.go b/adapters/windows/process_lifetime_windows.go new file mode 100644 index 00000000..c5c54b72 --- /dev/null +++ b/adapters/windows/process_lifetime_windows.go @@ -0,0 +1,121 @@ +//go:build windows + +package windows + +import ( + "errors" + "fmt" + "os/exec" + "sync" + "syscall" + "unsafe" + + win "golang.org/x/sys/windows" +) + +var procEngineThreadOwner = win.NewLazySystemDLL("kernel32.dll").NewProc("GetProcessIdOfThread") + +type jobChild struct { + cmd *exec.Cmd + job win.Handle + closeOnce sync.Once + closeErr error +} + +func startOwnedCommand(cmd *exec.Cmd) (func() error, error) { + return startWithLifetime(&jobChild{cmd: cmd}) +} + +func (c *jobChild) Prepare() error { + job, err := win.CreateJobObject(nil, nil) // unnamed, non-inheritable, owned only by core + if err != nil { + return fmt.Errorf("create engine lifetime job: %w", err) + } + c.job = job + limits := win.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{} + limits.BasicLimitInformation.LimitFlags = win.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if _, err := win.SetInformationJobObject(job, win.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&limits)), uint32(unsafe.Sizeof(limits))); err != nil { + return fmt.Errorf("set engine lifetime job: %w", err) + } + return nil +} + +func (c *jobChild) StartSuspended() error { + attr := new(syscall.SysProcAttr) + if c.cmd.SysProcAttr != nil { + *attr = *c.cmd.SysProcAttr + } + attr.CreationFlags |= win.CREATE_SUSPENDED | win.CREATE_NO_WINDOW + attr.HideWindow = true + c.cmd.SysProcAttr = attr + return c.cmd.Start() +} + +func (c *jobChild) Assign() error { + // Cmd retains its process handle until Wait, preventing reuse of this PID. + process, err := win.OpenProcess(win.PROCESS_SET_QUOTA|win.PROCESS_TERMINATE, false, uint32(c.cmd.Process.Pid)) + if err != nil { + return fmt.Errorf("open suspended engine: %w", err) + } + defer win.CloseHandle(process) + if err := win.AssignProcessToJobObject(c.job, process); err != nil { + return fmt.Errorf("assign engine lifetime job: %w", err) + } + return nil +} + +func (c *jobChild) Resume() error { + pid := uint32(c.cmd.Process.Pid) + snapshot, err := win.CreateToolhelp32Snapshot(win.TH32CS_SNAPTHREAD, 0) + if err != nil { + return err + } + defer win.CloseHandle(snapshot) + entry := win.ThreadEntry32{Size: uint32(unsafe.Sizeof(win.ThreadEntry32{}))} + err = win.Thread32First(snapshot, &entry) + var ids []uint32 + for err == nil { + if entry.OwnerProcessID == pid { + ids = append(ids, entry.ThreadID) + } + err = win.Thread32Next(snapshot, &entry) + } + if !errors.Is(err, win.ERROR_NO_MORE_FILES) { + return fmt.Errorf("enumerate suspended engine thread: %w", err) + } + if len(ids) != 1 { + return fmt.Errorf("suspended engine has %d threads; startup refused", len(ids)) + } + thread, err := win.OpenThread(win.THREAD_SUSPEND_RESUME|win.THREAD_QUERY_LIMITED_INFORMATION, false, ids[0]) + if err != nil { + return err + } + defer win.CloseHandle(thread) + owner, _, ownerErr := procEngineThreadOwner.Call(uintptr(thread)) + if owner == 0 { + return fmt.Errorf("verify engine thread owner: %w", ownerErr) + } + if uint32(owner) != pid { + return errors.New("suspended engine thread identity changed") + } + previous, err := win.ResumeThread(thread) + if err != nil { + return fmt.Errorf("resume owned engine: %w", err) + } + if previous != 1 { + return fmt.Errorf("unexpected engine thread suspension count %d", previous) + } + return nil +} + +func (c *jobChild) Kill() error { return c.cmd.Process.Kill() } +func (c *jobChild) Wait() error { return c.cmd.Wait() } +func (c *jobChild) Close() error { + c.closeOnce.Do(func() { + if c.job != 0 { + c.closeErr = win.CloseHandle(c.job) + c.job = 0 + } + }) + return c.closeErr +} diff --git a/adapters/windows/runner.go b/adapters/windows/runner.go index b8f24400..6b58c516 100644 --- a/adapters/windows/runner.go +++ b/adapters/windows/runner.go @@ -118,7 +118,14 @@ func New() *Runner { // spawned; the tunnel coming up (or failing) is observed through Done. Starting // while a process is already running is rejected — the caller is expected to // Stop first. -func (r *Runner) Start(ctx context.Context, configJSON []byte) error { +func (r *Runner) Start(ctx context.Context, configJSON []byte) (startErr error) { + // A missing binary/driver, temp-file failure or denied process/job setup is + // local to this installation. Trying other servers cannot repair it. + defer func() { + if startErr != nil { + startErr = localStartError{startErr} + } + }() bin, err := r.resolveSingbox() if err != nil { return err @@ -156,13 +163,17 @@ func (r *Runner) Start(ctx context.Context, configJSON []byte) error { } stderr, err := cmd.StderrPipe() if err != nil { + _ = stdout.Close() cancel() os.Remove(cfgPath) return fmt.Errorf("windows: stderr pipe: %w", err) } - if err := cmd.Start(); err != nil { + releaseProcess, err := startOwnedCommand(cmd) + if err != nil { cancel() + _ = stdout.Close() + _ = stderr.Close() os.Remove(cfgPath) return fmt.Errorf("windows: start sing-box: %w", err) } @@ -183,6 +194,7 @@ func (r *Runner) Start(ctx context.Context, configJSON []byte) error { // the running state so the Runner can be started again. go func() { werr := cmd.Wait() + werr = errors.Join(werr, releaseProcess()) cancel() os.Remove(cfgPath) diff --git a/core/control/connect.go b/core/control/connect.go index 97f9c857..73f6710f 100644 --- a/core/control/connect.go +++ b/core/control/connect.go @@ -778,6 +778,15 @@ func (d *Daemon) attemptNode(ctx context.Context, loop fallbackLoop, attempt fal _ = d.runner.Stop() return nodeSuperseded, "" } + var local interface{ LocalSetupFailure() bool } + if errors.As(err, &local) && local.LocalSetupFailure() { + _ = d.runner.Stop() + msg := "local tunnel setup failed: " + err.Error() + tracker.blockedWithReason(attempt, "local setup failed") + d.emitLog(LogError, msg) + d.setState(State{State: StateError, Profile: loop.profileID, Error: msg, Routing: d.snapshotState().Routing}) + return nodeLocalFailure, "" + } d.emitLog(LogWarn, fmt.Sprintf("connect: sing-box would not start for %s: %v", who, err)) return nodeFailed, "" } diff --git a/core/control/local_setup_failure_test.go b/core/control/local_setup_failure_test.go new file mode 100644 index 00000000..14a375b8 --- /dev/null +++ b/core/control/local_setup_failure_test.go @@ -0,0 +1,54 @@ +package control + +import ( + "context" + "strings" + "sync/atomic" + "testing" + "time" +) + +type fakeLocalSetupError struct{} + +func (fakeLocalSetupError) Error() string { return "engine lifetime job assignment denied" } +func (fakeLocalSetupError) LocalSetupFailure() bool { return true } + +type localSetupFailRunner struct { + *fakeRunner + attempts atomic.Int32 +} + +func (r *localSetupFailRunner) Start(context.Context, []byte) error { + r.attempts.Add(1) + return fakeLocalSetupError{} +} + +func TestLocalSetupFailureDoesNotMarkEveryServerUnavailable(t *testing.T) { + d, base, p := proxySafetyDaemon(t) + second := p.Servers[0] + second.ID = "second-server" + p.Servers = append(p.Servers, second) + r := &localSetupFailRunner{fakeRunner: base} + d.runner = r + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, "", 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 == StateError { + if !strings.Contains(st.Error, "job assignment denied") { + t.Fatalf("local failure hidden as remote failure: %q", st.Error) + } + if got := r.attempts.Load(); got != 1 { + t.Fatalf("retried %d servers for a machine-wide setup failure", got) + } + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("local setup failure never surfaced") +} From 9c7f07c0238f10017234a61429d4208cfb780fbe Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:40:44 +0300 Subject: [PATCH 11/56] fix(desktop): preserve user intent across asynchronous UI flows --- ui-desktop/src/App.audit.test.tsx | 145 +++++++++++ ui-desktop/src/App.bootstrap.test.tsx | 4 +- ui-desktop/src/App.simple.test.tsx | 6 +- ui-desktop/src/App.tsx | 229 ++++++++---------- .../src/components/ConnectionError.test.tsx | 15 ++ ui-desktop/src/components/ConnectionError.tsx | 20 ++ .../src/components/CrashReportModal.tsx | 5 +- .../src/components/DeepLinkConfirm.test.tsx | 4 +- ui-desktop/src/components/DeepLinkConfirm.tsx | 5 +- ui-desktop/src/components/ModalLayer.test.tsx | 52 ++++ ui-desktop/src/components/ModalLayer.tsx | 67 +++++ .../components/ProblemReportModal.test.tsx | 4 +- .../src/components/ProblemReportModal.tsx | 5 +- ui-desktop/src/components/ServerList.test.tsx | 6 +- ui-desktop/src/components/ServerList.tsx | 11 +- .../src/components/SimpleSetup.test.tsx | 14 ++ ui-desktop/src/components/SimpleSetup.tsx | 6 +- ui-desktop/src/components/SimpleView.test.tsx | 6 +- ui-desktop/src/components/SimpleView.tsx | 6 +- .../components/TunConflictConfirm.test.tsx | 4 +- .../src/components/TunConflictConfirm.tsx | 5 +- ui-desktop/src/components/UpdateBanner.tsx | 6 +- .../src/components/UpdateConfirm.test.tsx | 4 +- ui-desktop/src/components/UpdateConfirm.tsx | 5 +- ui-desktop/src/i18n/strings.ts | 50 +++- ui-desktop/src/lib/audit-races.test.ts | 89 +++++++ ui-desktop/src/lib/settings.ts | 1 + ui-desktop/src/lib/useIntentQueue.ts | 46 ++++ ui-desktop/src/lib/useNodeCheck.test.ts | 8 +- ui-desktop/src/lib/useNodeCheck.ts | 19 +- ui-desktop/src/lib/useNodePings.test.ts | 29 +++ ui-desktop/src/lib/useNodePings.ts | 35 +-- ui-desktop/src/lib/useServiceChecks.ts | 27 ++- ui-desktop/src/lib/useUpdateCheck.test.tsx | 68 +++--- ui-desktop/src/lib/useUpdateCheck.ts | 34 ++- ui-desktop/src/screens/ProfilesScreen.tsx | 23 +- .../screens/SettingsScreen.intents.test.tsx | 72 ++++++ ui-desktop/src/screens/SettingsScreen.tsx | 193 +++++++-------- ui-desktop/src/state/useTenebra.ts | 11 +- ui-desktop/src/styles/profiles.css | 2 + ui-desktop/src/styles/settings.css | 15 ++ ui-desktop/src/styles/shell.css | 31 ++- ui-desktop/src/styles/tokens.css | 12 +- 43 files changed, 1033 insertions(+), 366 deletions(-) create mode 100644 ui-desktop/src/App.audit.test.tsx create mode 100644 ui-desktop/src/components/ConnectionError.test.tsx create mode 100644 ui-desktop/src/components/ConnectionError.tsx create mode 100644 ui-desktop/src/components/ModalLayer.test.tsx create mode 100644 ui-desktop/src/components/ModalLayer.tsx create mode 100644 ui-desktop/src/components/SimpleSetup.test.tsx create mode 100644 ui-desktop/src/lib/audit-races.test.ts create mode 100644 ui-desktop/src/lib/useIntentQueue.ts create mode 100644 ui-desktop/src/lib/useNodePings.test.ts create mode 100644 ui-desktop/src/screens/SettingsScreen.intents.test.tsx diff --git a/ui-desktop/src/App.audit.test.tsx b/ui-desktop/src/App.audit.test.tsx new file mode 100644 index 00000000..7f57ceab --- /dev/null +++ b/ui-desktop/src/App.audit.test.tsx @@ -0,0 +1,145 @@ +import type { DeepLinkAction, PingResult } from "./api"; +import { createElement } from 'react'; +import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { App } from './App.tsx'; +import { renderWithProviders } from './test/renderWithProviders.tsx'; + +const m = vi.hoisted(() => ({ + checkNodes: vi.fn(), importSubscription: vi.fn(), refreshProfiles: vi.fn(), + updateAvailable: null as string | null, updateConfirm: false, confirmUpdate: vi.fn(), + connect: vi.fn(), disconnect: vi.fn(), onDeepLink: vi.fn(), deep: null as ((e: DeepLinkAction) => void) | null, pings: new Map(), + profiles: [ + { id: 'p1', name: 'Profile A', source: 'manual', nodes: [{id:'n1',name:'Node A',protocol:'vless',server:'198.51.100.10',port:443}], updatedAt:'2026-01-01T00:00:00Z' }, + { id: 'p2', name: 'Profile B', source: 'manual', nodes: [{id:'n2',name:'Node B',protocol:'vless',server:'198.51.100.11',port:443}], updatedAt:'2026-01-01T00:00:00Z' }, + ] +})); +vi.mock('./state/useTenebra.ts', () => ({ + useTenebra: () => ({ ready: true, state: {state:'idle',daemon_version:'0.5.11',crash_reports_asked:true}, profiles: m.profiles, + traffic: {up:0,down:0,upRate:0,downRate:0},logs:[],attempts:null,pickProgress:null, + connect:m.connect,disconnect:m.disconnect,refreshProfiles:m.refreshProfiles }), +})); +vi.mock('./api/index.ts', () => ({ + api: {checkNodes:m.checkNodes, importSubscription:m.importSubscription}, + onDeepLink: m.onDeepLink, + onTrayConnect: vi.fn(async () => () => {}), onTrayShow: vi.fn(async () => () => {}), + takeLaunchDeepLinks: vi.fn(async () => []), +})); +vi.mock('./lib/useNodePings.ts', () => ({ + useNodePings: () => ({results:m.pings,pinging:false,refresh:()=>{}}), +})); +vi.mock('./lib/useUpdateCheck.ts', () => ({ + useUpdateCheck: () => ({available:m.updateAvailable,stalled:false,confirming:m.updateConfirm,installing:false,deferred:false,progress:null,install:vi.fn(),dismiss:vi.fn(),cancelInstall:vi.fn(),confirmInstall:m.confirmUpdate}), +})); +beforeEach(() => { + localStorage.clear(); + m.deep = null; + m.updateAvailable = null; m.updateConfirm = false; + m.checkNodes.mockResolvedValue({best:"",results:[]}); + m.importSubscription.mockResolvedValue({name:"Imported profile"}); + m.refreshProfiles.mockResolvedValue(undefined); + m.pings = new Map(); + m.onDeepLink.mockImplementation(async (handler) => { m.deep = handler; return () => {}; }); + m.connect.mockResolvedValue({state:'connecting'}); +}); + +it('keeps failed ping unknown and permits a deliberate manual selection', async () => { + m.pings.set('n1',{node:'n1',ok:false,rttMs:0}); + renderWithProviders(createElement(App)); + await screen.findAllByText('Node A'); + const row=screen.getByText('Node A',{selector:'.srv-node-code'}).closest('.srv-row')!; + expect(row).toHaveAttribute('tabindex','0'); + expect(row).toHaveClass('is-dead'); + expect(document.querySelector('.cur-rtt')).toBeNull(); + expect(document.querySelectorAll('.cur-meta .ping-scale-bar.on.good')).toHaveLength(0); +}); +afterEach(() => cleanup()); + +it.each(['0', '1'])('keeps first launch focused on a single subscription task in mode %s', async (mode) => { + localStorage.setItem('tenebra.simpleMode', mode); + const saved = m.profiles; + m.profiles = []; + try { + renderWithProviders(createElement(App)); + await act(async () => {}); + expect(screen.getByRole('textbox', {name:/subscription link/i})).toBeInTheDocument(); + expect(document.querySelector('.srv-add')).toBeNull(); + expect(document.querySelector('.connect-btn')).toBeNull(); + expect(document.querySelector('.simple-btn')).toBeNull(); + } finally { m.profiles = saved; } +}); + +it('offers the same TUN conflict confirmation from a profile card', async () => { + m.connect.mockRejectedValue(new Error('another VPN owns the default route')); + renderWithProviders(createElement(App)); + await screen.findAllByText('Node A'); + fireEvent.click(document.querySelector('.srv-add')!); + const card = screen.getByRole('heading', {name:'Profile A'}).closest('li')!; + fireEvent.click(within(card).getByRole('button', {name:/nodes/i})); + fireEvent.click(within(card).getByRole('button', {name:'Connect'})); + const prompt = await screen.findByRole('alertdialog'); + await act(async () => fireEvent.click(within(prompt).getByRole('button', {name:/cancel/i}))); + expect(m.connect).toHaveBeenCalledTimes(1); +}); + +it('clears profile A node when selecting profile B from its card', async () => { + renderWithProviders(createElement(App)); + await screen.findAllByText('Node A'); + fireEvent.click(screen.getByText('Node A', {selector:'.srv-node-code'})); + const add = document.querySelector('.srv-add'); + if (add) fireEvent.click(add); else fireEvent.click(screen.getByRole('button', {name:/subscription/i})); + const cardB = screen.getByRole('heading', {name:'Profile B'}).closest('li')!; + fireEvent.click(within(cardB).getByRole('button', {name:/set active/i})); + fireEvent.click(document.querySelector('.overlay-close')!); + fireEvent.click(document.querySelector('.connect-btn')!); + await waitFor(() => expect(m.connect).toHaveBeenCalledTimes(1)); + expect(m.connect.mock.calls[0].slice(0,2)).toEqual(['p2',undefined]); +}); + +it('shows and cancels a connect deep link in simple mode', async () => { + localStorage.setItem('tenebra.simpleMode','1'); + renderWithProviders(createElement(App)); + await waitFor(() => expect(m.deep).toBeTypeOf('function')); + act(() => m.deep!({action:'connect',profile:'p1'})); + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + expect(m.connect).not.toHaveBeenCalled(); + fireEvent.click(within(screen.getByRole('alertdialog')).getByRole('button', {name: /not now/i})); + expect(m.connect).not.toHaveBeenCalled(); +}); + + + +it('imports a received subscription link without leaving simple mode', async () => { + localStorage.setItem('tenebra.simpleMode','1'); + renderWithProviders(createElement(App)); + await waitFor(() => expect(m.deep).toBeTypeOf('function')); + act(() => m.deep!({action:'import',url:'https://example.invalid/sub'})); + const modal = await screen.findByRole('dialog', {name:'Import'}); + expect(within(modal).getByDisplayValue('https://example.invalid/sub')).toBeInTheDocument(); + fireEvent.change(within(modal).getByRole('textbox', {name:'Name'}), {target:{value:'Imported profile'}}); + fireEvent.click(within(modal).getByRole('button', {name:'Import'})); + await waitFor(() => expect(m.refreshProfiles).toHaveBeenCalledTimes(1)); + expect(m.importSubscription).toHaveBeenCalledWith('https://example.invalid/sub','Imported profile'); + expect(document.querySelector('.app--simple')).toBeInTheDocument(); +}); + +it('shows the update confirmation and acts only on its explicit approval in simple mode', async () => { + localStorage.setItem('tenebra.simpleMode','1'); + m.updateAvailable = '9.9.9'; m.updateConfirm = true; + renderWithProviders(createElement(App)); + const modal = await screen.findByRole('alertdialog'); + expect(document.querySelector('.update-banner')).toBeInTheDocument(); + expect(m.confirmUpdate).not.toHaveBeenCalled(); + fireEvent.click(within(modal).getByRole('button', {name:/install now/i})); + expect(m.confirmUpdate).toHaveBeenCalledTimes(1); +}); + +it('distinguishes a failed prober from no usable node and still tries the connection', async () => { + m.checkNodes.mockRejectedValue(new Error('probe process unavailable')); + renderWithProviders(createElement(App)); + await screen.findAllByText('Node A'); + fireEvent.click(document.querySelector('.connect-btn')!); + await waitFor(() => expect(m.connect).toHaveBeenCalledTimes(1)); + expect(screen.getByText(/The node check could not run/)).toBeInTheDocument(); + expect(screen.queryByText(/No node carried traffic/)).toBeNull(); +}); diff --git a/ui-desktop/src/App.bootstrap.test.tsx b/ui-desktop/src/App.bootstrap.test.tsx index fe6ee7fb..1ae1a410 100644 --- a/ui-desktop/src/App.bootstrap.test.tsx +++ b/ui-desktop/src/App.bootstrap.test.tsx @@ -189,13 +189,13 @@ describe("App bootstrap", () => { }); describe("primary button", () => { - it("is disabled while there is no profile to connect to", async () => { + it("shows the import task before offering a connection", async () => { renderWithProviders(); // handlePrimary has no branch for a null profile, so a live-looking // button here is a button that silently eats the click. SimpleView // already disables its own for exactly this reason. - await waitFor(() => expect(primaryButton()).toBeDisabled()); + await waitFor(() => expect(screen.queryByRole("button", { name: /^(▶\s*)?Connect$/ })).toBeNull()); }); it("is live once a profile has loaded", async () => { diff --git a/ui-desktop/src/App.simple.test.tsx b/ui-desktop/src/App.simple.test.tsx index 2226ce34..18a92c19 100644 --- a/ui-desktop/src/App.simple.test.tsx +++ b/ui-desktop/src/App.simple.test.tsx @@ -227,10 +227,10 @@ describe("App simple mode", () => { // instantly and then carries nothing is exactly what auto-select used to pick. it("connects to the node the check picked, not to auto", async () => { mocks.checkNodes.mockResolvedValue({ - best: "n-alive", + best: "n1", results: [ { - node: "n-alive", + node: "n1", targets: [ { target: "https://a.example/204", stage: "ok", rttMs: 120 }, ], @@ -244,7 +244,7 @@ describe("App simple mode", () => { await waitFor(() => expect(mocks.connect).toHaveBeenCalledTimes(1)); expect(mocks.checkNodes).toHaveBeenCalledWith("p1"); - expect(mocks.connect.mock.calls[0][1]).toBe("n-alive"); + expect(mocks.connect.mock.calls[0][1]).toBe("n1"); }); // With nothing usable the connect must still be attempted — the core's diff --git a/ui-desktop/src/App.tsx b/ui-desktop/src/App.tsx index fa549c4a..9ad0a994 100644 --- a/ui-desktop/src/App.tsx +++ b/ui-desktop/src/App.tsx @@ -1,3 +1,5 @@ +import { ModalLayer } from "./components/ModalLayer"; +import { ConnectionError } from "./components/ConnectionError"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { UnlistenFn } from "@tauri-apps/api/event"; @@ -27,7 +29,7 @@ import { useTenebra } from "./state/useTenebra"; import { useI18n } from "./i18n/I18nContext"; import { describeCoreError, isTunConflict } from "./i18n/strings"; import { pushToast } from "./lib/toast"; -import type { RoutingMode } from "./api"; +import type { RoutingMode, State } from "./api"; import { api, onDeepLink, @@ -85,6 +87,8 @@ export function App() { const [query, setQuery] = useState(""); const [overlay, setOverlay] = useState(null); const [busy, setBusy] = useState(false); + const [connectError, setConnectError] = useState(null); + const connectingRef = useRef(false); /** * The bypass, as the core reports it — never as this session remembers it. @@ -194,7 +198,7 @@ export function App() { // state (the live `phase`): a relaunch would drop an active VPN, so // auto-install waits for the tunnel to go down and a manual install while it // is up asks first. - const update = useUpdateCheck(phase); + const update = useUpdateCheck(phase, tenebra.ready && !tenebra.coreError); // Daemon build vs app build, latched from state snapshots. A skew means the // privileged daemon predates this UI — nothing the app installs itself @@ -217,7 +221,9 @@ export function App() { // nothing. The crash path above needs both a consent and a crash file, which // together describe almost none of the ways this app actually disappoints // someone — a bypass that stopped carrying video leaves neither. - const problem = useProblemReport(state.daemon_version, tenebra.logs); + const problem = useProblemReport(state.daemon_version, connectError + ? [...tenebra.logs, { id: -1, at: new Date(), level: "error", msg: `connect request: ${connectError}` }] + : tenebra.logs); // The core-owned controls the shell drives directly. Their drawn position is // the state the daemon echoes back, so a refused command leaves the control @@ -256,6 +262,10 @@ export function App() { () => profiles.find((p) => p.id === selectedProfileId) ?? null, [profiles, selectedProfileId], ); + useEffect(() => { + if (selectedNodeId && !selectedProfile?.nodes.some((n) => n.id === selectedNodeId)) setSelectedNodeId(""); + }, [selectedProfile, selectedNodeId]); + const connectedProfile = useMemo( () => profiles.find((p) => p.id === state.profile) ?? null, [profiles, state.profile], @@ -270,7 +280,7 @@ export function App() { const nodeCheck = useNodeCheck(); // And, once connected, whether the three things the user came for actually // work: video, voice, game latency. - const services = useServiceChecks(phase); + const services = useServiceChecks(phase, `${state.profile ?? ""}:${state.node ?? ""}`); // The one thing this app says first. Video failing its check twice running is // worth interrupting over: it is what most people connected for, and the last // time it broke for everyone nobody said a word for four days. @@ -291,12 +301,13 @@ export function App() { city: loc.label, region: loc.region, protocol: n.protocol, - rttMs: probe ? probe.rttMs : null, - dead: probe ? !probe.ok : false, + rttMs: probe?.ok && !pings.stale ? probe.rttMs : null, + stale: !!probe && pings.stale, + dead: probe && !pings.stale ? !probe.ok : false, insecure: n.insecure ?? false, }; }), - [nodes, pings.results], + [nodes, pings.results, pings.stale], ); // Lowest-ping live node, used as the auto target and the idle "current node". @@ -319,7 +330,8 @@ export function App() { : (selectedProfile?.nodes.find((n) => n.id === targetNodeId) ?? null); const liveNodeId = connected ? state.node : targetNodeId; - const livePing = liveNodeId ? pings.results.get(liveNodeId)?.rttMs : undefined; + const liveProbe = liveNodeId ? pings.results.get(liveNodeId) : undefined; + const livePing = liveProbe?.ok && !pings.stale ? liveProbe.rttMs : undefined; // Confirm the App-level actions the user takes (reaching connected, arming the // kill switch, changing routing) with a toast. The initial status load is @@ -346,87 +358,63 @@ export function App() { phase === "health_reconnecting" || selectedProfileId !== null; + // All entrances share validation, refusal reporting and the one override prompt. + const connectSafely = useCallback(async (profileId: string, node?: string, auto?: boolean): Promise => { + if (connectingRef.current) return null; + connectingRef.current = true; + setBusy(true); + setConnectError(null); + try { + const profile = profiles.find((p) => p.id === profileId); + if (!profile) throw new Error("profile not found"); + if (node && !profile.nodes.some((n) => n.id === node)) throw new Error("node not found in profile"); + try { + return await tenebra.connect(profileId, node, auto); + } catch (e) { + if (!isTunConflict(e)) throw e; + pushToast(describeCoreError(e, t)); + if (!(await askTunOverride())) return null; + return await tenebra.connect(profileId, node, auto, true); + } + } catch (e) { + setConnectError(e instanceof Error ? e.message : String(e)); + pushToast(describeCoreError(e, t)); + return null; + } finally { + connectingRef.current = false; + setBusy(false); + } + }, [profiles, tenebra, askTunOverride, t]); + const handlePrimary = useCallback(() => { if (busy) return; setBusy(true); + setConnectError(null); void (async () => { try { - if ( - connected || - phase === "connecting" || - phase === "health_reconnecting" - ) { - // A click during an auto-recovery aborts it too, rather than racing a - // fresh connect against the watchdog's in-flight reconnect. + if (connected || phase === "connecting" || phase === "health_reconnecting") { await tenebra.disconnect(); } else if (selectedProfileId) { - // No explicit node → let the core choose. The persisted "auto-select - // fastest" preference decides between ping-ranked and protocol-fallback - // order; it is read fresh (like autoconnect) so a Settings toggle takes - // effect on the next connect without prop-threading. When a node is - // selected, auto is moot — the core honours the explicit exit. - let node = selectedNodeId || undefined; + // Validate against the current profile even if a refresh removed the pin. + let node = nodes.some((n) => n.id === selectedNodeId) ? selectedNodeId : undefined; let auto = node ? undefined : getAutoFastest(); - - // Before letting latency decide, find out what actually carries - // traffic. A node whose proxy handshake has stopped answering still - // completes a TCP dial instantly, so it reads as the *fastest* node and - // wins a latency-ranked pick while every request through it hangs — - // which is precisely how a working-looking connect left the user with - // no internet. Measuring first costs seconds; picking blind costs the - // session. if (!node) { - const best = await nodeCheck.run(selectedProfileId); - if (best) { - node = best; + const outcome = await nodeCheck.run(selectedProfileId); + if (outcome.kind === "checked" && outcome.best) { + node = outcome.best; auto = undefined; } else { - // Nothing passed. Say so — and still try: the core's fallback walk - // tries nodes in turn and may get through where a one-shot probe - // did not, and refusing to connect at all would be a worse answer - // than a slow one. - pushToast(t.servers.noneUsable); + pushToast(outcome.kind === "failed" ? t.errors.probeFailed : t.servers.noneUsable); } } - try { - await tenebra.connect(selectedProfileId, node, auto); - } catch (e) { - // The guard refuses to raise our tun while another VPN owns the - // default route. That refusal is correct by default — two tunnels - // routing everything leave the machine offline — but it must not be - // a dead end: the user is the only one who knows whether the other - // tunnel overlaps, so ask, and honour the answer for this connect - // only. - if (!isTunConflict(e)) throw e; - // Name the refusal before asking: the prompt is a yes/no, this line - // is the reason and the fix (turn the other tunnel off). - pushToast(describeCoreError(e, t)); - // Declining is an answer, not a second failure. Rethrowing here sent - // the same error to the outer catch, which said the very same line - // again — one refusal, two identical toasts. - if (!(await askTunOverride())) return; - await tenebra.connect(selectedProfileId, node, auto, true); - } + await connectSafely(selectedProfileId, node, auto); } } catch (e) { - // Say why nothing happened. A refused connect leaves the button exactly - // where it was, and swallowing the reason (the old behaviour) turned - // every refusal — a guard, a vanished node, a core that will not answer — - // into "the button does not work", which is unanswerable from the outside. + setConnectError(e instanceof Error ? e.message : String(e)); pushToast(describeCoreError(e, t)); - } finally { - setBusy(false); - } + } finally { setBusy(false); } })(); - }, [ - busy, - connected, - phase, - tenebra, - selectedProfileId, - selectedNodeId, - askTunOverride, - ]); + }, [busy, connected, phase, tenebra, selectedProfileId, selectedNodeId, nodes, nodeCheck, connectSafely, t]); const handleSelectNode = useCallback( (id: string) => { @@ -439,9 +427,9 @@ export function App() { // nothing was reconnected, `connecting` means it is coming back up. Say so, // rather than showing the same "reconnecting" for both and teaching the user // that changing exits costs them their session. - void tenebra - .connect(selectedProfileId, id) + void connectSafely(selectedProfileId, id) .then((st) => { + if (!st) return; const name = selectedProfile?.nodes.find((n) => n.id === id)?.name ?? id; pushToast( @@ -453,7 +441,7 @@ export function App() { }) .catch(() => {}); }, - [connected, selectedProfileId, selectedProfile, tenebra, t], + [connected, selectedProfileId, selectedProfile, connectSafely, t], ); const handleSelectProfile = useCallback((id: string) => { @@ -467,11 +455,10 @@ export function App() { const handleSelectAuto = useCallback(() => { setSelectedNodeId(""); if (connected && selectedProfileId) { - void tenebra - .connect(selectedProfileId, undefined, getAutoFastest()) + void connectSafely(selectedProfileId, undefined, getAutoFastest()) .catch(() => {}); } - }, [connected, selectedProfileId, tenebra]); + }, [connected, selectedProfileId, connectSafely]); const handleSetRouting = useCallback( (mode: RoutingMode) => { @@ -632,13 +619,12 @@ export function App() { // profile appears, honouring the fastest-node preference like a manual connect. useEffect(() => { if (!pendingConnect || !tenebra.ready) return; - if (!profiles.some((p) => p.id === pendingConnect)) return; const id = pendingConnect; setPendingConnect(null); setSelectedProfileId(id); setSelectedNodeId(""); - void tenebra.connect(id, undefined, getAutoFastest()).catch(() => {}); - }, [pendingConnect, tenebra, profiles]); + void connectSafely(id, undefined, getAutoFastest()); + }, [pendingConnect, tenebra, profiles, connectSafely]); // Deep links (tenebra://). Links the app was launched with (cold start) are // drained once on mount; links opened while it runs arrive as events. Both go @@ -712,42 +698,9 @@ export function App() { // Simple mode: one calm screen instead of the full shell. It reads the same // connection state and shares the same actions, so the two never disagree. The // eclipse easter egg still rides along; the console/toast layers do too. - if (simpleMode) { - return ( -
- - {tunConflictPrompt} - {problemReport} - - -
- ); - } - return ( -
- +
+ {!simpleMode && } {connected && killSwitch && (
@@ -755,7 +708,7 @@ export function App() {
)} - {tenebra.coreError && ( + {tenebra.coreError && !simpleMode && ( // The core never answered, so nothing on this screen is backed by // anything: no profiles, no real state, every action doomed. Say it in // the banner strip the update and skew notices already use (no new @@ -771,6 +724,7 @@ export function App() { installing={update.installing} deferred={update.deferred} progress={update.progress} + waitingForStatus={!tenebra.ready || !!tenebra.coreError} onInstall={update.install} onDismiss={update.dismiss} /> @@ -813,6 +767,31 @@ export function App() { /> )} + {(connectError || (phase === "error" && state.error)) && } + {simpleMode ? ( + + ) : (<> {nudge} {/* The one setup step lives on the main screen, not behind a menu: what a @@ -824,7 +803,7 @@ export function App() { onSubscribe={handleSimpleSubscribe} /> -
+ {(profiles.length > 0 || connected || phase === "connecting" || phase === "health_reconnecting") &&
setOverlay("profiles")} pinging={pings.pinging} /> -
+
} + )} + {overlayShown.value && ( -
setOverlay(null)} className={`overlay${overlayShown.leaving ? " is-leaving" : ""}`} role="dialog" aria-modal="true" + aria-label={overlayShown.value === "profiles" ? t.profiles.title : overlayShown.value === "settings" ? t.settings.title : t.logs.title} onClick={(e) => { if (e.target === e.currentTarget) setOverlay(null); }} @@ -908,7 +890,8 @@ export function App() { setOverlay(null)} @@ -920,7 +903,7 @@ export function App() { {overlayShown.value === "logs" && }
-
+ )} {connectRequestShown.value && ( diff --git a/ui-desktop/src/components/ConnectionError.test.tsx b/ui-desktop/src/components/ConnectionError.test.tsx new file mode 100644 index 00000000..01633494 --- /dev/null +++ b/ui-desktop/src/components/ConnectionError.test.tsx @@ -0,0 +1,15 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { expect, it, vi } from "vitest"; +import { ConnectionError } from "./ConnectionError"; +import { renderWithProviders } from "../test/renderWithProviders"; + +it("explains protocol failure in Russian and preserves the technical detail before reporting", () => { + const report = vi.fn(); + const error = "all protocols failed: vless handshake rejected"; + renderWithProviders(, { lang: "ru" }); + expect(screen.getByRole("alert")).toHaveTextContent("Обновите подписку"); + expect(screen.getByText(error)).toBeInTheDocument(); + expect(report).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button")); + expect(report).toHaveBeenCalledTimes(1); +}); diff --git a/ui-desktop/src/components/ConnectionError.tsx b/ui-desktop/src/components/ConnectionError.tsx new file mode 100644 index 00000000..6e58418a --- /dev/null +++ b/ui-desktop/src/components/ConnectionError.tsx @@ -0,0 +1,20 @@ +import { useI18n } from "../i18n/I18nContext"; +import { describeCoreError } from "../i18n/strings"; + +export function ConnectionError({ error, onReport }: { error: string; onReport: () => void }) { + const { t } = useI18n(); + const lower = error.toLowerCase(); + const explanation = lower.includes("all protocols failed") || lower.includes("handshake") + ? t.errors.protocolFailed + : lower.includes("not found") || lower.includes("no nodes") + ? t.errors.selectionFailed + : /pipe|ipc|core.*(down|unreachable)|service|timeout/.test(lower) + ? t.errors.serviceFailed + : describeCoreError(error, t) !== t.daemon.commandFailed + ? describeCoreError(error, t) : t.errors.connectFailed; + return
+

{explanation}

+
{t.errors.details}
{error}
+ +
; +} diff --git a/ui-desktop/src/components/CrashReportModal.tsx b/ui-desktop/src/components/CrashReportModal.tsx index 768cfbac..8b6ab408 100644 --- a/ui-desktop/src/components/CrashReportModal.tsx +++ b/ui-desktop/src/components/CrashReportModal.tsx @@ -1,3 +1,4 @@ +import { ModalLayer } from "./ModalLayer"; import { useEffect, useRef, useState } from "react"; import { useI18n } from "../i18n/I18nContext"; @@ -49,7 +50,7 @@ export function CrashReportModal({ } return ( -
{ if (e.target === e.currentTarget) onClose(); @@ -79,6 +80,6 @@ export function CrashReportModal({
- + ); } diff --git a/ui-desktop/src/components/DeepLinkConfirm.test.tsx b/ui-desktop/src/components/DeepLinkConfirm.test.tsx index 09d55bec..8687844e 100644 --- a/ui-desktop/src/components/DeepLinkConfirm.test.tsx +++ b/ui-desktop/src/components/DeepLinkConfirm.test.tsx @@ -73,7 +73,7 @@ describe("DeepLinkConfirm", () => { 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/DeepLinkConfirm.tsx b/ui-desktop/src/components/DeepLinkConfirm.tsx index 62025c24..08cb9f62 100644 --- a/ui-desktop/src/components/DeepLinkConfirm.tsx +++ b/ui-desktop/src/components/DeepLinkConfirm.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 DeepLinkConfirm({ }, [onCancel]); return ( -
{ // A click on the scrim (not the card) declines — the safe default. @@ -86,6 +87,6 @@ export function DeepLinkConfirm({
- + ); } diff --git a/ui-desktop/src/components/ModalLayer.test.tsx b/ui-desktop/src/components/ModalLayer.test.tsx new file mode 100644 index 00000000..2a48397f --- /dev/null +++ b/ui-desktop/src/components/ModalLayer.test.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import { fireEvent, screen } from "@testing-library/react"; +import { expect, it } from "vitest"; +import { UpdateConfirm } from "./UpdateConfirm"; +import { ModalLayer } from "./ModalLayer"; +import { renderWithProviders } from "../test/renderWithProviders"; + +it("enters the dialog, contains Tab, makes the background inert and restores focus", () => { + function Example() { + const [open, setOpen] = useState(false); + return <> + {open && {}} onCancel={() => setOpen(false)} />}; + } + const { container } = renderWithProviders(); + const opener = screen.getByRole("button", { name: "Open update" }); + opener.focus(); + fireEvent.click(opener); + const cancel = screen.getByRole("button", { name: "Cancel" }); + const install = screen.getByRole("button", { name: /install now/i }); + expect(cancel).toHaveFocus(); + expect(container).toHaveAttribute("inert"); + fireEvent.keyDown(cancel, { key: "Tab", shiftKey: true }); + expect(install).toHaveFocus(); + fireEvent.keyDown(install, { key: "Tab" }); + expect(cancel).toHaveFocus(); + fireEvent.keyDown(cancel, { key: "Escape" }); + expect(screen.queryByRole("alertdialog")).toBeNull(); + expect(container).not.toHaveAttribute("inert"); + expect(opener).toHaveFocus(); +}); + +it("keeps the parent modal isolated while a child opens and closes", () => { + function Example() { + const [child, setChild] = useState(false); + return {}} role="dialog" aria-label="Parent"> + + {child && setChild(false)} role="dialog" aria-label="Child"> + + } + ; + } + const { container, unmount } = renderWithProviders(); + const opener = screen.getByRole("button", { name: "Open child" }); + opener.focus(); + fireEvent.click(opener); + expect(screen.getByRole("dialog", { name: "Parent" })).toHaveAttribute("inert"); + fireEvent.click(screen.getByRole("button", { name: "Close child" })); + expect(container).toHaveAttribute("inert"); + expect(opener).toHaveFocus(); + unmount(); + expect(container).not.toHaveAttribute("inert"); +}); diff --git a/ui-desktop/src/components/ModalLayer.tsx b/ui-desktop/src/components/ModalLayer.tsx new file mode 100644 index 00000000..e5c023b1 --- /dev/null +++ b/ui-desktop/src/components/ModalLayer.tsx @@ -0,0 +1,67 @@ +import { useLayoutEffect, useRef, type HTMLAttributes } from "react"; +import { createPortal } from "react-dom"; + +const layers: HTMLElement[] = []; +const locks = new Map(); +const focusable = 'button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), summary, [tabindex]:not([tabindex="-1"])'; + +/** One focus/keyboard boundary for every modal, including stacked import flows. */ +export function ModalLayer({ onClose, children, ...props }: HTMLAttributes & { onClose: () => void }) { + const root = useRef(null); + const close = useRef(onClose); + close.current = onClose; + useLayoutEffect(() => { + const element = root.current!; + const previous = document.activeElement as HTMLElement | null; + layers.push(element); + const background = [...document.body.children].filter((el) => el !== element); + for (const el of background) { + const lock = locks.get(el) ?? { count: 0, inert: el.hasAttribute("inert") }; + lock.count += 1; + locks.set(el, lock); + el.setAttribute("inert", ""); + } + const controls = () => [...element.querySelectorAll(focusable)] + .filter((el) => !el.closest('[inert], [hidden], [aria-hidden="true"]')); + const focusFirst = () => (controls()[0] ?? element).focus(); + focusFirst(); + const isTop = () => layers[layers.length - 1] === element; + const onFocus = (event: FocusEvent) => { + if (isTop() && !element.contains(event.target as Node)) focusFirst(); + }; + const onKey = (event: KeyboardEvent) => { + if (!isTop()) return; + if (event.key === "Escape") { + event.preventDefault(); + event.stopImmediatePropagation(); + close.current(); + } else if (event.key === "Tab") { + const list = controls(); + const first = list[0]; + const last = list[list.length - 1]; + if (!first || !element.contains(document.activeElement) || + (event.shiftKey && document.activeElement === first) || + (!event.shiftKey && document.activeElement === last)) { + event.preventDefault(); + (event.shiftKey ? last ?? element : first ?? element).focus(); + } + } + }; + document.addEventListener("keydown", onKey, true); + document.addEventListener("focusin", onFocus); + return () => { + document.removeEventListener("keydown", onKey, true); + document.removeEventListener("focusin", onFocus); + layers.splice(layers.indexOf(element), 1); + for (const el of background) { + const lock = locks.get(el)!; + if (--lock.count === 0) { + if (!lock.inert) el.removeAttribute("inert"); + locks.delete(el); + } + } + if (previous?.isConnected && !previous.closest("[inert]")) previous.focus(); + }; + }, []); + return createPortal(
{children}
, document.body); +} diff --git a/ui-desktop/src/components/ProblemReportModal.test.tsx b/ui-desktop/src/components/ProblemReportModal.test.tsx index eb048290..720a6fec 100644 --- a/ui-desktop/src/components/ProblemReportModal.test.tsx +++ b/ui-desktop/src/components/ProblemReportModal.test.tsx @@ -112,12 +112,12 @@ describe("ProblemReportModal", () => { }); it("closes on Escape and on a scrim click, like the app's other overlays", () => { - const { props, container } = setup(); + const { props } = setup(); fireEvent.keyDown(window, { key: "Escape" }); expect(props.onClose).toHaveBeenCalledTimes(1); - const scrim = container.querySelector(".prof-modal-scrim"); + const scrim = document.querySelector(".prof-modal-scrim"); fireEvent.mouseDown(scrim as Element); expect(props.onClose).toHaveBeenCalledTimes(2); }); diff --git a/ui-desktop/src/components/ProblemReportModal.tsx b/ui-desktop/src/components/ProblemReportModal.tsx index 827d1437..572e7f1d 100644 --- a/ui-desktop/src/components/ProblemReportModal.tsx +++ b/ui-desktop/src/components/ProblemReportModal.tsx @@ -1,3 +1,4 @@ +import { ModalLayer } from "./ModalLayer"; import { useEffect, useRef, useState } from "react"; import { useI18n } from "../i18n/I18nContext"; @@ -68,7 +69,7 @@ export function ProblemReportModal({ } return ( -
{ if (e.target === e.currentTarget) onClose(); @@ -137,6 +138,6 @@ export function ProblemReportModal({
- + ); } diff --git a/ui-desktop/src/components/ServerList.test.tsx b/ui-desktop/src/components/ServerList.test.tsx index 76d09d74..2db93eca 100644 --- a/ui-desktop/src/components/ServerList.test.tsx +++ b/ui-desktop/src/components/ServerList.test.tsx @@ -138,15 +138,15 @@ describe("ServerList", () => { expect(onQuery).toHaveBeenCalledWith("x"); }); - it("marks a dead row aria-disabled and does not select it on click", async () => { + it("keeps failed TCP results visible and permits manual selection", async () => { const onSelectNode = vi.fn(); const user = userEvent.setup(); renderWithProviders(); const deadRow = screen.getByText("US-NYC-01").closest('[role="button"]')!; - expect(deadRow).toHaveAttribute("aria-disabled", "true"); + expect(deadRow).toHaveAttribute("tabindex", "0"); await user.click(deadRow); - expect(onSelectNode).not.toHaveBeenCalled(); + expect(onSelectNode).toHaveBeenCalledWith("n-nyc"); // A live row does select. const liveRow = screen.getByText("DE-FRA-01").closest('[role="button"]')!; diff --git a/ui-desktop/src/components/ServerList.tsx b/ui-desktop/src/components/ServerList.tsx index f787517d..d2128990 100644 --- a/ui-desktop/src/components/ServerList.tsx +++ b/ui-desktop/src/components/ServerList.tsx @@ -19,6 +19,7 @@ export interface ServerRow { rttMs: number | null; /** Probe came back failed. */ dead: boolean; + stale?: boolean; /** TLS certificate verification is off (skip-cert-verify) on this node. */ insecure: boolean; } @@ -352,11 +353,11 @@ export const ServerList = forwardRef( className={`srv-row${active ? " active" : ""}${s.dead ? " is-dead" : ""}`} style={{ animationDelay: staggerDelay(i) }} role="button" - tabIndex={s.dead ? -1 : 0} - aria-disabled={s.dead} - onClick={() => !s.dead && onSelectNode(s.id)} + tabIndex={0} + title={s.dead ? t.servers.manualAfterPing : undefined} + onClick={() => onSelectNode(s.id)} onKeyDown={(e) => { - if (!s.dead && (e.key === "Enter" || e.key === " ")) { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelectNode(s.id); } @@ -390,7 +391,7 @@ export const ServerList = forwardRef( )}
- {s.dead + {s.stale ? t.servers.pingStale : s.dead ? t.servers.down : s.rttMs !== null ? `${s.rttMs} ${t.units.ms}` diff --git a/ui-desktop/src/components/SimpleSetup.test.tsx b/ui-desktop/src/components/SimpleSetup.test.tsx new file mode 100644 index 00000000..45f81097 --- /dev/null +++ b/ui-desktop/src/components/SimpleSetup.test.tsx @@ -0,0 +1,14 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { expect, it, vi } from "vitest"; +import { SimpleSetup } from "./SimpleSetup"; +import { renderWithProviders } from "../test/renderWithProviders"; + +it("explains a subscription failure without exposing its private URL", async () => { + const subscribe = vi.fn().mockRejectedValue(new Error('Get "https://example.invalid/private-token": dial tcp: refused')); + renderWithProviders(, { lang: "ru" }); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "https://example.invalid/private-token" } }); + fireEvent.click(screen.getByRole("button")); + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Не удалось скачать подписку"); + expect(alert).not.toHaveTextContent("private-token"); +}); diff --git a/ui-desktop/src/components/SimpleSetup.tsx b/ui-desktop/src/components/SimpleSetup.tsx index e6dcee11..7af58f6b 100644 --- a/ui-desktop/src/components/SimpleSetup.tsx +++ b/ui-desktop/src/components/SimpleSetup.tsx @@ -1,3 +1,4 @@ +import { importErrorMessage } from "../lib/importError"; import { useState } from "react"; import { useI18n } from "../i18n/I18nContext"; @@ -40,7 +41,7 @@ export function SimpleSetup({ hasProfile, onSubscribe }: SimpleSetupProps) { try { await fn(); } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + setError(importErrorMessage(e, t)); } finally { setBusy(false); } @@ -71,10 +72,11 @@ export function SimpleSetup({ hasProfile, onSubscribe }: SimpleSetupProps) {
diff --git a/ui-desktop/src/components/SimpleView.test.tsx b/ui-desktop/src/components/SimpleView.test.tsx index 965f561f..2edd35b1 100644 --- a/ui-desktop/src/components/SimpleView.test.tsx +++ b/ui-desktop/src/components/SimpleView.test.tsx @@ -54,8 +54,8 @@ describe("SimpleView", () => { expect(screen.getByText("You're protected · AMS-01")).toBeInTheDocument(); }); - it("shows Abort while connecting", () => { - setup({ phase: "connecting" }); + it.each(["connecting", "health_reconnecting"] as const)("shows Abort during %s", (phase) => { + setup({ phase }); expect(screen.getByRole("button", { name: "ABORT" })).toBeInTheDocument(); }); @@ -70,7 +70,7 @@ describe("SimpleView", () => { // instead of telling them to go and import one elsewhere — the setup step // IS the empty state. setup({ profiles: [], nodes: [] }); - expect(screen.getByRole("button", { name: "Connect" })).toBeDisabled(); + expect(screen.queryByRole("button", { name: "Connect" })).toBeNull(); expect( screen.getByLabelText("Paste your subscription link"), ).toBeInTheDocument(); diff --git a/ui-desktop/src/components/SimpleView.tsx b/ui-desktop/src/components/SimpleView.tsx index fd3e7a1f..a83f3c8c 100644 --- a/ui-desktop/src/components/SimpleView.tsx +++ b/ui-desktop/src/components/SimpleView.tsx @@ -105,7 +105,7 @@ export function SimpleView({ } const connected = phase === "connected"; - const pending = phase === "connecting"; + const pending = phase === "connecting" || phase === "health_reconnecting"; const hasProfile = profiles.length > 0; const buttonLabel = connected @@ -166,14 +166,14 @@ export function SimpleView({

)} - + } {/* 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>(new Map()); const [pinging, setPinging] = useState(false); + const [stale, setStale] = useState(false); + const [error, setError] = useState(null); + const generation = useRef(0); const run = useCallback((id: string | null) => { + const current = ++generation.current; + setError(null); if (!id) { setResults(new Map()); + setPinging(false); + setStale(false); return; } - let cancelled = false; + setStale(true); setPinging(true); - api - .ping(id) + void Promise.resolve().then(() => api.ping(id)) .then((list) => { - if (cancelled) return; + if (current !== generation.current) return; setResults(new Map(list.map((r) => [r.node, r]))); + setStale(false); }) - .catch(() => { - // Surfaced on the log channel; leave prior results in place. + .catch((e: unknown) => { + if (current !== generation.current) return; + setError(e instanceof Error ? e.message : String(e)); }) .finally(() => { - if (!cancelled) setPinging(false); + if (current === generation.current) setPinging(false); }); - return () => { - cancelled = true; - }; }, []); // Re-probe whenever the selected profile changes. Clears first so rows from // the previous profile don't show another profile's latencies. useEffect(() => { setResults(new Map()); - const cancel = run(profileId); - return cancel; + run(profileId); + return () => { generation.current += 1; }; }, [profileId, run]); const refresh = useCallback(() => run(profileId), [run, profileId]); - return { results, pinging, refresh }; + return { results, pinging, stale, error, refresh }; } diff --git a/ui-desktop/src/lib/useServiceChecks.ts b/ui-desktop/src/lib/useServiceChecks.ts index 98e0ede4..d53c2663 100644 --- a/ui-desktop/src/lib/useServiceChecks.ts +++ b/ui-desktop/src/lib/useServiceChecks.ts @@ -27,23 +27,28 @@ export interface ServiceChecksState { * screen. Stale ticks next to a disconnected tunnel are worse than no ticks: * they say "everything works" about a session that no longer exists. */ -export function useServiceChecks(phase: ConnectionState): ServiceChecksState { +export function useServiceChecks(phase: ConnectionState, sessionKey = ""): ServiceChecksState { const [checks, setChecks] = useState([]); const [checking, setChecking] = useState(false); const [runs, setRuns] = useState(0); const inFlight = useRef(false); + const generation = useRef(0); + const phaseRef = useRef(phase); + phaseRef.current = phase; const run = useCallback(() => { - if (inFlight.current) return; + if (inFlight.current || phaseRef.current !== "connected") return; + const current = generation.current; inFlight.current = true; setChecking(true); // Wrapped so a core without the command degrades to "no checks" rather than // throwing into the render path. void Promise.resolve() .then(() => api.checkServices()) - .then((r) => setChecks(r.checks)) - .catch(() => setChecks([])) + .then((r) => { if (current === generation.current) setChecks(r.checks); }) + .catch(() => { if (current === generation.current) setChecks([]); }) .finally(() => { + if (current !== generation.current) return; inFlight.current = false; setChecking(false); setRuns((n) => n + 1); @@ -52,11 +57,19 @@ export function useServiceChecks(phase: ConnectionState): ServiceChecksState { useEffect(() => { if (phase === "connected") { + setChecks([]); + setRuns(0); run(); - return; + } else { + setChecks([]); + setRuns(0); + setChecking(false); } - setChecks([]); - }, [phase, run]); + return () => { + generation.current += 1; + inFlight.current = false; + }; + }, [phase, sessionKey, run]); return { checks, checking, runs, refresh: run }; } diff --git a/ui-desktop/src/lib/useUpdateCheck.test.tsx b/ui-desktop/src/lib/useUpdateCheck.test.tsx index b3b71953..a686bd75 100644 --- a/ui-desktop/src/lib/useUpdateCheck.test.tsx +++ b/ui-desktop/src/lib/useUpdateCheck.test.tsx @@ -55,7 +55,7 @@ describe("useUpdateCheck", () => { vi.mocked(inAppUpdatesSupported).mockResolvedValue(false); vi.mocked(checkForUpdate).mockResolvedValue(fakeUpdate()); - const { result } = renderHook(() => useUpdateCheck("idle")); + const { result } = renderHook(() => useUpdateCheck("idle", true)); await waitFor(() => expect(inAppUpdatesSupported).toHaveBeenCalled()); expect(checkForUpdate).not.toHaveBeenCalled(); @@ -66,7 +66,7 @@ describe("useUpdateCheck", () => { it("surfaces the found version for the banner", async () => { vi.mocked(checkForUpdate).mockResolvedValue(fakeUpdate()); - const { result } = renderHook(() => useUpdateCheck("idle")); + const { result } = renderHook(() => useUpdateCheck("idle", true)); await waitFor(() => expect(result.current.available).toBe("9.9.9")); // Auto-install is off by default, so nothing may install on its own. @@ -76,7 +76,7 @@ describe("useUpdateCheck", () => { it("shows nothing when already on the latest version", async () => { vi.mocked(checkForUpdate).mockResolvedValue(null); - const { result } = renderHook(() => useUpdateCheck("idle")); + const { result } = renderHook(() => useUpdateCheck("idle", true)); await waitFor(() => expect(checkForUpdate).toHaveBeenCalled()); expect(result.current.available).toBeNull(); @@ -85,7 +85,7 @@ describe("useUpdateCheck", () => { it("swallows a failed check so an offline launch stays silent", async () => { vi.mocked(checkForUpdate).mockRejectedValue(new Error("offline")); - const { result } = renderHook(() => useUpdateCheck("idle")); + const { result } = renderHook(() => useUpdateCheck("idle", true)); await waitFor(() => expect(checkForUpdate).toHaveBeenCalled()); expect(result.current.available).toBeNull(); @@ -98,7 +98,7 @@ describe("useUpdateCheck", () => { // StrictMode replays the mount effect (setup → cleanup → setup); the hook // must not fire a second check — with auto-install on that would race two // installs of the same release. - const { result, rerender } = renderHook(() => useUpdateCheck("idle"), { + const { result, rerender } = renderHook(() => useUpdateCheck("idle", true), { wrapper: StrictMode, }); @@ -110,7 +110,7 @@ describe("useUpdateCheck", () => { it("hides the banner on dismiss without persisting anything", async () => { vi.mocked(checkForUpdate).mockResolvedValue(fakeUpdate()); - const { result } = renderHook(() => useUpdateCheck("idle")); + const { result } = renderHook(() => useUpdateCheck("idle", true)); await waitFor(() => expect(result.current.available).toBe("9.9.9")); act(() => result.current.dismiss()); @@ -135,7 +135,7 @@ describe("useUpdateCheck", () => { return new Promise(() => {}); }); - const { result } = renderHook(() => useUpdateCheck("idle")); + const { result } = renderHook(() => useUpdateCheck("idle", true)); await waitFor(() => expect(result.current.available).toBe("9.9.9")); act(() => result.current.install()); @@ -151,7 +151,7 @@ describe("useUpdateCheck", () => { vi.mocked(checkForUpdate).mockResolvedValue(fakeUpdate()); vi.mocked(installUpdate).mockRejectedValue(new Error("disk full")); - const { result } = renderHook(() => useUpdateCheck("idle")); + const { result } = renderHook(() => useUpdateCheck("idle", true)); await waitFor(() => expect(result.current.available).toBe("9.9.9")); act(() => result.current.install()); @@ -167,7 +167,7 @@ describe("useUpdateCheck", () => { vi.mocked(checkForUpdate).mockResolvedValue(update); vi.mocked(installUpdate).mockResolvedValue(); - const { result } = renderHook(() => useUpdateCheck("idle")); + const { result } = renderHook(() => useUpdateCheck("idle", true)); // The silent path passes no progress callback — there is no banner to feed. await waitFor(() => expect(installUpdate).toHaveBeenCalledWith(update)); @@ -179,7 +179,7 @@ describe("useUpdateCheck", () => { vi.mocked(checkForUpdate).mockResolvedValue(fakeUpdate()); vi.mocked(installUpdate).mockRejectedValue(new Error("network down")); - const { result } = renderHook(() => useUpdateCheck("idle")); + const { result } = renderHook(() => useUpdateCheck("idle", true)); // A failed silent install must leave the update discoverable by hand. await waitFor(() => expect(result.current.available).toBe("9.9.9")); @@ -194,7 +194,7 @@ describe("useUpdateCheck", () => { vi.mocked(checkForUpdate).mockResolvedValue(fakeUpdate()); vi.mocked(installUpdate).mockResolvedValue(); - const { result } = renderHook(() => useUpdateCheck("connected")); + const { result } = renderHook(() => useUpdateCheck("connected", true)); // The banner surfaces in its deferred state instead of installing. await waitFor(() => expect(result.current.deferred).toBe(true)); @@ -208,7 +208,7 @@ describe("useUpdateCheck", () => { vi.mocked(installUpdate).mockResolvedValue(); const { result, rerender } = renderHook( - ({ phase }: { phase: ConnectionState }) => useUpdateCheck(phase), + ({ phase }: { phase: ConnectionState }) => useUpdateCheck(phase, true), { initialProps: { phase: "connected" as ConnectionState } }, ); @@ -226,7 +226,7 @@ describe("useUpdateCheck", () => { vi.mocked(installUpdate).mockResolvedValue(); const { result, rerender } = renderHook( - ({ phase }: { phase: ConnectionState }) => useUpdateCheck(phase), + ({ phase }: { phase: ConnectionState }) => useUpdateCheck(phase, true), { initialProps: { phase: "connected" as ConnectionState } }, ); @@ -243,7 +243,7 @@ describe("useUpdateCheck", () => { vi.mocked(checkForUpdate).mockResolvedValue(fakeUpdate()); vi.mocked(installUpdate).mockResolvedValue(); - const { result } = renderHook(() => useUpdateCheck("connected")); + const { result } = renderHook(() => useUpdateCheck("connected", true)); await waitFor(() => expect(result.current.available).toBe("9.9.9")); // The banner action opens the confirm rather than cutting the tunnel. @@ -261,7 +261,7 @@ describe("useUpdateCheck", () => { vi.mocked(checkForUpdate).mockResolvedValue(fakeUpdate()); vi.mocked(installUpdate).mockResolvedValue(); - const { result } = renderHook(() => useUpdateCheck("connected")); + const { result } = renderHook(() => useUpdateCheck("connected", true)); await waitFor(() => expect(result.current.available).toBe("9.9.9")); act(() => result.current.install()); @@ -279,7 +279,7 @@ describe("useUpdateCheck", () => { vi.mocked(checkForUpdate).mockResolvedValue(update); vi.mocked(installUpdate).mockResolvedValue(); - const { result } = renderHook(() => useUpdateCheck("idle")); + const { result } = renderHook(() => useUpdateCheck("idle", true)); await waitFor(() => expect(result.current.available).toBe("9.9.9")); act(() => result.current.install()); @@ -316,7 +316,7 @@ describe("useUpdateCheck", () => { it("checks again once the interval has passed", async () => { vi.mocked(checkForUpdate).mockResolvedValue(null); - renderHook(() => useUpdateCheck("idle")); + renderHook(() => useUpdateCheck("idle", true)); await beat(); expect(checkForUpdate).toHaveBeenCalledTimes(1); @@ -328,7 +328,7 @@ describe("useUpdateCheck", () => { it("leaves the release host alone in between", async () => { vi.mocked(checkForUpdate).mockResolvedValue(null); - renderHook(() => useUpdateCheck("idle")); + renderHook(() => useUpdateCheck("idle", true)); await beat(); // Every beat in the interval asks the clock and finds nothing to do; the @@ -340,7 +340,7 @@ describe("useUpdateCheck", () => { it("checks on the first beat after a long sleep", async () => { vi.mocked(checkForUpdate).mockResolvedValue(null); - renderHook(() => useUpdateCheck("idle")); + renderHook(() => useUpdateCheck("idle", true)); await beat(); expect(checkForUpdate).toHaveBeenCalledTimes(1); @@ -363,7 +363,7 @@ describe("useUpdateCheck", () => { return Promise.resolve(null); }); - renderHook(() => useUpdateCheck("idle")); + renderHook(() => useUpdateCheck("idle", true)); await beat(); expect(asked).toEqual(["stable"]); @@ -379,7 +379,7 @@ describe("useUpdateCheck", () => { vi.mocked(inAppUpdatesSupported).mockResolvedValue(false); vi.mocked(checkForUpdate).mockResolvedValue(fakeUpdate()); - const { result } = renderHook(() => useUpdateCheck("idle")); + const { result } = renderHook(() => useUpdateCheck("idle", true)); await beat(); await beat(24 * 60 * 60 * 1000); @@ -393,7 +393,7 @@ describe("useUpdateCheck", () => { vi.mocked(checkForUpdate).mockResolvedValueOnce(null); vi.mocked(checkForUpdate).mockResolvedValue(fakeUpdate("0.5.6")); - const { result } = renderHook(() => useUpdateCheck("idle")); + const { result } = renderHook(() => useUpdateCheck("idle", true)); await beat(); expect(result.current.available).toBeNull(); @@ -413,7 +413,7 @@ describe("useUpdateCheck", () => { vi.mocked(installUpdate).mockResolvedValue(); const { result, rerender } = renderHook( - ({ phase }: { phase: ConnectionState }) => useUpdateCheck(phase), + ({ phase }: { phase: ConnectionState }) => useUpdateCheck(phase, true), { initialProps: { phase: "connected" as ConnectionState } }, ); @@ -440,7 +440,7 @@ describe("useUpdateCheck", () => { it("does not re-offer a release the user already put off", async () => { vi.mocked(checkForUpdate).mockResolvedValue(fakeUpdate("0.5.6")); - const { result } = renderHook(() => useUpdateCheck("idle")); + const { result } = renderHook(() => useUpdateCheck("idle", true)); await beat(); act(() => result.current.dismiss()); expect(result.current.available).toBeNull(); @@ -457,7 +457,7 @@ describe("useUpdateCheck", () => { it("says nothing about the first failure", async () => { vi.mocked(checkForUpdate).mockRejectedValue(new Error("offline")); - const { result } = renderHook(() => useUpdateCheck("idle")); + const { result } = renderHook(() => useUpdateCheck("idle", true)); await beat(); // An offline launch still looks exactly like an up-to-date one. @@ -468,7 +468,7 @@ describe("useUpdateCheck", () => { it("surfaces the third failure in a row", async () => { vi.mocked(checkForUpdate).mockRejectedValue(new Error("offline")); - const { result } = renderHook(() => useUpdateCheck("idle")); + const { result } = renderHook(() => useUpdateCheck("idle", true)); await beat(); await beat(UPDATE_CHECK_INTERVAL_MS); expect(result.current.stalled).toBe(false); @@ -484,7 +484,7 @@ describe("useUpdateCheck", () => { localStorage.setItem("tenebra.updateFailures", "2"); vi.mocked(checkForUpdate).mockRejectedValue(new Error("offline")); - const { result } = renderHook(() => useUpdateCheck("idle")); + const { result } = renderHook(() => useUpdateCheck("idle", true)); await beat(); expect(result.current.stalled).toBe(true); @@ -494,7 +494,7 @@ describe("useUpdateCheck", () => { localStorage.setItem("tenebra.updateFailures", "4"); vi.mocked(checkForUpdate).mockResolvedValue(null); - const { result } = renderHook(() => useUpdateCheck("idle")); + const { result } = renderHook(() => useUpdateCheck("idle", true)); await beat(); expect(result.current.stalled).toBe(false); @@ -506,7 +506,7 @@ describe("useUpdateCheck", () => { vi.mocked(checkForUpdate).mockRejectedValueOnce(new Error("offline")); vi.mocked(checkForUpdate).mockResolvedValue(null); - const { result } = renderHook(() => useUpdateCheck("idle")); + const { result } = renderHook(() => useUpdateCheck("idle", true)); await beat(); expect(result.current.stalled).toBe(true); expect(checkForUpdate).toHaveBeenCalledTimes(1); @@ -526,7 +526,7 @@ describe("useUpdateCheck", () => { localStorage.setItem("tenebra.updateFailures", "3"); vi.mocked(checkForUpdate).mockRejectedValue(new Error("offline")); - const { result } = renderHook(() => useUpdateCheck("idle")); + const { result } = renderHook(() => useUpdateCheck("idle", true)); await beat(); expect(result.current.stalled).toBe(true); @@ -553,7 +553,7 @@ describe("useUpdateCheck", () => { it("offers the toast for a release that is waiting on the user", async () => { vi.mocked(checkForUpdate).mockResolvedValue(fakeUpdate("0.5.6")); - renderHook(() => useUpdateCheck("idle")); + renderHook(() => useUpdateCheck("idle", true)); await waitFor(() => expect(notifyUpdateAvailable).toHaveBeenCalledWith("0.5.6"), @@ -567,7 +567,7 @@ describe("useUpdateCheck", () => { // the toast would go missing on whichever platform reports it differently. vi.mocked(checkForUpdate).mockResolvedValue(fakeUpdate("0.5.6")); - const { result } = renderHook(() => useUpdateCheck("idle")); + const { result } = renderHook(() => useUpdateCheck("idle", true)); await waitFor(() => expect(result.current.available).toBe("0.5.6")); expect(notifyUpdateAvailable).toHaveBeenCalledWith("0.5.6"); @@ -578,7 +578,7 @@ describe("useUpdateCheck", () => { vi.setSystemTime(new Date("2026-08-24T12:00:00Z")); vi.mocked(checkForUpdate).mockResolvedValue(fakeUpdate("0.5.6")); - renderHook(() => useUpdateCheck("idle")); + renderHook(() => useUpdateCheck("idle", true)); await act(async () => { await vi.advanceTimersByTimeAsync(0); }); @@ -596,7 +596,7 @@ describe("useUpdateCheck", () => { vi.mocked(checkForUpdate).mockResolvedValue(fakeUpdate("0.5.6")); vi.mocked(installUpdate).mockResolvedValue(); - renderHook(() => useUpdateCheck("idle")); + renderHook(() => useUpdateCheck("idle", true)); await waitFor(() => expect(installUpdate).toHaveBeenCalled()); expect(notifyUpdateAvailable).not.toHaveBeenCalled(); diff --git a/ui-desktop/src/lib/useUpdateCheck.ts b/ui-desktop/src/lib/useUpdateCheck.ts index 9f7102d9..40063119 100644 --- a/ui-desktop/src/lib/useUpdateCheck.ts +++ b/ui-desktop/src/lib/useUpdateCheck.ts @@ -89,7 +89,7 @@ export interface UpdatePrompt { dismissStalled: () => void; } -export function useUpdateCheck(phase: ConnectionState): UpdatePrompt { +export function useUpdateCheck(phase: ConnectionState, ready: boolean): UpdatePrompt { const [update, setUpdate] = useState(null); const [dismissed, setDismissed] = useState(false); const [installing, setInstalling] = useState(false); @@ -106,6 +106,8 @@ export function useUpdateCheck(phase: ConnectionState): UpdatePrompt { // callbacks read it fresh without re-subscribing on every transition. const phaseRef = useRef(phase); phaseRef.current = phase; + const readyRef = useRef(ready); + readyRef.current = ready; // Kick off the download → install → relaunch. Shared by every route into an // install: the silent auto path, the deferred auto-fire, and both manual @@ -113,6 +115,7 @@ export function useUpdateCheck(phase: ConnectionState): UpdatePrompt { // its downloading state. const installingRef = useRef(false); const runInstall = useCallback((target: Update) => { + if (!readyRef.current || installingRef.current) return; setConfirming(false); setDeferred(false); setInstalling(true); @@ -203,12 +206,15 @@ export function useUpdateCheck(phase: ConnectionState): UpdatePrompt { setDismissed(false); autoFired.current = false; if (getAutoInstallUpdates()) { - if (!tunnelBusy(phaseRef.current)) { + if (readyRef.current && !tunnelBusy(phaseRef.current)) { try { + installingRef.current = true; + autoFired.current = true; // Nothing is riding the tunnel — apply it silently and relaunch. await installUpdate(found); return; } catch { + installingRef.current = false; // The silent install failed; fall back to the banner so the update // stays discoverable (and retryable) by hand. } @@ -255,19 +261,35 @@ export function useUpdateCheck(phase: ConnectionState): UpdatePrompt { // longer carrying traffic. The ref guard fires it exactly once per release — // including across StrictMode's replay — and a manual install (which clears // `deferred`) stands it down. + useEffect(() => { + const disarm = () => { + if (!getAutoInstallUpdates()) setDeferred(false); + }; + window.addEventListener("tenebra:auto-install", disarm); + window.addEventListener("storage", disarm); + return () => { + window.removeEventListener("tenebra:auto-install", disarm); + window.removeEventListener("storage", disarm); + }; + }, []); + useEffect(() => { if (!deferred || !update || autoFired.current) { return; } - if (tunnelBusy(phase)) { + if (!getAutoInstallUpdates()) { + setDeferred(false); + return; + } + if (!ready || tunnelBusy(phase)) { return; } autoFired.current = true; runInstall(update); - }, [deferred, update, phase, runInstall]); + }, [deferred, update, phase, ready, runInstall]); const install = useCallback(() => { - if (!update || installing) { + if (!update || installing || !readyRef.current) { return; } // A live tunnel: installing relaunches the app and drops the VPN, so get an @@ -280,7 +302,7 @@ export function useUpdateCheck(phase: ConnectionState): UpdatePrompt { }, [update, installing, runInstall]); const confirmInstall = useCallback(() => { - if (!update || installing) { + if (!update || installing || !readyRef.current) { return; } runInstall(update); diff --git a/ui-desktop/src/screens/ProfilesScreen.tsx b/ui-desktop/src/screens/ProfilesScreen.tsx index 3bba26e3..a3eff231 100644 --- a/ui-desktop/src/screens/ProfilesScreen.tsx +++ b/ui-desktop/src/screens/ProfilesScreen.tsx @@ -1,9 +1,10 @@ +import { ModalLayer } from "../components/ModalLayer"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { api, type PingResult, type Profile } from "../api"; import type { Tenebra } from "../state/useTenebra"; import { useI18n } from "../i18n/I18nContext"; -import type { Strings } from "../i18n/strings"; +import { describeCoreError, type Strings } from "../i18n/strings"; import { formatDate, formatExpiry, formatTrafficUsage } from "../lib/format"; import { pushToast } from "../lib/toast"; import { ClipboardError, readClipboardText } from "../lib/clipboard"; @@ -35,6 +36,7 @@ interface ProfilesScreenProps { * still runs, it just leaves the overlay up. */ onConnected?: () => void; + onConnect?: (profile: string, node?: string) => Promise; } export function ProfilesScreen({ @@ -44,6 +46,7 @@ export function ProfilesScreen({ initialImport = null, onImportConsumed, onConnected, + onConnect, }: ProfilesScreenProps) { const { t } = useI18n(); const { profiles, state } = tenebra; @@ -141,6 +144,7 @@ export function ProfilesScreen({ onArmRemove={() => armRemove(profile.id)} onDisarmRemove={disarmRemove} onConnected={onConnected} + onConnect={onConnect} /> ))} @@ -176,6 +180,7 @@ interface ProfileCardProps { onDisarmRemove: () => void; /** Ask the shell to dismiss the overlay after a connect started from here. */ onConnected?: () => void; + onConnect?: (profile: string, node?: string) => Promise; } // Calendar-day tone for the expiry readout, mirroring the day math in @@ -216,6 +221,7 @@ function ProfileCard({ onArmRemove, onDisarmRemove, onConnected, + onConnect, }: ProfileCardProps) { const { t, lang } = useI18n(); const [expanded, setExpanded] = useState(false); @@ -270,13 +276,18 @@ function ProfileCard({ async function connectNode(nodeId?: string) { setBusy(true); try { - await tenebra.connect(profile.id, nodeId); + if (onConnect) { + if (!(await onConnect(profile.id, nodeId))) return; + } else { + await tenebra.connect(profile.id, nodeId); + } + onSelect(); // A card connect is a "go": hand the overlay back so the main panel and the // fallback walk are in view. The "tunnel up" toast is raised by the shell // on the state transition, so it isn't duplicated here. onConnected?.(); - } catch { - // The state stream surfaces the failure. + } catch (e) { + pushToast(describeCoreError(e, t)); } finally { setBusy(false); } @@ -712,7 +723,7 @@ function ImportDialog({ tenebra, onClose, initialUrl }: ImportDialogProps) { }; return ( -
{ if (e.target === e.currentTarget) { @@ -919,6 +930,6 @@ function ImportDialog({ tenebra, onClose, initialUrl }: ImportDialogProps) { )}
- + ); } diff --git a/ui-desktop/src/screens/SettingsScreen.intents.test.tsx b/ui-desktop/src/screens/SettingsScreen.intents.test.tsx new file mode 100644 index 00000000..cd6c7477 --- /dev/null +++ b/ui-desktop/src/screens/SettingsScreen.intents.test.tsx @@ -0,0 +1,72 @@ +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +import { SettingsScreen } from "./SettingsScreen"; +import { makeProfile, makeTenebra } from "../test/fixtures"; +import { renderWithProviders } from "../test/renderWithProviders"; +vi.mock("@tauri-apps/plugin-autostart", () => ({ isEnabled: vi.fn(async () => false), enable: vi.fn(), disable: vi.fn() })); +vi.mock("@tauri-apps/api/app", () => ({ getVersion: vi.fn(async () => "0.5.11") })); +vi.mock("../lib/updates", () => ({ inAppUpdatesSupported: vi.fn(async () => false), checkForUpdate: vi.fn(), installUpdate: vi.fn() })); +beforeEach(() => localStorage.clear()); +function toggle(label: string) { + fireEvent.click(screen.getByText(label).closest(".set-row")!.querySelector('[role="switch"]')!); +} + +it("serializes independent DNS edits and preserves both intents", async () => { + let finish!: () => void; + const save = vi.fn().mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })).mockResolvedValue(undefined); + renderWithProviders(); + await act(async () => {}); + toggle("Block ads and trackers"); + toggle("IPv4-only DNS"); + expect(save).toHaveBeenCalledTimes(1); + await act(async () => finish()); + await waitFor(() => expect(save).toHaveBeenNthCalledWith(2, true, "", "", true)); +}); + +it("does not undo an unrelated routing intent when the previous save fails", async () => { + let fail!: (reason: Error) => void; + const save = vi.fn().mockImplementationOnce(() => new Promise((_resolve, reject) => { fail = reject; })).mockResolvedValue(undefined); + renderWithProviders(); + await act(async () => {}); + toggle("Russian banking sites stay direct"); + toggle("Russian government sites stay direct"); + expect(save).toHaveBeenCalledTimes(1); + await act(async () => fail(new Error("refused"))); + await waitFor(() => expect(save).toHaveBeenNthCalledWith(2, [], [], false, true)); +}); + +it("preserves both multihop endpoints selected before the first save returns", async () => { + let finish!: () => void; + const save = vi.fn().mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })).mockResolvedValue(undefined); + renderWithProviders(); + await act(async () => {}); + const selects = screen.getAllByRole("combobox"); + const entry = selects.find((s) => s.querySelector('option[value="node-1"]'))!; + const exit = selects.find((s) => s !== entry && s.querySelector('option[value="node-2"]'))!; + fireEvent.change(entry, { target: { value: "node-1" } }); + fireEvent.change(exit, { target: { value: "node-2" } }); + expect(save).toHaveBeenCalledTimes(1); + await act(async () => finish()); + await waitFor(() => expect(save).toHaveBeenNthCalledWith(2, "profile-1", false, "node-1", "node-2")); +}); + +it("gives every settings switch a meaningful accessible name", async () => { + renderWithProviders(); + await act(async () => {}); + expect(screen.getByRole("switch", { name: "Block ads and trackers" })).toBeInTheDocument(); + expect(screen.queryAllByRole("switch", { name: /^(ON|OFF)$/ })).toEqual([]); +}); + +it("preserves a split-mode edit while a pending application edit finishes", async () => { + let finish!: () => void; + const save = vi.fn().mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })).mockResolvedValue(undefined); + renderWithProviders(); + await act(async () => {}); + const field = screen.getByRole("textbox", { name: "Apps" }); + fireEvent.change(field, { target: { value: "chrome.exe" } }); + fireEvent.keyDown(field, { key: "Enter" }); + fireEvent.click(screen.getByRole("radio", { name: /^only these/i })); + expect(save).toHaveBeenCalledTimes(1); + await act(async () => finish()); + await waitFor(() => expect(save).toHaveBeenNthCalledWith(2, "include", ["chrome.exe"])); +}); diff --git a/ui-desktop/src/screens/SettingsScreen.tsx b/ui-desktop/src/screens/SettingsScreen.tsx index a299d812..8c9d71e3 100644 --- a/ui-desktop/src/screens/SettingsScreen.tsx +++ b/ui-desktop/src/screens/SettingsScreen.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type KeyboardEvent } from "react"; +import { Fragment, useEffect, useRef, useState, type KeyboardEvent } from "react"; import { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart"; import { getVersion } from "@tauri-apps/api/app"; import type { Update } from "@tauri-apps/plugin-updater"; @@ -8,6 +8,7 @@ import { type ConnectionMode, type PickProgressEvent, type RoutingMode, + type State, type SplitMode, type TunStack, type ZapretUpdate, @@ -46,6 +47,7 @@ import { type UpdateStatus, } from "../lib/updates"; import { tunnelBusy } from "../lib/tunnel"; +import { useIntentQueue } from "../lib/useIntentQueue"; import { useReducedMotion } from "../lib/useReducedMotion"; interface SettingsScreenProps { @@ -554,6 +556,14 @@ export function SettingsScreen({ tenebra }: SettingsScreenProps) { .catch(() => {}); } + const { value: intended, enqueue } = useIntentQueue(tenebra.state, reportRefusal); + const queueDns = (change: (s: State) => State) => enqueue(change, (s) => + tenebra.setDns(s.ad_block ?? false, s.dns_remote ?? "", s.dns_direct ?? "", s.ipv4_only ?? false)); + const queueRules = (change: (s: State) => State) => enqueue(change, (s) => + tenebra.setRules(s.rules_direct ?? [], s.rules_proxy ?? [], s.preset_ru_banking ?? false, s.preset_ru_gov ?? false)); + const queueSplit = (change: (s: State) => State) => enqueue(change, (s) => + tenebra.setSplit(s.split ?? "off", s.split_apps ?? [])); + // Multihop two-hop chain. Core-owned like the other toggles: off with no // selection until the user picks an entry and an exit node. The choices are // drawn from the active profile (the connected one, else the first stored) and @@ -563,40 +573,29 @@ export function SettingsScreen({ tenebra }: SettingsScreenProps) { tenebra.state.profile || tenebra.profiles[0]?.id || ""; const multihopNodes = tenebra.profiles.find((p) => p.id === multihopProfileId)?.nodes ?? []; - const multihopEnabled = tenebra.state.multihop?.enabled ?? false; - const multihopEntry = tenebra.state.multihop?.entry_id ?? ""; - const multihopExit = tenebra.state.multihop?.exit_id ?? ""; + const multihopEnabled = intended.multihop?.enabled ?? false; + const multihopEntry = intended.multihop?.entry_id ?? ""; + const multihopExit = intended.multihop?.exit_id ?? ""; // The chain can only be armed once both ends are chosen and distinct — the same // rule the core enforces — so the toggle is inert until then (it can always turn // off). The selectors stay usable while off so a pick can be made first. const multihopArmable = multihopEntry !== "" && multihopExit !== "" && - multihopEntry !== multihopExit; + multihopEntry !== multihopExit && + multihopNodes.some((n) => n.id === multihopEntry) && + multihopNodes.some((n) => n.id === multihopExit); - function toggleMultihop() { - if (!multihopEnabled && !multihopArmable) { - return; - } - void tenebra - .setMultihop( - multihopProfileId, - !multihopEnabled, - multihopEntry, - multihopExit, - ) - .catch(reportRefusal); - } - function selectMultihopEntry(entryId: string) { - void tenebra - .setMultihop(multihopProfileId, multihopEnabled, entryId, multihopExit) - .catch(reportRefusal); + function queueMultihop(patch: Partial>) { + enqueue((s) => ({ ...s, multihop: { enabled: false, entry_id: "", exit_id: "", ...s.multihop, ...patch } }), + (s) => tenebra.setMultihop(multihopProfileId, s.multihop!.enabled, s.multihop!.entry_id ?? "", s.multihop!.exit_id ?? "")); } - function selectMultihopExit(exitId: string) { - void tenebra - .setMultihop(multihopProfileId, multihopEnabled, multihopEntry, exitId) - .catch(reportRefusal); + function toggleMultihop() { + if (!multihopEnabled && !multihopArmable) return; + queueMultihop({ enabled: !multihopEnabled }); } + function selectMultihopEntry(entryId: string) { queueMultihop({ entry_id: entryId }); } + function selectMultihopExit(exitId: string) { queueMultihop({ exit_id: exitId }); } // Health-failover watchdog. On by default in the core, which projects the // effective value into State as a concrete bool — so the armed default arrives @@ -619,7 +618,7 @@ export function SettingsScreen({ tenebra }: SettingsScreenProps) { // (`tenebra.simpleMode`) and "true"/"false" encoding are a contract with the // shell — keep them verbatim. const [simpleMode, setSimpleMode] = useState( - () => localStorage.getItem("tenebra.simpleMode") === "true", + () => ["true", "1"].includes(localStorage.getItem("tenebra.simpleMode") ?? ""), ); function toggleSimpleMode() { @@ -648,11 +647,9 @@ export function SettingsScreen({ tenebra }: SettingsScreenProps) { } function toggleAutoInstall() { - setAutoInstallState((prev) => { - const next = !prev; - setAutoInstallUpdates(next); - return next; - }); + const next = !autoInstall; + setAutoInstallState(next); + setAutoInstallUpdates(next); } // Update channel is renderer-owned, like the auto-* toggles: the scheduled @@ -726,7 +723,7 @@ export function SettingsScreen({ tenebra }: SettingsScreenProps) { // The download → install → relaunch itself. Shared by the direct (tunnel down) // path and the confirmed one, so the gate below has one thing to call. async function runUpdateInstall() { - if (!pendingUpdate) { + if (!pendingUpdate || !tenebra.ready || tenebra.coreError) { return; } setConfirmingInstall(false); @@ -747,7 +744,7 @@ export function SettingsScreen({ tenebra }: SettingsScreenProps) { // background service to swap it), which would drop a live tunnel — so when one // is up, confirm first; when it's down, install straight away. function applyUpdate() { - if (!pendingUpdate) { + if (!pendingUpdate || !tenebra.ready || tenebra.coreError) { return; } if (tunnelBusy(tenebra.state.state)) { @@ -847,8 +844,8 @@ export function SettingsScreen({ tenebra }: SettingsScreenProps) { // Split tunnelling. The core owns the canonical list (it normalizes names), so // the rendered list always reflects tenebra.state rather than a local copy; the // text field is the only local state. - const splitMode = tenebra.state.split ?? "off"; - const splitApps = tenebra.state.split_apps ?? []; + const splitMode = intended.split ?? "off"; + const splitApps = intended.split_apps ?? []; const [appDraft, setAppDraft] = useState(""); const splitOptions: { mode: SplitMode; label: string; hint: string }[] = [ @@ -869,7 +866,7 @@ export function SettingsScreen({ tenebra }: SettingsScreenProps) { if (mode === splitMode) { return; } - void tenebra.setSplit(mode, splitApps).catch(reportRefusal); + queueSplit((s) => ({ ...s, split: mode })); } // See routingRefs: arrow keys move focus along with the selection here too. @@ -899,19 +896,12 @@ export function SettingsScreen({ tenebra }: SettingsScreenProps) { if (!canAddApp) { return; } - void tenebra - .setSplit(splitMode, [...splitApps, normalizedDraft]) - .catch(reportRefusal); + queueSplit((s) => ({ ...s, split_apps: [...(s.split_apps ?? []), normalizedDraft] })); setAppDraft(""); } function removeApp(name: string) { - void tenebra - .setSplit( - splitMode, - splitApps.filter((a) => a !== name), - ) - .catch(reportRefusal); + queueSplit((s) => ({ ...s, split_apps: (s.split_apps ?? []).filter((a) => a !== name) })); } function onAppInputKey(e: KeyboardEvent) { @@ -1007,10 +997,10 @@ export function SettingsScreen({ tenebra }: SettingsScreenProps) { // re-applies them in place. Each set_dns carries the whole set, so we derive it // from the current toggles and the two resolver drafts, ignoring a malformed // draft. - const adBlock = tenebra.state.ad_block ?? false; - const ipv4Only = tenebra.state.ipv4_only ?? false; - const dnsRemote = tenebra.state.dns_remote ?? ""; - const dnsDirect = tenebra.state.dns_direct ?? ""; + const adBlock = intended.ad_block ?? false; + const ipv4Only = intended.ipv4_only ?? false; + const dnsRemote = intended.dns_remote ?? ""; + const dnsDirect = intended.dns_direct ?? ""; const [remoteDraft, setRemoteDraft] = useState(dnsRemote); const [directDraft, setDirectDraft] = useState(dnsDirect); @@ -1031,27 +1021,20 @@ export function SettingsScreen({ tenebra }: SettingsScreenProps) { // Every set_dns carries the whole set of preferences, so each toggle/edit path // re-sends the current values of the others alongside the one it changes. - function pushDns(nextAdBlock: boolean, nextIpv4Only: boolean) { - void tenebra - .setDns(nextAdBlock, remoteValue, directValue, nextIpv4Only) - .catch(reportRefusal); - } - function toggleAdBlock() { - pushDns(!adBlock, ipv4Only); + const ad_block = !adBlock; + queueDns((s) => ({ ...s, ad_block })); } - function toggleIpv4Only() { - pushDns(adBlock, !ipv4Only); + const ipv4_only = !ipv4Only; + queueDns((s) => ({ ...s, ipv4_only })); } - - // Commit a resolver edit: only when a valid draft actually changes an effective - // resolver, so blurring an unchanged (or malformed) field is a no-op. function commitResolvers() { - if (remoteValue === dnsRemote && directValue === dnsDirect) { - return; - } - pushDns(adBlock, ipv4Only); + if (remoteValue === dnsRemote && directValue === dnsDirect) return; + const patch: Partial = {}; + if (remoteValue !== dnsRemote) patch.dns_remote = remoteValue; + if (directValue !== dnsDirect) patch.dns_direct = directValue; + queueDns((s) => ({ ...s, ...patch })); } function onResolverKey(e: KeyboardEvent) { @@ -1066,28 +1049,18 @@ export function SettingsScreen({ tenebra }: SettingsScreenProps) { // (when a tunnel is live) re-applies in place. Each set_rules carries the whole // set, so every toggle/edit re-sends the current values of the others alongside // the one it changes. - const rulesDirect = tenebra.state.rules_direct ?? []; - const rulesProxy = tenebra.state.rules_proxy ?? []; - const presetRuBanking = tenebra.state.preset_ru_banking ?? false; - const presetRuGov = tenebra.state.preset_ru_gov ?? false; - - function pushRules( - nextDirect: string[], - nextProxy: string[], - nextBanking: boolean, - nextGov: boolean, - ) { - void tenebra - .setRules(nextDirect, nextProxy, nextBanking, nextGov) - .catch(reportRefusal); - } + const rulesDirect = intended.rules_direct ?? []; + const rulesProxy = intended.rules_proxy ?? []; + const presetRuBanking = intended.preset_ru_banking ?? false; + const presetRuGov = intended.preset_ru_gov ?? false; function toggleRuleBanking() { - pushRules(rulesDirect, rulesProxy, !presetRuBanking, presetRuGov); + const preset_ru_banking = !presetRuBanking; + queueRules((s) => ({ ...s, preset_ru_banking })); } - function toggleRuleGov() { - pushRules(rulesDirect, rulesProxy, presetRuBanking, !presetRuGov); + const preset_ru_gov = !presetRuGov; + queueRules((s) => ({ ...s, preset_ru_gov })); } // Routing presets. Two of the three take a class of traffic out of the tunnel, @@ -1124,9 +1097,11 @@ export function SettingsScreen({ tenebra }: SettingsScreenProps) {
{NAV_SECTIONS.map((key) => { const active = activeSection === key; + const group = key === "routing" ? t.settings.groupTraffic : key === "mode" ? t.settings.groupAdvanced : key === "reliability" ? t.settings.groupHelp : key === "appearance" ? t.settings.groupApp : null; return ( + + {group &&

{group}

} +
); })}
@@ -1319,6 +1295,7 @@ export function SettingsScreen({ tenebra }: SettingsScreenProps) { ) : ( @@ -2179,6 +2150,7 @@ export function SettingsScreen({ tenebra }: SettingsScreenProps) { } + {status === "blocked" + ? + : } + } + ; +} diff --git a/ui-desktop/src/components/SimpleView.test.tsx b/ui-desktop/src/components/SimpleView.test.tsx index 2edd35b1..ea45880b 100644 --- a/ui-desktop/src/components/SimpleView.test.tsx +++ b/ui-desktop/src/components/SimpleView.test.tsx @@ -46,12 +46,12 @@ describe("SimpleView", () => { expect(screen.getByText("You're not connected")).toBeInTheDocument(); }); - it("shows Disconnect and the protected line with the node when connected", () => { + it("shows Disconnect and the connected line with the node when connected", () => { setup({ phase: "connected", nodeName: "AMS-01" }); expect( screen.getByRole("button", { name: "Disconnect" }), ).toBeInTheDocument(); - expect(screen.getByText("You're protected · AMS-01")).toBeInTheDocument(); + expect(screen.getByText("Tunnel connected · AMS-01")).toBeInTheDocument(); }); it.each(["connecting", "health_reconnecting"] as const)("shows Abort during %s", (phase) => { diff --git a/ui-desktop/src/i18n/strings.ts b/ui-desktop/src/i18n/strings.ts index 070c43e6..015b007f 100644 --- a/ui-desktop/src/i18n/strings.ts +++ b/ui-desktop/src/i18n/strings.ts @@ -138,11 +138,24 @@ export interface Strings { }; /** Bottom bar: routing segmented control, kill-switch, quick actions. */ + protection: { + active: string; + blocked: string; + applying: string; + off: string; + error: string; + unavailable: string; + legacy: string; + unknown: string; + lastGuard: string; + retry: string; + disable: string; + disconnect: string; + }; bottom: { routing: string; killSwitch: string; - killBanner: string; - /** Tooltip explaining what arming actually does (and costs). */ + /** Requested preference; actual native evidence is displayed separately. */ killSwitchHint: string; leakCheck: string; settings: string; @@ -884,7 +897,7 @@ const en: Strings = { }, conn: { eyebrow: "Tunnel status", - subOff: "traffic unprotected · select a node and connect", + subOff: "tunnel disconnected · select a node and connect", subPending: "establishing tunnel · negotiating · · ·", subReconnecting: "node failed · switching to a healthy exit on its own", subConnected: "tunnel connected", @@ -941,20 +954,33 @@ const en: Strings = { manualAfterPing: "TCP check failed. Select this node to try connecting manually.", noneUsable: "No node carried traffic — connecting anyway, node by node", }, + protection: { + active: "Persistent protection is active. The tunnel and traffic guard are verified. Closing the app keeps the guard; Disconnect releases it.", + blocked: "Internet traffic is blocked until the tunnel recovers or you explicitly disconnect.", + applying: "Applying protection. Waiting for the service to confirm the traffic guard.", + off: "Protection is requested, but no traffic guard is installed yet.", + error: "Protection could not be confirmed. Retry it or explicitly turn it off to release the block.", + unavailable: "This platform does not provide persistent protection. The preference alone cannot block traffic.", + legacy: "This service version does not confirm persistent protection. The saved preference is not a guarantee.", + unknown: "Current protection is unconfirmed. Waiting for a fresh service status.", + lastGuard: "The last confirmed guard was persistent. Current protection is unconfirmed; traffic may remain blocked until recovery or explicit removal.", + retry: "Retry protection", + disable: "Turn protection off", + disconnect: "Disconnect and allow traffic", + }, bottom: { routing: "Routing", killSwitch: "kill-switch", - killBanner: "KILL-SWITCH ARMED · traffic blocked if the tunnel drops", killSwitchHint: - "Block traffic that tries to bypass the tunnel; if the tunnel dies, restart it. Applies live; connects get rougher while armed.", + "Request persistent protection. The status above confirms whether it is installed. Use Turn protection off or Disconnect to release a confirmed block.", leakCheck: "leak-check", settings: "settings", report: "report a problem", }, toast: { tunnelUp: "tunnel up", - killOn: "kill-switch · on", - killOff: "kill-switch · off", + killOn: "kill-switch · requested", + killOff: "kill-switch request · off", route: "route · {mode}", profileActive: "active profile · {name}", profilePinged: "ping updated · {alive}/{total} alive", @@ -1064,7 +1090,7 @@ const en: Strings = { setupLinkPlaceholder: "https://…", bypassOn: "bypass on", bypassOff: "bypass off", - statusOn: "You're protected", + statusOn: "Tunnel connected", statusOff: "You're not connected", server: "Server", auto: "Automatic — fastest", @@ -1455,7 +1481,7 @@ const ru: Strings = { }, conn: { eyebrow: "Статус туннеля", - subOff: "трафик не защищён · выберите узел и подключитесь", + subOff: "туннель отключён · выберите узел и подключитесь", subPending: "поднимаю туннель · согласование · · ·", subReconnecting: "узел отказал · сам переключаюсь на рабочий выход", subConnected: "туннель подключён", @@ -1512,20 +1538,33 @@ const ru: Strings = { manualAfterPing: "TCP-проверка не прошла. Выберите узел, чтобы попробовать подключиться вручную.", noneUsable: "Ни один узел не пропустил трафик — подключаюсь перебором", }, + protection: { + active: "Постоянная защита активна. Туннель и блокировка трафика подтверждены. Закрытие приложения сохраняет блокировку; Отключить снимает её.", + blocked: "Интернет-трафик заблокирован до восстановления туннеля или явного отключения.", + applying: "Применяем защиту. Ожидаем подтверждения блокировки от службы.", + off: "Защита запрошена, но блокировка трафика ещё не установлена.", + error: "Не удалось подтвердить защиту. Повторите попытку или явно выключите её, чтобы снять блокировку.", + unavailable: "На этой платформе постоянная защита недоступна. Одна настройка не блокирует трафик.", + legacy: "Эта версия службы не подтверждает постоянную защиту. Сохранённая настройка не гарантирует блокировку.", + unknown: "Текущая защита не подтверждена. Ожидаем свежий статус службы.", + lastGuard: "Последняя подтверждённая блокировка была постоянной. Текущая защита не подтверждена; трафик может оставаться заблокированным до восстановления или явного снятия.", + retry: "Повторить защиту", + disable: "Выключить защиту", + disconnect: "Отключить и разрешить трафик", + }, bottom: { routing: "Маршрут", killSwitch: "kill-switch", - killBanner: "KILL-SWITCH ВКЛ · трафик блокируется при обрыве туннеля", killSwitchHint: - "Блокировать трафик в обход туннеля; при падении туннеля — перезапустить его. Применяется сразу; коннект с ним грубее.", + "Запросить постоянную защиту. Статус выше подтверждает её применение. Чтобы снять подтверждённую блокировку, выключите защиту или нажмите Отключить.", leakCheck: "проверка", settings: "настройки", report: "сообщить о проблеме", }, toast: { tunnelUp: "туннель поднят", - killOn: "kill-switch · вкл", - killOff: "kill-switch · выкл", + killOn: "kill-switch · запрошен", + killOff: "kill-switch · запрос снят", route: "маршрут · {mode}", profileActive: "активный профиль · {name}", profilePinged: "пинг обновлён · {alive}/{total} живы", @@ -1635,7 +1674,7 @@ const ru: Strings = { setupLinkPlaceholder: "https://…", bypassOn: "обход включён", bypassOff: "обход выключен", - statusOn: "Вы под защитой", + statusOn: "Туннель подключён", statusOff: "Вы не подключены", server: "Сервер", auto: "Автоматически — быстрее всего", diff --git a/ui-desktop/src/lib/audit-races.test.ts b/ui-desktop/src/lib/audit-races.test.ts index 7b7f8668..e96266cc 100644 --- a/ui-desktop/src/lib/audit-races.test.ts +++ b/ui-desktop/src/lib/audit-races.test.ts @@ -45,6 +45,68 @@ it("merges bootstrap metadata without rolling back the latest event phase", asyn expect(result.current.state).toMatchObject({ state: "connected", node: "new-node", profile: "p1", kill_switch: true, routing: "global", daemon_version: "0.5.11" }); }); +it("keeps new guard evidence when an older bootstrap snapshot returns", async () => { + let resolveStatus!: (s: State) => void; + m.status.mockImplementation(() => new Promise((resolve) => { resolveStatus = resolve; })); + const { result } = renderHook(() => useTenebra()); + const protection = { status: "blocked", enforced: true, persistent: true } as const; + act(() => m.state!({ state: "error", protection })); + await act(async () => resolveStatus({ state: "connected", protection: { ...protection, status: "active" } })); + expect(result.current.state.protection).toEqual(protection); +}); + +it("holds confidence across service loss and refreshes status when events return", async () => { + const guard = { enforced: true, persistent: true }; + m.status.mockResolvedValue({ state: "connected", protection: { ...guard, status: "active" } }); + const { result } = renderHook(() => useTenebra()); + await waitFor(() => expect(result.current.ready).toBe(true)); + let resolveStatus!: (s: State) => void; + m.status.mockImplementation(() => new Promise((resolve) => { resolveStatus = resolve; })); + act(() => m.state!({ state: "connecting", error: "Reconnecting to the Tenebra service…" })); + expect(result.current.coreError).toContain("Reconnecting"); + expect(result.current.state.protection?.status).toBe("active"); // Retained, unconfirmed evidence. + act(() => m.state!({ state: "error", protection: { ...guard, status: "blocked" } })); + expect(m.status).toHaveBeenCalledTimes(2); + expect(result.current.coreError).not.toBeNull(); + // A newer guard change cannot be rolled back by the recovery status. + act(() => m.state!({ state: "idle", protection: { enforced: false, persistent: false, status: "off" } })); + await act(async () => resolveStatus({ state: "error", protection: { ...guard, status: "blocked" } })); + expect(result.current.coreError).toBeNull(); + expect(result.current.state.protection?.status).toBe("off"); + expect(result.current.state.state).toBe("idle"); +}); + +it("does not clear a newer pipe outage from an old in-flight status", async () => { + const { result } = renderHook(() => useTenebra()); + await waitFor(() => expect(result.current.ready).toBe(true)); + let resolveStatus!: (s: State) => void; + m.status.mockImplementation(() => new Promise((resolve) => { resolveStatus = resolve; })); + let request!: Promise; + act(() => { request = result.current.refreshStatus(); }); + act(() => m.state!({ state: "error", error: "Lost the connection to the Tenebra service; reconnecting." })); + await act(async () => { resolveStatus({ state: "connected" }); await request; }); + expect(result.current.coreError).toContain("Lost the connection"); + expect(result.current.state.state).toBe("error"); +}); + +it("does not turn a lost service's synthetic error into an idle auto-update opportunity", async () => { + setAutoInstallUpdates(true); + m.status.mockResolvedValue({ state: "connected" }); + const { result } = renderHook(() => { + const tenebra = useTenebra(); + return useUpdateCheck(tenebra.state.state, tenebra.ready && !tenebra.coreError); + }); + await waitFor(() => expect(result.current.deferred).toBe(true)); + let resolveStatus!: (s: State) => void; + m.status.mockImplementation(() => new Promise((resolve) => { resolveStatus = resolve; })); + act(() => m.state!({ state: "error", error: "Lost the connection to the Tenebra service; reconnecting." })); + expect(m.installUpdate).not.toHaveBeenCalled(); + act(() => m.state!({ state: "idle" })); + expect(m.installUpdate).not.toHaveBeenCalled(); + await act(async () => resolveStatus({ state: "idle" })); + await waitFor(() => expect(m.installUpdate).toHaveBeenCalledTimes(1)); +}); + it("holds automatic and manual installation until daemon status is ready", async () => { setAutoInstallUpdates(true); let resolveStatus!: (s: State) => void; diff --git a/ui-desktop/src/lib/useActionToasts.test.tsx b/ui-desktop/src/lib/useActionToasts.test.tsx index 86129431..698c2c41 100644 --- a/ui-desktop/src/lib/useActionToasts.test.tsx +++ b/ui-desktop/src/lib/useActionToasts.test.tsx @@ -58,7 +58,7 @@ describe("useActionToasts", () => { expect(messages).toEqual(["tunnel up · EX-01 · hysteria2"]); }); - it("announces the kill switch arming and disarming", () => { + it("announces the requested kill switch preference separately from native guard evidence", () => { const base: ConnectedNode = { name: "EX-01", protocol: "vless" }; const { messages, rerender } = setup({ state: { ready: true, phase: "connected", killSwitch: false, routing: "smart" }, @@ -74,7 +74,7 @@ describe("useActionToasts", () => { node: base, }); - expect(messages).toEqual(["kill-switch · on", "kill-switch · off"]); + expect(messages).toEqual(["kill-switch · requested", "kill-switch request · off"]); }); it("announces a routing change with a lowercased mode", () => { diff --git a/ui-desktop/src/lib/useActionToasts.ts b/ui-desktop/src/lib/useActionToasts.ts index d2ed4d0a..37b80abb 100644 --- a/ui-desktop/src/lib/useActionToasts.ts +++ b/ui-desktop/src/lib/useActionToasts.ts @@ -26,7 +26,7 @@ const ROUTING_LABEL: Record = { /** * Raises a toast on the App-level state transitions the user just caused: - * reaching "connected", arming / disarming the kill switch, and changing the + * reaching "connected", changing the requested kill switch preference, and changing the * routing mode. Baselines are seeded from the first settled snapshot (once * `ready`), so the initial status load is silent — only genuine changes speak. * diff --git a/ui-desktop/src/state/useTenebra.ts b/ui-desktop/src/state/useTenebra.ts index 2955d042..334d8938 100644 --- a/ui-desktop/src/state/useTenebra.ts +++ b/ui-desktop/src/state/useTenebra.ts @@ -102,10 +102,10 @@ export interface Tenebra { ready: boolean; /** * Why the last attempt to reach the core failed, or null once one succeeded. - * Non-null means the app is drawn but has nothing behind it — no profiles, no - * state, every action doomed — and the shell is expected to say so out loud. - * The retry keeps running underneath, so this clears itself the moment the - * core answers. Optional so a hand-built stub reads as a healthy core. + * Non-null means current service state is unconfirmed. Previously loaded + * profiles and guard evidence remain available, but cannot certify a live + * tunnel. Retries continue until a fresh status answers. Optional so a + * hand-built stub reads as a healthy core. */ coreError?: string | null; state: State; @@ -187,7 +187,7 @@ export interface Tenebra { * them needs this. The bypass commands do: `start_zapret` answers with the * strategy it started and `pick_zapret` with its measurements, neither of them * a `State`, and the core pushes no state event for either — the state event - * carries only phase/node/error. Without a re-read the screen keeps drawing + * carries phase/node/error/protection. Without a re-read the screen keeps drawing * the bypass exactly as it was before the user switched it. */ refreshStatus: () => Promise; @@ -220,6 +220,9 @@ export function useTenebra(): Tenebra { ); const logSeq = useRef(0); + const stateEventSeq = useRef(0); + const serviceLossSeq = useRef(0); + const serviceLost = useRef(false); const appendLog = useCallback((e: LogEvent) => { setLogs((prev) => { @@ -249,8 +252,18 @@ export function useTenebra(): Tenebra { }, []); const refreshStatus = useCallback(async () => { - applySnapshot(await api.status()); - }, [applySnapshot]); + const eventSeq = stateEventSeq.current; + const lossSeq = serviceLossSeq.current; + const next = await api.status(); + setState((prev) => foldSnapshot(prev, eventSeq === stateEventSeq.current ? next : { + ...next, state: prev.state, node: prev.node, error: prev.error, + protection: prev.protection ?? next.protection, + })); + if (lossSeq === serviceLossSeq.current) { + serviceLost.current = false; + setCoreError(null); + } + }, []); // Initial load and event wiring. Unlisten handles are resolved // asynchronously, so we guard against tearing down before they arrive. @@ -258,6 +271,9 @@ export function useTenebra(): Tenebra { let active = true; const unlisteners: UnlistenFn[] = []; let retryTimer: ReturnType | null = null; + let recoveryTimer: ReturnType | null = null; + let recovering = false; + let recoveryDelay = BOOTSTRAP_RETRY_MS; let retryDelay = BOOTSTRAP_RETRY_MS; // Once the core has pushed a state event it is fresher than any snapshot // still in flight; a status that resolves (or is retried) afterwards must @@ -266,6 +282,27 @@ export function useTenebra(): Tenebra { // The console gets one line per outage, not one per retry. let announcedFailure = false; + const recover = async () => { + if (!active || recovering) return; + if (recoveryTimer) clearTimeout(recoveryTimer); + recoveryTimer = null; + recovering = true; + try { + await refreshStatus(); + } catch (error) { + if (active) setCoreError(reasonOf(error)); + } finally { + recovering = false; + if (active && serviceLost.current) scheduleRecovery(); + else recoveryDelay = BOOTSTRAP_RETRY_MS; + } + }; + const scheduleRecovery = () => { + if (recoveryTimer || recovering || !active) return; + recoveryTimer = setTimeout(() => { recoveryTimer = null; void recover(); }, recoveryDelay); + recoveryDelay = Math.min(recoveryDelay * 2, BOOTSTRAP_RETRY_MAX_MS); + }; + const applyTraffic = (e: TrafficEvent) => { setTraffic({ up: e.up, @@ -286,11 +323,23 @@ export function useTenebra(): Tenebra { const subs = await Promise.all([ onState((e) => { sawStateEvent = true; + stateEventSeq.current++; + // The Rust relay emits these while the daemon pipe is unavailable. + // Its synthetic phase does not confirm either a tunnel or guard. + if (/reconnecting to the tenebra service|lost the connection to the tenebra service/i.test(e.error ?? "")) { + serviceLost.current = true; + serviceLossSeq.current++; + setCoreError(e.error!); + scheduleRecovery(); + } else if (serviceLost.current) { + void recover(); + } setState((prev) => ({ ...prev, state: e.state, node: e.node ?? prev.node, error: e.error, + protection: e.protection ?? prev.protection, })); // A clean disconnect zeroes the live counters. if (e.state === "idle") { @@ -341,6 +390,7 @@ export function useTenebra(): Tenebra { // silent. Failing this once used to abort the whole effect: no ready, no // profiles, no subscriptions — an app that looked alive and did nothing. const load = async () => { + const lossSeq = serviceLossSeq.current; try { const [initialState, initialProfiles] = await Promise.all([ api.status(), @@ -355,10 +405,14 @@ export function useTenebra(): Tenebra { state: prev.state, node: prev.node, error: prev.error, + protection: prev.protection ?? initialState.protection, }) : foldSnapshot(prev, initialState)); setProfiles(initialProfiles); - setCoreError(null); + if (lossSeq === serviceLossSeq.current) { + serviceLost.current = false; + setCoreError(null); + } setReady(true); if (announcedFailure) { announcedFailure = false; @@ -392,9 +446,10 @@ export function useTenebra(): Tenebra { clearTimeout(retryTimer); retryTimer = null; } + if (recoveryTimer) clearTimeout(recoveryTimer); unlisteners.forEach((u) => u()); }; - }, [appendLog, applySnapshot]); + }, [appendLog, refreshStatus]); const connect = useCallback( async ( diff --git a/ui-desktop/src/styles/shell.css b/ui-desktop/src/styles/shell.css index 4b573ee0..349b83d2 100644 --- a/ui-desktop/src/styles/shell.css +++ b/ui-desktop/src/styles/shell.css @@ -99,33 +99,31 @@ color: var(--signal); } -/* ── kill-switch banner ── */ -.kill-banner { - background: var(--signal); - color: var(--on-signal); - padding: 6px 22px; - font-size: var(--fs-label); - letter-spacing: 0.1em; - text-transform: uppercase; - font-weight: 600; - text-align: center; - /* Drops in from under the top bar when armed. Neutralised by reduced-motion. */ - animation: kill-banner-in var(--d-slow) var(--e-out) backwards; -} -@keyframes kill-banner-in { - from { - opacity: 0; - transform: translateY(-100%); - } - to { - opacity: 1; - transform: translateY(0); - } +/* Native protection evidence, separate from the requested toggle. */ +.protection-banner { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 12px; + padding: 10px 18px; + border-bottom: 1px solid var(--line); + color: var(--text-dim); + background: var(--surface); + font-size: 12px; +} +.protection-copy { flex: 1; min-width: 0; } +.protection-copy p { margin: 0; line-height: 1.5; } +.protection-copy pre { white-space: pre-wrap; overflow-wrap: anywhere; } +.protection-copy details { margin-top: 6px; } +.protection-actions { display: flex; flex-wrap: wrap; gap: 8px; } +.protection-banner[data-protection="active"] { color: var(--good); } +.protection-banner[data-protection="blocked"], +.protection-banner[data-protection="error"] { color: var(--signal); } +@media (max-width: 720px) { + .protection-banner { align-items: flex-start; flex-direction: column; } } - /* ── update banner — the update check found a newer release ── */ -/* Quiet by design: a bordered row in the warn tint, not the kill banner's - filled alarm. An update is a heads-up, not an incident. The daemon-skew +/* Quiet by design: a bordered row in the warn tint. The daemon-skew banner (a stale privileged daemon behind an updated app) shares these classes: same severity, same row. */ .update-banner { From 5842ff1211c74b19844ceae86c3aefc58f2f4f1c Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:59:35 +0300 Subject: [PATCH 14/56] fix(proxy): recover retained leases after same-user relogon --- core/control/session_proxy.go | 13 +++++- core/control/session_proxy_rebind_test.go | 54 +++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 core/control/session_proxy_rebind_test.go diff --git a/core/control/session_proxy.go b/core/control/session_proxy.go index d8e468ee..057335ad 100644 --- a/core/control/session_proxy.go +++ b/core/control/session_proxy.go @@ -1,5 +1,7 @@ package control +import "errors" + // 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 { @@ -35,7 +37,16 @@ func (p *sessionSystemProxy) Disable() error { return nil } if err := p.ops.Run(*p.owner, "restore", ""); err != nil { - return err + // A logout destroys the old WTS session. Its HKCU lease still belongs + // to the same SID when that user logs on again with a new session ID. + current, currentErr := p.ops.Current() + if currentErr != nil || current.SID == "" || current.SID != p.owner.SID || current.Session == p.owner.Session { + return errors.Join(err, currentErr) + } + p.owner = ¤t + if retryErr := p.ops.Run(current, "restore", ""); retryErr != nil { + return errors.Join(err, retryErr) + } } p.owner = nil return nil diff --git a/core/control/session_proxy_rebind_test.go b/core/control/session_proxy_rebind_test.go new file mode 100644 index 00000000..e6971066 --- /dev/null +++ b/core/control/session_proxy_rebind_test.go @@ -0,0 +1,54 @@ +package control + +import ( + "errors" + "testing" +) + +type logonProxySessions struct { + current proxyUser + hasLease bool + restoreUsers []proxyUser +} + +func (m *logonProxySessions) Current() (proxyUser, error) { return m.current, nil } +func (m *logonProxySessions) Read(proxyUser) (proxyState, error) { return proxyState{}, nil } +func (m *logonProxySessions) HasLease(proxyUser) (bool, error) { return m.hasLease, nil } +func (m *logonProxySessions) Run(u proxyUser, action, _ string) error { + if u != m.current { + return errors.New("WTSQueryUserToken: prior session is gone") + } + if action == "restore" { + m.restoreUsers = append(m.restoreUsers, u) + } + m.hasLease = action == "apply" + return nil +} + +func TestUserProxyReconcileRebindsOnlySameSIDNewSession(t *testing.T) { + for _, sameUser := range []bool{true, false} { + t.Run(map[bool]string{true: "same SID", false: "different SID"}[sameUser], func(t *testing.T) { + original := proxyUser{SID: "S-1-5-21-1000", Session: 1} + m := &logonProxySessions{current: original} + p := &sessionSystemProxy{ops: m} + if err := p.Enable("127.0.0.1:2080"); err != nil { + t.Fatal(err) + } + m.current.Session = 2 + if !sameUser { + m.current.SID = "S-1-5-21-2000" + } + found, err := p.Reconcile() + if !found { + t.Fatal("retained cleanup was lost") + } + if sameUser { + if err != nil || p.owner != nil || m.hasLease || len(m.restoreUsers) != 1 || m.restoreUsers[0] != m.current { + t.Fatalf("same user could not recover at new logon: owner=%+v error=%v", p.owner, err) + } + } else if err == nil || p.owner == nil || *p.owner != original || !m.hasLease || len(m.restoreUsers) != 0 { + t.Fatal("cleanup was transferred to another SID") + } + }) + } +} From 150e86db8ab2c03086ff1cc6f988842cdcbc834f Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:59:36 +0300 Subject: [PATCH 15/56] fix(proxy): preserve later external proxy selections --- core/control/user_proxy_lease.go | 33 +++++++++++++++---- core/control/user_proxy_lease_test.go | 46 ++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/core/control/user_proxy_lease.go b/core/control/user_proxy_lease.go index 3db957ff..1d739088 100644 --- a/core/control/user_proxy_lease.go +++ b/core/control/user_proxy_lease.go @@ -17,9 +17,10 @@ type userProxySettings struct { } type userProxyLease struct { - Version int `json:"version"` - Before userProxySettings `json:"before"` - Applied userProxySettings `json:"applied"` + Version int `json:"version"` + Before userProxySettings `json:"before"` + Applied userProxySettings `json:"applied"` + Confirmed bool `json:"confirmed,omitempty"` } type userProxyOperations interface { @@ -54,6 +55,13 @@ func applyUserProxy(o userProxyOperations, target string) error { return err } if lease.Version == 1 && lease.Applied.Server == target && current == lease.Applied { + if !lease.Confirmed { + confirmed := *lease + confirmed.Confirmed = true + if err := o.Save(confirmed); err != nil { + return fmt.Errorf("confirm existing user proxy snapshot: %w", err) + } + } return nil } if err := restoreUserProxy(o); err != nil { @@ -65,7 +73,8 @@ func applyUserProxy(o userProxyOperations, target string) error { 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 { + newLease := userProxyLease{Version: 1, Before: before, Applied: want} + if err := o.Save(newLease); err != nil { return fmt.Errorf("save user proxy rollback snapshot: %w", err) } if err := o.Write(want); err != nil { @@ -78,6 +87,12 @@ func applyUserProxy(o userProxyOperations, target string) error { } return errors.Join(err, restoreUserProxy(o)) } + // Persist successful readback before reporting success. Recovery can then + // distinguish a later switch back to Before.Server from a partial apply. + newLease.Confirmed = true + if err := o.Save(newLease); err != nil { + return errors.Join(fmt.Errorf("confirm user proxy snapshot: %w", err), restoreUserProxy(o)) + } return nil } @@ -98,8 +113,14 @@ func restoreUserProxy(o userProxyOperations) error { } // 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() + if current.Server != lease.Applied.Server { + if lease.Confirmed || current == lease.Before || current.Server != lease.Before.Server { + return o.Delete() + } + // With an unconfirmed/crashed apply, a mix of old and new settings + // at the old server could be a partial option write or a later user + // edit. There is no evidence to safely undo it automatically. + return errors.New("ambiguous user proxy snapshot; previous server has changed settings, automatic restore refused") } want := current // Restore only fields still equal to our write. This also rolls back a diff --git a/core/control/user_proxy_lease_test.go b/core/control/user_proxy_lease_test.go index 4b98bffa..e261189f 100644 --- a/core/control/user_proxy_lease_test.go +++ b/core/control/user_proxy_lease_test.go @@ -12,6 +12,8 @@ type memoryUserProxy struct { failWrite int failSave bool failDelete bool + saves int + failSaveAt int } func (m *memoryUserProxy) Read() (userProxySettings, error) { return m.settings, nil } @@ -26,12 +28,54 @@ func (m *memoryUserProxy) Write(s userProxySettings) error { } func (m *memoryUserProxy) Load() (*userProxyLease, error) { return m.lease, nil } func (m *memoryUserProxy) Save(l userProxyLease) error { - if m.failSave { + m.saves++ + if m.failSave || m.saves == m.failSaveAt { return errors.New("snapshot unavailable") } m.lease = &l return nil } + +func TestUserProxyPreservesExternalReturnToOriginalServer(t *testing.T) { + before := userProxySettings{Flags: 9, Server: "corp.example:8080", PAC: "https://config.example/old.pac"} + m := &memoryUserProxy{settings: before} + if err := applyUserProxy(m, "127.0.0.1:2080"); err != nil { + t.Fatal(err) + } + changed := userProxySettings{Flags: 3, Server: before.Server, Bypass: "intranet"} + m.settings = changed + if err := restoreUserProxy(m); err != nil { + t.Fatal(err) + } + if m.settings != changed || m.lease != nil { + t.Fatalf("cleanup overwrote later corporate configuration: got %+v, want %+v", m.settings, changed) + } +} + +func TestUserProxyConfirmationFailureRollsBack(t *testing.T) { + before := userProxySettings{Flags: 9, PAC: "https://config.example/proxy.pac"} + m := &memoryUserProxy{settings: before, failSaveAt: 2} + if err := applyUserProxy(m, "127.0.0.1:2080"); err == nil { + t.Fatal("accepted an apply without durable confirmation") + } + if m.settings != before || m.lease != nil { + t.Fatal("failed confirmation did not restore the original configuration") + } +} + +func TestUserProxyUnconfirmedAmbiguousOriginalServerIsPreserved(t *testing.T) { + before := userProxySettings{Flags: 9, Server: "corp.example:8080", PAC: "https://config.example/old.pac"} + applied := userProxySettings{Flags: 3, Server: "127.0.0.1:2080", Bypass: "localhost;127.0.0.1;[::1]"} + changed := applied + changed.Server = before.Server + m := &memoryUserProxy{settings: changed, lease: &userProxyLease{Version: 1, Before: before, Applied: applied}} + if err := restoreUserProxy(m); err == nil { + t.Fatal("ambiguous partial write/external switch should retain cleanup for repair") + } + if m.settings != changed || m.lease == nil || m.writes != 0 { + t.Fatal("ambiguous state was changed or ownership discarded") + } +} func (m *memoryUserProxy) Delete() error { if m.failDelete { return errors.New("delete failed") From 5bcb7f167e2d02cbe6b38d476f9a0ca14b2b0b4e Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:08:39 +0300 Subject: [PATCH 16/56] fix(proxy): isolate helper locking in a protected user directory --- core/control/proxy_lock_policy.go | 61 ++++++++++ core/control/proxy_lock_policy_test.go | 73 ++++++++++++ core/control/proxy_lock_windows.go | 153 +++++++++++++++++++++++++ core/control/proxy_windows.go | 15 +-- 4 files changed, 290 insertions(+), 12 deletions(-) create mode 100644 core/control/proxy_lock_policy.go create mode 100644 core/control/proxy_lock_policy_test.go create mode 100644 core/control/proxy_lock_windows.go diff --git a/core/control/proxy_lock_policy.go b/core/control/proxy_lock_policy.go new file mode 100644 index 00000000..13be61e6 --- /dev/null +++ b/core/control/proxy_lock_policy.go @@ -0,0 +1,61 @@ +package control + +import ( + "errors" + "fmt" + "strings" +) + +// Only a local, unambiguous KnownFolder path is accepted. Do not derive this +// location from LOCALAPPDATA, TEMP, a working directory or a caller argument. +func proxyLockNTPath(path string) (string, error) { + if len(path) < 4 || !((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z')) || path[1:3] != `:\` { + return "", errors.New("user proxy lock requires an absolute local known folder") + } + for _, part := range strings.Split(path[3:], `\`) { + if part == "" || part == "." || part == ".." || strings.TrimRight(part, ". ") != part || strings.ContainsAny(part, ":/\x00") { + return "", errors.New("ambiguous user proxy lock path") + } + device := strings.ToUpper(strings.SplitN(part, ".", 2)[0]) + if device == "CON" || device == "PRN" || device == "AUX" || device == "NUL" || device == "CONIN$" || device == "CONOUT$" || (len(device) == 4 && (strings.HasPrefix(device, "COM") || strings.HasPrefix(device, "LPT")) && device[3] >= '1' && device[3] <= '9') { + return "", errors.New("device name in user proxy lock path") + } + } + return `\??\` + path, nil +} + +type proxyLockGrant struct { + SID string + Mask uint32 +} + +type proxyLockNode struct { + Directory, Reparse, MultipleLinks bool + Owner string + DACLPresent, Protected bool + Grants []proxyLockGrant +} + +func trustedProxyLockSID(sid, user string) bool { + return sid != "" && (sid == user || sid == "S-1-5-18" || sid == "S-1-5-32-544") +} + +// Same-user and administrator writes are already authorized to change that +// user's proxy. Everyone else must be unable to replace the directory or lock. +func validateProxyLockNode(n proxyLockNode, user string, directory, private bool) error { + if user == "" || n.Directory != directory || n.Reparse || (!directory && n.MultipleLinks) { + return errors.New("user proxy lock path has an unexpected file type or link") + } + if !trustedProxyLockSID(n.Owner, user) || !n.DACLPresent || (private && !n.Protected) { + return errors.New("user proxy lock ownership or private DACL is unsafe") + } + // FILE_WRITE_DATA/APPEND_DATA/WRITE_EA/DELETE_CHILD/WRITE_ATTRIBUTES, + // DELETE/WRITE_DAC/WRITE_OWNER, GENERIC_WRITE/ALL and MAXIMUM_ALLOWED. + const mutation = 0x2 | 0x4 | 0x10 | 0x40 | 0x100 | 0x10000 | 0x40000 | 0x80000 | 0x40000000 | 0x10000000 | 0x02000000 + for _, grant := range n.Grants { + if !trustedProxyLockSID(grant.SID, user) && ((private && grant.Mask != 0) || grant.Mask&mutation != 0) { + return fmt.Errorf("user proxy lock permits access by another identity: %s", grant.SID) + } + } + return nil +} diff --git a/core/control/proxy_lock_policy_test.go b/core/control/proxy_lock_policy_test.go new file mode 100644 index 00000000..7610557f --- /dev/null +++ b/core/control/proxy_lock_policy_test.go @@ -0,0 +1,73 @@ +package control + +import ( + "fmt" + "testing" +) + +func TestProxyLockPathRejectsAmbiguousAndRemoteLocations(t *testing.T) { + for _, path := range []string{"", `C:relative`, `\\server\share\Local`, `\\?\C:\Local`, `C:\Users\u\..\other`, `C:\Users\u\Local.`, `C:\Users\u\Local `, `C:\Users\u\Local:stream`, `C:\Users\NUL\Local`, `C:\Users\u\\Local`, "C:\\Users\\u\\Local\x00"} { + if _, err := proxyLockNTPath(path); err == nil { + t.Errorf("accepted unsafe known folder %q", path) + } + } + got, err := proxyLockNTPath(`C:\Users\Даня\AppData\Local`) + if err != nil || got != `\??\C:\Users\Даня\AppData\Local` { + t.Fatalf("valid Unicode known folder failed: %q %v", got, err) + } +} + +func TestProxyLockPolicyRejectsOtherUsersAndReparsePoints(t *testing.T) { + const user = "S-1-5-21-1000" + good := proxyLockNode{Directory: true, Owner: user, DACLPresent: true, Protected: true, + Grants: []proxyLockGrant{{SID: user, Mask: 0x1f01ff}, {SID: "S-1-5-18", Mask: 0x1f01ff}, {SID: "S-1-5-32-544", Mask: 0x1f01ff}}} + if err := validateProxyLockNode(good, user, true, true); err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + change func(*proxyLockNode) + }{ + {"foreign owner", func(n *proxyLockNode) { n.Owner = "S-1-5-21-2000" }}, + {"null DACL", func(n *proxyLockNode) { n.DACLPresent = false }}, + {"inheritable private DACL", func(n *proxyLockNode) { n.Protected = false }}, + {"junction", func(n *proxyLockNode) { n.Reparse = true }}, + {"file instead of directory", func(n *proxyLockNode) { n.Directory = false }}, + {"foreign read grant", func(n *proxyLockNode) { n.Grants = append(n.Grants, proxyLockGrant{SID: "S-1-1-0", Mask: 0x80000000}) }}, + } { + t.Run(test.name, func(t *testing.T) { + n := good + test.change(&n) + if validateProxyLockNode(n, user, true, true) == nil { + t.Fatal("unsafe private lock location accepted") + } + }) + } + file := good + file.Directory = false + if err := validateProxyLockNode(file, user, false, true); err != nil { + t.Fatal(err) + } + file.MultipleLinks = true + if validateProxyLockNode(file, user, false, true) == nil { + t.Fatal("hard-linked lock file accepted") + } +} + +func TestProxyLockKnownFolderPermitsReadOnlyButNotForeignMutation(t *testing.T) { + const user = "S-1-5-21-1000" + n := proxyLockNode{Directory: true, Owner: user, DACLPresent: true, + Grants: []proxyLockGrant{{SID: user, Mask: 0x1f01ff}, {SID: "S-1-5-32-545", Mask: 0x1200a9}}} + if err := validateProxyLockNode(n, user, true, false); err != nil { + t.Fatal(err) + } + for _, mask := range []uint32{0x2, 0x4, 0x10, 0x40, 0x100, 0x10000, 0x40000, 0x80000, 0x40000000, 0x10000000, 0x02000000} { + t.Run(fmt.Sprintf("%#x", mask), func(t *testing.T) { + bad := n + bad.Grants = append(bad.Grants, proxyLockGrant{SID: "S-1-5-21-2000", Mask: mask}) + if validateProxyLockNode(bad, user, true, false) == nil { + t.Errorf("foreign mutation mask %#x accepted", mask) + } + }) + } +} diff --git a/core/control/proxy_lock_windows.go b/core/control/proxy_lock_windows.go new file mode 100644 index 00000000..5593b319 --- /dev/null +++ b/core/control/proxy_lock_windows.go @@ -0,0 +1,153 @@ +//go:build windows + +package control + +import ( + "errors" + "fmt" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +const proxyLockDirectory = "Tenebra-private-proxy" + +// The parent and child handles remain open throughout the operation. Children +// are opened relative to verified handles, so renaming any path ancestor cannot +// redirect a subsequent create. OBJ_DONT_REPARSE rejects junctions/symlinks. +// No global kernel object is exposed to precreation by another logged-in user. +func acquireUserProxyLock(sid string) (func(), error) { + basePath, err := windows.KnownFolderPath(windows.FOLDERID_LocalAppData, 0) + if err != nil { + return nil, fmt.Errorf("resolve user proxy lock known folder: %w", err) + } + ntPath, err := proxyLockNTPath(basePath) + if err != nil { + return nil, err + } + var handles []windows.Handle + release := func() { + for i := len(handles) - 1; i >= 0; i-- { + windows.CloseHandle(handles[i]) + } + handles = nil + } + ok := false + defer func() { + if !ok { + release() + } + }() + base, err := openProxyLockNode(0, ntPath, true, false, nil) + if err != nil { + return nil, fmt.Errorf("open user proxy lock known folder without reparse: %w", err) + } + handles = append(handles, base) + if err := checkProxyLockHandle(base, sid, true, false); err != nil { + return nil, err + } + sd, err := windows.SecurityDescriptorFromString("O:" + sid + "D:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;" + sid + ")") + if err != nil { + return nil, err + } + dir, err := openProxyLockNode(base, proxyLockDirectory, true, true, sd) + if err != nil { + return nil, fmt.Errorf("open private user proxy lock directory: %w", err) + } + handles = append(handles, dir) + if err := checkProxyLockHandle(dir, sid, true, true); err != nil { + return nil, err + } + deadline := time.Now().Add(5 * time.Second) + for { + lock, err := openProxyLockNode(dir, "operation.lock", false, true, sd) + if err == nil { + handles = append(handles, lock) + if err := checkProxyLockHandle(lock, sid, false, true); err != nil { + return nil, err + } + ok = true + return release, nil + } + if !errors.Is(err, windows.STATUS_SHARING_VIOLATION) || !time.Now().Before(deadline) { + return nil, fmt.Errorf("acquire private user proxy lock: %w", err) + } + time.Sleep(20 * time.Millisecond) + } +} + +func openProxyLockNode(parent windows.Handle, name string, directory, create bool, sd *windows.SECURITY_DESCRIPTOR) (windows.Handle, error) { + objectName, err := windows.NewNTUnicodeString(name) + if err != nil { + return 0, err + } + oa := windows.OBJECT_ATTRIBUTES{ + RootDirectory: parent, ObjectName: objectName, + Attributes: windows.OBJ_CASE_INSENSITIVE | windows.OBJ_DONT_REPARSE, + SecurityDescriptor: sd, + } + oa.Length = uint32(unsafe.Sizeof(oa)) + access := uint32(windows.READ_CONTROL | windows.FILE_READ_ATTRIBUTES | windows.SYNCHRONIZE) + options := uint32(windows.FILE_SYNCHRONOUS_IO_NONALERT | windows.FILE_NON_DIRECTORY_FILE) + share := uint32(0) // exclusive file handle; released automatically after a crash + if directory { + access |= windows.FILE_TRAVERSE + options = windows.FILE_SYNCHRONOUS_IO_NONALERT | windows.FILE_DIRECTORY_FILE + share = windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE // never allow delete/rename + } else { + access |= windows.FILE_READ_DATA | windows.FILE_WRITE_DATA + } + disposition := uint32(windows.FILE_OPEN) + if create { + disposition = windows.FILE_OPEN_IF // never truncate an existing object + } + var handle windows.Handle + var status windows.IO_STATUS_BLOCK + err = windows.NtCreateFile(&handle, access, &oa, &status, nil, windows.FILE_ATTRIBUTE_NORMAL, share, disposition, options, 0, 0) + return handle, err +} + +func checkProxyLockHandle(handle windows.Handle, user string, directory, private bool) error { + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err != nil { + return err + } + sd, err := windows.GetSecurityInfo(handle, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION) + if err != nil { + return err + } + owner, _, err := sd.Owner() + if err != nil || owner == nil { + return errors.New("user proxy lock owner unavailable") + } + acl, _, err := sd.DACL() + if err != nil || acl == nil { + return errors.New("user proxy lock DACL unavailable") + } + control, _, err := sd.Control() + if err != nil { + return err + } + node := proxyLockNode{ + Directory: info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0, + Reparse: info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0, + MultipleLinks: info.NumberOfLinks != 1, Owner: owner.String(), + DACLPresent: true, Protected: control&windows.SE_DACL_PROTECTED != 0, + } + for i := uint32(0); i < uint32(acl.AceCount); i++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(acl, i, &ace); err != nil { + return err + } + if ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 || ace.Header.AceType == windows.ACCESS_DENIED_ACE_TYPE { + continue // neither grants access to this object + } + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + return errors.New("unsupported user proxy lock ACL entry") + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + node.Grants = append(node.Grants, proxyLockGrant{SID: sid.String(), Mask: uint32(ace.Mask)}) + } + return validateProxyLockNode(node, user, directory, private) +} diff --git a/core/control/proxy_windows.go b/core/control/proxy_windows.go index 8ef4ef17..38903255 100644 --- a/core/control/proxy_windows.go +++ b/core/control/proxy_windows.go @@ -224,20 +224,11 @@ func runUserProxyAction(action, target string) error { 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) { + release, err := acquireUserProxyLock(sid) + if err != nil { 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) + defer release() ops := wininetProxyOperations{} if action == "apply" { return applyUserProxy(ops, target) From 2b2606174c4fbca64495d64999b199ab76520dac Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:24:44 +0300 Subject: [PATCH 17/56] fix(desktop): skip CSS-hidden controls in modal focus boundaries --- ui-desktop/src/components/ModalLayer.test.tsx | 17 +++++++++++++++++ ui-desktop/src/components/ModalLayer.tsx | 12 +++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/ui-desktop/src/components/ModalLayer.test.tsx b/ui-desktop/src/components/ModalLayer.test.tsx index 2a48397f..acec2eaa 100644 --- a/ui-desktop/src/components/ModalLayer.test.tsx +++ b/ui-desktop/src/components/ModalLayer.test.tsx @@ -5,6 +5,23 @@ import { UpdateConfirm } from "./UpdateConfirm"; import { ModalLayer } from "./ModalLayer"; import { renderWithProviders } from "../test/renderWithProviders"; +it("skips controls hidden by responsive CSS when entering and wrapping focus", () => { + renderWithProviders( {}} role="dialog" aria-label="Responsive settings"> + +
+ + + +
); + const first = screen.getByRole("button", { name: "First visible" }); + const last = screen.getByRole("button", { name: "Last visible" }); + expect(first).toHaveFocus(); + fireEvent.keyDown(first, { key: "Tab", shiftKey: true }); + expect(last).toHaveFocus(); + fireEvent.keyDown(last, { key: "Tab" }); + expect(first).toHaveFocus(); +}); + it("enters the dialog, contains Tab, makes the background inert and restores focus", () => { function Example() { const [open, setOpen] = useState(false); diff --git a/ui-desktop/src/components/ModalLayer.tsx b/ui-desktop/src/components/ModalLayer.tsx index e5c023b1..4b70a089 100644 --- a/ui-desktop/src/components/ModalLayer.tsx +++ b/ui-desktop/src/components/ModalLayer.tsx @@ -22,7 +22,17 @@ export function ModalLayer({ onClose, children, ...props }: HTMLAttributes [...element.querySelectorAll(focusable)] - .filter((el) => !el.closest('[inert], [hidden], [aria-hidden="true"]')); + .filter((el) => { + if (el.closest('[inert], [hidden], [aria-hidden="true"]')) return false; + // Responsive close buttons and whole panels can be hidden by CSS, + // without a hidden attribute. Focusing one leaves the browser on BODY. + for (let node: HTMLElement | null = el; node; node = node.parentElement) { + const style = getComputedStyle(node); + if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return false; + if (node === element) break; + } + return true; + }); const focusFirst = () => (controls()[0] ?? element).focus(); focusFirst(); const isTop = () => layers[layers.length - 1] === element; From b538f5c4545d03e8012868d14f1a776bddda848a Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:27:36 +0300 Subject: [PATCH 18/56] fix(desktop): keep failed TCP checks readable and selectable --- ui-desktop/src/components/ServerList.test.tsx | 4 ++-- ui-desktop/src/i18n/strings.ts | 8 ++++---- ui-desktop/src/styles/servers.css | 16 +++------------- 3 files changed, 9 insertions(+), 19 deletions(-) diff --git a/ui-desktop/src/components/ServerList.test.tsx b/ui-desktop/src/components/ServerList.test.tsx index 2db93eca..0f7a05fe 100644 --- a/ui-desktop/src/components/ServerList.test.tsx +++ b/ui-desktop/src/components/ServerList.test.tsx @@ -80,9 +80,9 @@ describe("ServerList", () => { it("reflects online and showing counts", () => { renderWithProviders(); - // Two of three rows are live → the heading reads "Nodes · 2 online". + // A TCP response describes reachability, not a verified VPN handshake. expect( - screen.getByRole("heading", { name: /Nodes · 2 online/ }), + screen.getByRole("heading", { name: /Nodes · 2 TCP reachable/ }), ).toBeInTheDocument(); // All three rows are visible with no filter → "showing 3". expect( diff --git a/ui-desktop/src/i18n/strings.ts b/ui-desktop/src/i18n/strings.ts index 015b007f..486b3935 100644 --- a/ui-desktop/src/i18n/strings.ts +++ b/ui-desktop/src/i18n/strings.ts @@ -928,7 +928,7 @@ const en: Strings = { }, servers: { title: "Nodes", - online: "online", + online: "TCP reachable", showing: "showing", regionAll: "all", regionEurope: "europe", @@ -936,7 +936,7 @@ const en: Strings = { regionAsiaPac: "asia-pac", searchPlaceholder: "search node · de-fra", emptyFilter: "no nodes match this filter", - down: "down", + down: "no TCP", addSub: "+ add", noNodes: "this subscription has no nodes", auto: "AUTO", @@ -1512,7 +1512,7 @@ const ru: Strings = { }, servers: { title: "Узлы", - online: "онлайн", + online: "ответили TCP", showing: "показано", regionAll: "все", regionEurope: "европа", @@ -1520,7 +1520,7 @@ const ru: Strings = { regionAsiaPac: "азия", searchPlaceholder: "поиск узла · de-fra", emptyFilter: "нет узлов под этот фильтр", - down: "недост.", + down: "нет TCP", addSub: "+ добавить", noNodes: "в этой подписке нет узлов", auto: "АВТО", diff --git a/ui-desktop/src/styles/servers.css b/ui-desktop/src/styles/servers.css index 4ea05304..93d59a28 100644 --- a/ui-desktop/src/styles/servers.css +++ b/ui-desktop/src/styles/servers.css @@ -360,17 +360,8 @@ .srv-row.active .srv-node { color: var(--signal); } -/* A dead node dims whole and stops responding to hover — nothing to select. */ -.srv-row.is-dead { - cursor: default; - opacity: 0.55; -} -.srv-row.is-dead:hover { - background: transparent; -} -.srv-row.is-dead:hover::before { - transform: scaleY(0); -} +/* A failed TCP check still allows a manual connection. Keep the row readable + and interactive; the ping label and empty signal bars carry the result. */ .srv-name { display: flex; flex-direction: column; @@ -456,8 +447,7 @@ Filled `backwards`, not `both`. A filled final frame outranks ordinary author declarations, so `both` left every row pinned at the keyframe's opacity: 1 for - good — which is why `.srv-row.is-dead { opacity: 0.55 }` never dimmed a dead - node at all. `backwards` still holds the first frame through the stagger delay + good. `backwards` still holds the first frame through the stagger delay (the part an entrance actually needs) and hands the row back to the stylesheet the moment it lands. The same applies to every entrance in the app. */ @keyframes srv-row-in { From 08ae1eaef1429b3ceca6b49586384f49cbccde12 Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:26:20 +0300 Subject: [PATCH 19/56] fix(installer): verify and release owned protection on explicit uninstall --- .github/workflows/ci.yml | 2 + docs/delivery-acceptance.md | 31 +++ scripts/embed-uninstall-helper.mjs | 48 +++++ scripts/test-uninstall-policy.ps1 | 25 +++ scripts/uninstall-helper.test.mjs | 26 +++ ui-desktop/src-tauri/installer-hooks.nsh | 16 +- .../installer-release-protection.nsh | 184 ++++++++++++++++++ .../installer-release-protection.ps1 | 88 +++++++++ ui-desktop/src-tauri/installer-wfp-probe.nsh | 60 ++++++ 9 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 scripts/embed-uninstall-helper.mjs create mode 100644 scripts/test-uninstall-policy.ps1 create mode 100644 scripts/uninstall-helper.test.mjs create mode 100644 ui-desktop/src-tauri/installer-release-protection.nsh create mode 100644 ui-desktop/src-tauri/installer-release-protection.ps1 create mode 100644 ui-desktop/src-tauri/installer-wfp-probe.nsh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd3a55af..8147c103 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,6 +91,8 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy + - name: Check uninstall trust policy without native side effects + run: powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts/test-uninstall-policy.ps1 - name: Check Rust formatting working-directory: ui-desktop/src-tauri run: cargo fmt --check diff --git a/docs/delivery-acceptance.md b/docs/delivery-acceptance.md index 79b5625a..4b9e622c 100644 --- a/docs/delivery-acceptance.md +++ b/docs/delivery-acceptance.md @@ -37,6 +37,37 @@ 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. +Explicit uninstall (`UpdateMode <> 1`) stops the service first, then queries only +the fixed T05 provider `fcb43b44-9358-4cd7-a998-9e7f822d5248` and sublayer +`fcb43b45-9358-4cd7-a998-9e7f822d5248`. Both exact WFP NOT_FOUND results permit +legacy removal without executing an old core. Query failures are not absence. +If either object exists, the installed `tenebra-core.exe +--release-host-protection` must confirm removal and a second probe must find both +objects absent before the service registration or binaries are removed. The +core alone checks ownership (`tenebra/persistent-host-guard/v1`) and deletes its +objects. A missing, unsupported or failing core while policy exists aborts +uninstall and retains the binary for repair. Cleanup has a 20-second child-process +deadline; the containing PowerShell invocation has an NSIS 35-second timeout. +The read-only WFP probe itself uses synchronous local Windows API calls. + +The embedded cleanup wrapper executes only the exact installed core. It checks +all path ancestors for reparse points and administrator/SYSTEM/TrustedInstaller +ownership plus ACLs excluding unprivileged mutation, then holds the EXE open +against writes and replacement while running the fixed cleanup command. Unsafe +custom install locations require repair into an administrator-controlled path. +No installed or temporary PowerShell script is executed; the reviewed source is +embedded as constant chunks. Regenerate its include with +`node scripts/embed-uninstall-helper.mjs`; CI checks the source and embed agree. + +Ordinary update and repair in update mode preserve persistent protection. The +first upgrade uses the previous uninstaller's compiled hooks and installs these +new hooks for later removals. Rolling back to a pre-T05 core requires explicitly +disabling/releasing host protection with a T05-capable core first and confirming +its provider/sublayer are absent; an older uninstaller cannot know how to remove +new policy. VM acceptance must cover legacy with no policy, current owned policy, +missing/old core with policy, query failure, cleanup failure/timeout, unsafe EXE +locations, ordinary update/repair retention, and rollback preparation. + ## Release channels All platform jobs upload into one draft. Only `publish` may open the release, diff --git a/scripts/embed-uninstall-helper.mjs b/scripts/embed-uninstall-helper.mjs new file mode 100644 index 00000000..40fed575 --- /dev/null +++ b/scripts/embed-uninstall-helper.mjs @@ -0,0 +1,48 @@ +import fs from 'node:fs'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +export const sourcePath = new URL('../ui-desktop/src-tauri/installer-release-protection.ps1', import.meta.url); +export const outputPath = new URL('../ui-desktop/src-tauri/installer-release-protection.nsh', import.meta.url); + +function nsisString(text) { + return text.replaceAll('$', () => '$$').replaceAll('"', '$\\"'); +} + +export function renderUninstallHelper(source) { + const encoded = Buffer.from(source.replaceAll('\r\n', '\n'), 'utf16le').toString('base64'); + const chunks = encoded.match(/.{1,512}/g); + const names = chunks.map((_, index) => `TENEBRA_RELEASE_PS${index}`); + const set = (name, value) => [ + ` StrCpy $0 "${value}"`, + ` System::Call 'kernel32::SetEnvironmentVariableW(w "${name}", w r0) i.r0'`, + ' ${If} $0 = 0', + " System::Call 'kernel32::GetLastError() i.r0'", + ' !insertmacro TenebraServiceFailure "prepare protection cleanup for"', + ' ${EndIf}', + ]; + const driver = `$s=(0..${chunks.length - 1}|ForEach-Object{[Environment]::GetEnvironmentVariable('TENEBRA_RELEASE_PS'+$_)})-join'';& ([ScriptBlock]::Create([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($s))))`; + const execute = ' nsExec::ExecToLog /TIMEOUT=35000 ' + '`"$SYSDIR\\WindowsPowerShell\\v1.0\\powershell.exe" -NoProfile -NonInteractive -Command "' + nsisString(driver) + '"`'; + return [ + '; Generated by scripts/embed-uninstall-helper.mjs. Edit the .ps1 source.', + '; Constant chunks avoid NSIS string limits and execution of a user-writable script.', + '!macro TenebraReleaseHostProtection', + ...set('TENEBRA_RELEASE_CORE', '$INSTDIR\\tenebra-core.exe'), + ...chunks.flatMap((chunk, index) => set(names[index], chunk)), + execute, + ' Pop $0', + ' Push $0', + ...['TENEBRA_RELEASE_CORE', ...names].map(name => ` System::Call 'kernel32::SetEnvironmentVariableW(w "${name}", p 0) i.r0'`), + ' Pop $0', + ' !insertmacro TenebraRequireSuccess "release owned host protection before unregistering"', + '!macroend', + '', + ].join('\n'); +} + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + const output = renderUninstallHelper(fs.readFileSync(sourcePath, 'utf8')); + if (process.argv.includes('--check')) { + if (fs.readFileSync(outputPath, 'utf8').replaceAll('\r\n', '\n') !== output) throw new Error('Run node scripts/embed-uninstall-helper.mjs'); + } else fs.writeFileSync(outputPath, output); + console.log(fileURLToPath(outputPath)); +} diff --git a/scripts/test-uninstall-policy.ps1 b/scripts/test-uninstall-policy.ps1 new file mode 100644 index 00000000..05baaa38 --- /dev/null +++ b/scripts/test-uninstall-policy.ps1 @@ -0,0 +1,25 @@ +$ErrorActionPreference = 'Stop' +. "$PSScriptRoot/../ui-desktop/src-tauri/installer-release-protection.ps1" -PolicyOnly + +function Reject([scriptblock]$Action) { + $rejected = $false + try { & $Action } catch { $rejected = $true } + if (!$rejected) { throw 'Unsafe cleanup policy input was accepted.' } +} + +$paths = @(Get-TenebraCleanupPathChain 'C:\Program Files\Tenebra\tenebra-core.exe') +if ($paths.Count -ne 4 -or $paths[0] -cne 'C:\' -or $paths[3] -cne 'C:\Program Files\Tenebra\tenebra-core.exe') { throw 'Cleanup path chain is not rooted and ordered.' } +foreach ($path in @('', 'C:tenebra-core.exe', '\\host\share\tenebra-core.exe', 'C:\Tenebra\..\tenebra-core.exe', 'C:\Tenebra\other.exe', 'C:\Tenebra\tenebra-core.exe:stream', 'C:\Tenebra.\tenebra-core.exe')) { + Reject { Get-TenebraCleanupPathChain $path } +} + +$trusted = [Security.AccessControl.RawSecurityDescriptor]::new('O:BAG:BAD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;FR;;;BU)') +Assert-TenebraCleanupAcl $trusted $true +foreach ($sddl in @('O:BUG:BUD:(A;;FA;;;BU)', 'O:BAG:BAD:NO_ACCESS_CONTROL', 'O:BAG:BAD:(A;;FA;;;SY)(A;;FW;;;BU)', 'O:BAG:BAD:(A;;FA;;;SY)(A;;WD;;;BU)', 'O:BAG:BAD:(A;;FA;;;SY)(A;;WO;;;BU)')) { + $bad = [Security.AccessControl.RawSecurityDescriptor]::new($sddl) + Reject { Assert-TenebraCleanupAcl $bad $true } +} +$createChild = [Security.AccessControl.RawSecurityDescriptor]::new('O:BAG:BAD:(A;;FA;;;BA)(A;;0x6;;;BU)') +Assert-TenebraCleanupAcl $createChild $false +Reject { Assert-TenebraCleanupAcl $createChild $true } +Write-Output 'PASS: pure uninstall path and ACL policy; no files, services, processes or WFP objects opened.' diff --git a/scripts/uninstall-helper.test.mjs b/scripts/uninstall-helper.test.mjs new file mode 100644 index 00000000..27c1d576 --- /dev/null +++ b/scripts/uninstall-helper.test.mjs @@ -0,0 +1,26 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { renderUninstallHelper, sourcePath, outputPath } from './embed-uninstall-helper.mjs'; + +test('embedded uninstall helper is exactly the reviewed source and fits NSIS strings', () => { + const source = fs.readFileSync(sourcePath, 'utf8').replaceAll('\r\n', '\n'); + const generated = fs.readFileSync(outputPath, 'utf8').replaceAll('\r\n', '\n'); + assert.equal(generated, renderUninstallHelper(source)); + const chunks = [...generated.matchAll(/StrCpy \$0 "([A-Za-z0-9+/=]{64,})"/g)].map(m => m[1]); + assert.equal(Buffer.from(chunks.join(''), 'base64').toString('utf16le'), source); + assert.ok(generated.split('\n').every(line => line.length < 900)); + assert.ok(generated.includes('$$s=')); // literal PowerShell $, not an NSIS variable + for (let i = 0; i < chunks.length; i++) { + assert.ok(generated.includes(`SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS${i}", p 0)`)); + } +}); + +test('host protection cleanup is exclusive to explicit uninstall before service deletion', () => { + const hooks = fs.readFileSync(new URL('../ui-desktop/src-tauri/installer-hooks.nsh', import.meta.url), 'utf8'); + const uninstall = hooks.slice(hooks.indexOf('!macro NSIS_HOOK_PREUNINSTALL')); + assert.equal((hooks.match(/!insertmacro TenebraReleaseHostProtection/g) ?? []).length, 1); + assert.match(uninstall, /TenebraStopService[\s\S]*\$UpdateMode <> 1[\s\S]*TenebraReleaseHostProtection[\s\S]*sc\.exe" delete tenebra/); + assert.ok(!uninstall.includes('${FileExists}')); // missing core must fail closed + assert.equal((uninstall.match(/!insertmacro TenebraProbeHostProtection/g) ?? []).length, 2); +}); diff --git a/ui-desktop/src-tauri/installer-hooks.nsh b/ui-desktop/src-tauri/installer-hooks.nsh index d5e01f4a..f46149d7 100644 --- a/ui-desktop/src-tauri/installer-hooks.nsh +++ b/ui-desktop/src-tauri/installer-hooks.nsh @@ -37,12 +37,15 @@ ${EndIf} !macroend +!include "${__FILEDIR__}\installer-wfp-probe.nsh" +!include "${__FILEDIR__}\installer-release-protection.nsh" + !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 + ${If} $0 == "0" nsExec::Exec /TIMEOUT=35000 '"$SYSDIR\sc.exe" stop tenebra' Pop $0 ${If} $0 != 1062 @@ -189,6 +192,17 @@ ; Keep the registration through updates; POSTINSTALL reconfigures it. !insertmacro TenebraStopService ${If} $UpdateMode <> 1 + ; Legacy cores cannot create T05 policy and do not implement its remover. + ; A read-only absence proof permits their uninstall without executing them. + !insertmacro TenebraProbeHostProtection + ${If} $0 == "present" + !insertmacro TenebraReleaseHostProtection + !insertmacro TenebraProbeHostProtection + ${If} $0 != "absent" + StrCpy $0 "owned WFP objects remain after cleanup" + !insertmacro TenebraServiceFailure "confirm host protection removal before unregistering" + ${EndIf} + ${EndIf} nsExec::Exec /TIMEOUT=35000 '"$SYSDIR\sc.exe" delete tenebra' Pop $0 ${If} $0 != 1060 diff --git a/ui-desktop/src-tauri/installer-release-protection.nsh b/ui-desktop/src-tauri/installer-release-protection.nsh new file mode 100644 index 00000000..0920a409 --- /dev/null +++ b/ui-desktop/src-tauri/installer-release-protection.nsh @@ -0,0 +1,184 @@ +; Generated by scripts/embed-uninstall-helper.mjs. Edit the .ps1 source. +; Constant chunks avoid NSIS string limits and execution of a user-writable script. +!macro TenebraReleaseHostProtection + StrCpy $0 "$INSTDIR\tenebra-core.exe" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_CORE", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "cABhAHIAYQBtACgAWwBzAHcAaQB0AGMAaABdACQAUABvAGwAaQBjAHkATwBuAGwAeQApAAoACgAjACAARQBtAGIAZQBkAGQAZQBkACAAYQBzACAAYwBvAG4AcwB0AGEAbgB0ACAAcwBvAHUAcgBjAGUAIABpAG4AIAB0AGgAZQAgAHUAbgBpAG4AcwB0AGEAbABsAGUAcgAsACAAbgBlAHYAZQByACAAbABvAGEAZABlAGQAIABmAHIAbwBtACAAYQBuACAAaQBuAHMAdABhAGwAbABlAGQACgAjACAAbwByACAAdABlAG0AcABvAHIAYQByAHkAIABzAGMAcgBpAHAAdAAgAGYAaQBsAGUALgAgAC0AUABvAGwAaQBjAHkATwBuAGwAeQAgAGUAeABwAG8AcwBlAHMAIABvAG4AbAB5ACAAcAB1AHIAZQAgAHAAYQB0AGgALwBBAEMATAAgAGMAaABlAGMAawBzACAAdABvACAAQwBJAC4ACgAkAEUA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS0", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "cgByAG8AcgBBAGMAdABpAG8AbgBQAHIAZQBmAGUAcgBlAG4AYwBlACAAPQAgACcAUwB0AG8AcAAnAAoACgBmAHUAbgBjAHQAaQBvAG4AIABHAGUAdAAtAFQAZQBuAGUAYgByAGEAQwBsAGUAYQBuAHUAcABQAGEAdABoAEMAaABhAGkAbgAoAFsAcwB0AHIAaQBuAGcAXQAkAEMAYQBuAGQAaQBkAGEAdABlACkAIAB7AAoAIAAgACAAIABpAGYAIAAoACQAQwBhAG4AZABpAGQAYQB0AGUAIAAtAG4AbwB0AG0AYQB0AGMAaAAgACcAXgBbAEEALQBaAGEALQB6AF0AOgBcAFwAJwAgAC0AbwByACAAJABDAGEAbgBkAGkAZABhAHQAZQAuAFMAdQBiAHMAdAByAGkAbgBnACgAMgApAC4AQwBvAG4AdABhAGkAbgBzACgAJwA6ACcAKQAgAC0AbwByACAAJABDAGEAbgBkAGkAZABhAHQAZQAuAEMA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS1", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "bwBuAHQAYQBpAG4AcwAoAFsAYwBoAGEAcgBdADAAKQApACAAewAKACAAIAAgACAAIAAgACAAIAB0AGgAcgBvAHcAIAAnAFAAcgBvAHQAZQBjAHQAaQBvAG4AIABjAGwAZQBhAG4AdQBwACAAcgBlAHEAdQBpAHIAZQBzACAAYQBuACAAYQBiAHMAbwBsAHUAdABlACAAbABvAGMAYQBsACAAaQBuAHMAdABhAGwAbABlAGQAIABjAG8AcgBlACAAcABhAHQAaAAuACcACgAgACAAIAAgAH0ACgAgACAAIAAgACQAYwBvAHIAZQAgAD0AIABbAEkATwAuAFAAYQB0AGgAXQA6ADoARwBlAHQARgB1AGwAbABQAGEAdABoACgAJABDAGEAbgBkAGkAZABhAHQAZQApAAoAIAAgACAAIABpAGYAIAAoACQAYwBvAHIAZQAgAC0AYwBuAGUAIAAkAEMAYQBuAGQAaQBkAGEAdABlACAALQBvAHIAIABbAEkA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS2", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "TwAuAFAAYQB0AGgAXQA6ADoARwBlAHQARgBpAGwAZQBOAGEAbQBlACgAJABjAG8AcgBlACkAIAAtAGkAbgBlACAAJwB0AGUAbgBlAGIAcgBhAC0AYwBvAHIAZQAuAGUAeABlACcAKQAgAHsACgAgACAAIAAgACAAIAAgACAAdABoAHIAbwB3ACAAJwBQAHIAbwB0AGUAYwB0AGkAbwBuACAAYwBsAGUAYQBuAHUAcAAgAGUAeABlAGMAdQB0AGEAYgBsAGUAIABwAGEAdABoACAAaQBzACAAYQBtAGIAaQBnAHUAbwB1AHMALgAnAAoAIAAgACAAIAB9AAoAIAAgACAAIAAkAGMAaABhAGkAbgAgAD0AIABAACgAKQAKACAAIAAgACAAZgBvAHIAIAAoACQAcABhAHQAaAAgAD0AIAAkAGMAbwByAGUAOwAgACQAcABhAHQAaAA7ACAAJABwAGEAdABoACAAPQAgAFsASQBPAC4AUABhAHQAaABdADoA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS3", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "OgBHAGUAdABEAGkAcgBlAGMAdABvAHIAeQBOAGEAbQBlACgAJABwAGEAdABoACkAKQAgAHsACgAgACAAIAAgACAAIAAgACAAJABuAGEAbQBlACAAPQAgAFsASQBPAC4AUABhAHQAaABdADoAOgBHAGUAdABGAGkAbABlAE4AYQBtAGUAKAAkAHAAYQB0AGgAKQAKACAAIAAgACAAIAAgACAAIABpAGYAIAAoACQAbgBhAG0AZQAgAC0AYQBuAGQAIAAoACQAbgBhAG0AZQAuAFQAcgBpAG0ARQBuAGQAKAAnACAAJwAsACAAJwAuACcAKQAgAC0AYwBuAGUAIAAkAG4AYQBtAGUAKQApACAAewAgAHQAaAByAG8AdwAgACcAQQBtAGIAaQBnAHUAbwB1AHMAIABjAGwAZQBhAG4AdQBwACAAcABhAHQAaAAgAGMAbwBtAHAAbwBuAGUAbgB0AC4AJwAgAH0ACgAgACAAIAAgACAAIAAgACAAJABjAGgA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS4", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "YQBpAG4AIAA9ACAAQAAoACQAcABhAHQAaAApACAAKwAgACQAYwBoAGEAaQBuAAoAIAAgACAAIAB9AAoAIAAgACAAIAByAGUAdAB1AHIAbgAgACQAYwBoAGEAaQBuAAoAfQAKAAoAZgB1AG4AYwB0AGkAbwBuACAAQQBzAHMAZQByAHQALQBUAGUAbgBlAGIAcgBhAEMAbABlAGEAbgB1AHAAQQBjAGwAKABbAFMAZQBjAHUAcgBpAHQAeQAuAEEAYwBjAGUAcwBzAEMAbwBuAHQAcgBvAGwALgBSAGEAdwBTAGUAYwB1AHIAaQB0AHkARABlAHMAYwByAGkAcAB0AG8AcgBdACQARABlAHMAYwByAGkAcAB0AG8AcgAsACAAWwBiAG8AbwBsAF0AJABGAGkAbABlACkAIAB7AAoAIAAgACAAIAAkAHQAcgB1AHMAdABlAGQAIAA9ACAAQAAoACcAUwAtADEALQA1AC0AMQA4ACcALAAgACcAUwAtADEA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS5", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "LQA1AC0AMwAyAC0ANQA0ADQAJwAsACAAJwBTAC0AMQAtADUALQA4ADAALQA5ADUANgAwADAAOAA4ADgANQAtADMANAAxADgANQAyADIANgA0ADkALQAxADgAMwAxADAAMwA4ADAANAA0AC0AMQA4ADUAMwAyADkAMgA2ADMAMQAtADIAMgA3ADEANAA3ADgANAA2ADQAJwApAAoAIAAgACAAIABpAGYAIAAoACEAJABEAGUAcwBjAHIAaQBwAHQAbwByAC4ATwB3AG4AZQByACAALQBvAHIAIAAkAEQAZQBzAGMAcgBpAHAAdABvAHIALgBPAHcAbgBlAHIALgBWAGEAbAB1AGUAIAAtAG4AbwB0AGkAbgAgACQAdAByAHUAcwB0AGUAZAAgAC0AbwByACAAJABuAHUAbABsACAALQBlAHEAIAAkAEQAZQBzAGMAcgBpAHAAdABvAHIALgBEAGkAcwBjAHIAZQB0AGkAbwBuAGEAcgB5AEEAYwBsACkA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS6", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "IAB7AAoAIAAgACAAIAAgACAAIAAgAHQAaAByAG8AdwAgACcAQwBsAGUAYQBuAHUAcAAgAHAAYQB0AGgAIABuAGUAZQBkAHMAIABhAGQAbQBpAG4AaQBzAHQAcgBhAHQAbwByACAAbwB3AG4AZQByAHMAaABpAHAAIABhAG4AZAAgAGEAIAByAGUAcwB0AHIAaQBjAHQAaQB2AGUAIABEAEEAQwBMAC4AJwAKACAAIAAgACAAfQAKACAAIAAgACAAIwAgAEEAbgBjAGUAcwB0AG8AcgBzACAAbQBhAHkAIABhAGwAbABvAHcAIABjAHIAZQBhAHQAaQBvAG4AIABvAGYAIAB1AG4AcgBlAGwAYQB0AGUAZAAgAGMAaABpAGwAZAByAGUAbgAgACgAdABoAGUAIAB2AG8AbAB1AG0AZQAgAHIAbwBvAHQAIABkAG8AZQBzACkALgAKACAAIAAgACAAIwAgAE0AdQB0AGEAdABpAG8AbgAsACAAZABlAGwA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS7", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "ZQB0AGUALQBjAGgAaQBsAGQALAAgAEEAQwBMAC8AbwB3AG4AZQByACAAYwBoAGEAbgBnAGUAcwAgAGEAbgBkACAAYQBsAGwAIAB3AHIAaQB0AGUAcwAgAHQAbwAgAHQAaABlACAARQBYAEUAIABhAHIAZQAgAGQAZQBuAGkAZQBkAC4ACgAgACAAIAAgACQAbQB1AHQAYQB0AGkAbwBuACAAPQAgADAAeAA1ADIAMABEADAAMQA1ADAATAAKACAAIAAgACAAaQBmACAAKAAkAEYAaQBsAGUAKQAgAHsAIAAkAG0AdQB0AGEAdABpAG8AbgAgAD0AIAAkAG0AdQB0AGEAdABpAG8AbgAgAC0AYgBvAHIAIAA2ACAAfQAKACAAIAAgACAAZgBvAHIAZQBhAGMAaAAgACgAJABhAGMAZQAgAGkAbgAgACQARABlAHMAYwByAGkAcAB0AG8AcgAuAEQAaQBzAGMAcgBlAHQAaQBvAG4AYQByAHkAQQBjAGwA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS8", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "KQAgAHsACgAgACAAIAAgACAAIAAgACAAaQBmACAAKAAoAFsAaQBuAHQAXQAkAGEAYwBlAC4AQQBjAGUARgBsAGEAZwBzACAALQBiAGEAbgBkACAAWwBpAG4AdABdAFsAUwBlAGMAdQByAGkAdAB5AC4AQQBjAGMAZQBzAHMAQwBvAG4AdAByAG8AbAAuAEEAYwBlAEYAbABhAGcAcwBdADoAOgBJAG4AaABlAHIAaQB0AE8AbgBsAHkAKQAgAC0AbgBlACAAMAApACAAewAgAGMAbwBuAHQAaQBuAHUAZQAgAH0ACgAgACAAIAAgACAAIAAgACAAaQBmACAAKAAkAGEAYwBlACAALQBpAHMAbgBvAHQAIABbAFMAZQBjAHUAcgBpAHQAeQAuAEEAYwBjAGUAcwBzAEMAbwBuAHQAcgBvAGwALgBDAG8AbQBtAG8AbgBBAGMAZQBdACkAIAB7ACAAdABoAHIAbwB3ACAAJwBVAG4AcwB1AHAAcABvAHIA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS9", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "dABlAGQAIABjAGwAZQBhAG4AdQBwACAAcABhAHQAaAAgAEEAQwBMACAAZQBuAHQAcgB5AC4AJwAgAH0ACgAgACAAIAAgACAAIAAgACAAaQBmACAAKAAkAGEAYwBlAC4AQQBjAGUAUQB1AGEAbABpAGYAaQBlAHIAIAAtAGUAcQAgAFsAUwBlAGMAdQByAGkAdAB5AC4AQQBjAGMAZQBzAHMAQwBvAG4AdAByAG8AbAAuAEEAYwBlAFEAdQBhAGwAaQBmAGkAZQByAF0AOgA6AEEAYwBjAGUAcwBzAEQAZQBuAGkAZQBkACkAIAB7ACAAYwBvAG4AdABpAG4AdQBlACAAfQAKACAAIAAgACAAIAAgACAAIABpAGYAIAAoACQAYQBjAGUALgBBAGMAZQBRAHUAYQBsAGkAZgBpAGUAcgAgAC0AbgBlACAAWwBTAGUAYwB1AHIAaQB0AHkALgBBAGMAYwBlAHMAcwBDAG8AbgB0AHIAbwBsAC4AQQBjAGUA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS10", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "UQB1AGEAbABpAGYAaQBlAHIAXQA6ADoAQQBjAGMAZQBzAHMAQQBsAGwAbwB3AGUAZAAgAC0AbwByACAAJABhAGMAZQAuAEkAcwBDAGEAbABsAGIAYQBjAGsAKQAgAHsAIAB0AGgAcgBvAHcAIAAnAFUAbgBzAHUAcABwAG8AcgB0AGUAZAAgAGMAbABlAGEAbgB1AHAAIABwAGEAdABoACAAQQBDAEwAIABlAG4AdAByAHkALgAnACAAfQAKACAAIAAgACAAIAAgACAAIABpAGYAIAAoACQAYQBjAGUALgBTAGUAYwB1AHIAaQB0AHkASQBkAGUAbgB0AGkAZgBpAGUAcgAuAFYAYQBsAHUAZQAgAC0AbgBvAHQAaQBuACAAJAB0AHIAdQBzAHQAZQBkACAALQBhAG4AZAAgACgAKABbAGwAbwBuAGcAXQAkAGEAYwBlAC4AQQBjAGMAZQBzAHMATQBhAHMAawAgAC0AYgBhAG4AZAAgACQAbQB1AHQA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS11", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "YQB0AGkAbwBuACkAIAAtAG4AZQAgADAAKQApACAAewAKACAAIAAgACAAIAAgACAAIAAgACAAIAAgAHQAaAByAG8AdwAgACcAQwBsAGUAYQBuAHUAcAAgAHAAYQB0AGgAIABpAHMAIAB3AHIAaQB0AGEAYgBsAGUAIABiAHkAIABhACAAbgBvAG4ALQBhAGQAbQBpAG4AaQBzAHQAcgBhAHQAbwByAC4AJwAKACAAIAAgACAAIAAgACAAIAB9AAoAIAAgACAAIAB9AAoAfQAKAAoAZgB1AG4AYwB0AGkAbwBuACAAQQBzAHMAZQByAHQALQBUAGUAbgBlAGIAcgBhAEMAbABlAGEAbgB1AHAASQBtAGEAZwBlACgAWwBzAHQAcgBpAG4AZwBbAF0AXQAkAEMAaABhAGkAbgApACAAewAKACAAIAAgACAAZgBvAHIAZQBhAGMAaAAgACgAJABwAGEAdABoACAAaQBuACAAJABDAGgAYQBpAG4AKQAgAHsA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS12", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "CgAgACAAIAAgACAAIAAgACAAJABpAHQAZQBtACAAPQAgAEcAZQB0AC0ASQB0AGUAbQAgAC0ATABpAHQAZQByAGEAbABQAGEAdABoACAAJABwAGEAdABoACAALQBGAG8AcgBjAGUACgAgACAAIAAgACAAIAAgACAAaQBmACAAKAAoAFsAaQBuAHQAXQAkAGkAdABlAG0ALgBBAHQAdAByAGkAYgB1AHQAZQBzACAALQBiAGEAbgBkACAAWwBpAG4AdABdAFsASQBPAC4ARgBpAGwAZQBBAHQAdAByAGkAYgB1AHQAZQBzAF0AOgA6AFIAZQBwAGEAcgBzAGUAUABvAGkAbgB0ACkAIAAtAG4AZQAgADAAKQAgAHsAIAB0AGgAcgBvAHcAIAAnAEMAbABlAGEAbgB1AHAAIABwAGEAdABoACAAYwBvAG4AdABhAGkAbgBzACAAYQAgAHIAZQBwAGEAcgBzAGUAIABwAG8AaQBuAHQALgAnACAAfQAKACAA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS13", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "IAAgACAAIAAgACAAIAAkAGkAcwBGAGkAbABlACAAPQAgACQAcABhAHQAaAAgAC0AYwBlAHEAIAAkAEMAaABhAGkAbgBbAC0AMQBdAAoAIAAgACAAIAAgACAAIAAgAGkAZgAgACgAJABpAHQAZQBtAC4AUABTAEkAcwBDAG8AbgB0AGEAaQBuAGUAcgAgAC0AZQBxACAAJABpAHMARgBpAGwAZQApACAAewAgAHQAaAByAG8AdwAgACcAQwBsAGUAYQBuAHUAcAAgAHAAYQB0AGgAIABoAGEAcwAgAHQAaABlACAAdwByAG8AbgBnACAAZgBpAGwAZQAgAHQAeQBwAGUALgAnACAAfQAKACAAIAAgACAAIAAgACAAIAAkAGEAYwBsACAAPQAgAEcAZQB0AC0AQQBjAGwAIAAtAEwAaQB0AGUAcgBhAGwAUABhAHQAaAAgACQAcABhAHQAaAAKACAAIAAgACAAIAAgACAAIAAkAGQAZQBzAGMAcgBpAHAA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS14", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "dABvAHIAIAA9ACAAWwBTAGUAYwB1AHIAaQB0AHkALgBBAGMAYwBlAHMAcwBDAG8AbgB0AHIAbwBsAC4AUgBhAHcAUwBlAGMAdQByAGkAdAB5AEQAZQBzAGMAcgBpAHAAdABvAHIAXQA6ADoAbgBlAHcAKAAkAGEAYwBsAC4ARwBlAHQAUwBlAGMAdQByAGkAdAB5AEQAZQBzAGMAcgBpAHAAdABvAHIAQgBpAG4AYQByAHkARgBvAHIAbQAoACkALAAgADAAKQAKACAAIAAgACAAIAAgACAAIABBAHMAcwBlAHIAdAAtAFQAZQBuAGUAYgByAGEAQwBsAGUAYQBuAHUAcABBAGMAbAAgACQAZABlAHMAYwByAGkAcAB0AG8AcgAgACQAaQBzAEYAaQBsAGUACgAgACAAIAAgAH0ACgB9AAoACgBpAGYAIAAoACQAUABvAGwAaQBjAHkATwBuAGwAeQApACAAewAgAHIAZQB0AHUAcgBuACAAfQAKAAoA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS15", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "JABpAG0AYQBnAGUAIAA9ACAAJABuAHUAbABsAAoAJABwAHIAbwBjAGUAcwBzACAAPQAgACQAbgB1AGwAbAAKAHQAcgB5ACAAewAKACAAIAAgACAAJABjAGgAYQBpAG4AIAA9ACAAQAAoAEcAZQB0AC0AVABlAG4AZQBiAHIAYQBDAGwAZQBhAG4AdQBwAFAAYQB0AGgAQwBoAGEAaQBuACAAKABbAEUAbgB2AGkAcgBvAG4AbQBlAG4AdABdADoAOgBHAGUAdABFAG4AdgBpAHIAbwBuAG0AZQBuAHQAVgBhAHIAaQBhAGIAbABlACgAJwBUAEUATgBFAEIAUgBBAF8AUgBFAEwARQBBAFMARQBfAEMATwBSAEUAJwApACkAKQAKACAAIAAgACAAaQBmACAAKABbAEkATwAuAEQAcgBpAHYAZQBJAG4AZgBvAF0AOgA6AG4AZQB3ACgAJABjAGgAYQBpAG4AWwAwAF0AKQAuAEQAcgBpAHYAZQBUAHkA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS16", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "cABlACAALQBuAGUAIABbAEkATwAuAEQAcgBpAHYAZQBUAHkAcABlAF0AOgA6AEYAaQB4AGUAZAApACAAewAgAHQAaAByAG8AdwAgACcAQwBsAGUAYQBuAHUAcAAgAGkAbQBhAGcAZQAgAG0AdQBzAHQAIABiAGUAIABvAG4AIABhACAAbABvAGMAYQBsACAAZgBpAHgAZQBkACAAZAByAGkAdgBlAC4AJwAgAH0ACgAgACAAIAAgAEEAcwBzAGUAcgB0AC0AVABlAG4AZQBiAHIAYQBDAGwAZQBhAG4AdQBwAEkAbQBhAGcAZQAgACQAYwBoAGEAaQBuAAoAIAAgACAAIAAkAGMAbwByAGUAIAA9ACAAJABjAGgAYQBpAG4AWwAtADEAXQAKACAAIAAgACAAIwAgAEgAbwBsAGQAIABhACAAbgBvAG4ALQBpAG4AaABlAHIAaQB0AGEAYgBsAGUAIABoAGEAbgBkAGwAZQAgAGQAZQBuAHkAaQBuAGcA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS17", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "IAB3AHIAaQB0AGUAcwAvAGQAZQBsAGUAdABlACAAdwBoAGkAbABlACAAbABhAHUAbgBjAGgAaQBuAGcAIAB0AGgAZQAKACAAIAAgACAAIwAgAGMAaABlAGMAawBlAGQAIABpAG0AYQBnAGUALgAgAEUAeABpAHMAdABpAG4AZwAgAGgAbwBzAHQAaQBsAGUAIAB3AHIAaQB0AGUAIABoAGEAbgBkAGwAZQBzACAAYwBhAHUAcwBlACAAdABoAGkAcwAgAG8AcABlAG4AIAB0AG8AIABmAGEAaQBsAC4ACgAgACAAIAAgACQAaQBtAGEAZwBlACAAPQAgAFsASQBPAC4ARgBpAGwAZQBdADoAOgBPAHAAZQBuACgAJABjAG8AcgBlACwAIABbAEkATwAuAEYAaQBsAGUATQBvAGQAZQBdADoAOgBPAHAAZQBuACwAIABbAEkATwAuAEYAaQBsAGUAQQBjAGMAZQBzAHMAXQA6ADoAUgBlAGEAZAAsACAA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS18", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "WwBJAE8ALgBGAGkAbABlAFMAaABhAHIAZQBdADoAOgBSAGUAYQBkACkACgAgACAAIAAgACQAcwB0AGEAcgB0ACAAPQAgAFsARABpAGEAZwBuAG8AcwB0AGkAYwBzAC4AUAByAG8AYwBlAHMAcwBTAHQAYQByAHQASQBuAGYAbwBdADoAOgBuAGUAdwAoACkACgAgACAAIAAgACQAcwB0AGEAcgB0AC4ARgBpAGwAZQBOAGEAbQBlACAAPQAgACQAYwBvAHIAZQAKACAAIAAgACAAJABzAHQAYQByAHQALgBXAG8AcgBrAGkAbgBnAEQAaQByAGUAYwB0AG8AcgB5ACAAPQAgAFsASQBPAC4AUABhAHQAaABdADoAOgBHAGUAdABEAGkAcgBlAGMAdABvAHIAeQBOAGEAbQBlACgAJABjAG8AcgBlACkACgAgACAAIAAgACQAcwB0AGEAcgB0AC4AQQByAGcAdQBtAGUAbgB0AHMAIAA9ACAAJwAtAC0A" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS19", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "cgBlAGwAZQBhAHMAZQAtAGgAbwBzAHQALQBwAHIAbwB0AGUAYwB0AGkAbwBuACcACgAgACAAIAAgACQAcwB0AGEAcgB0AC4AVQBzAGUAUwBoAGUAbABsAEUAeABlAGMAdQB0AGUAIAA9ACAAJABmAGEAbABzAGUACgAgACAAIAAgACQAcwB0AGEAcgB0AC4AQwByAGUAYQB0AGUATgBvAFcAaQBuAGQAbwB3ACAAPQAgACQAdAByAHUAZQAKACAAIAAgACAAJABwAHIAbwBjAGUAcwBzACAAPQAgAFsARABpAGEAZwBuAG8AcwB0AGkAYwBzAC4AUAByAG8AYwBlAHMAcwBdADoAOgBTAHQAYQByAHQAKAAkAHMAdABhAHIAdAApAAoAIAAgACAAIABpAGYAIAAoACEAJABwAHIAbwBjAGUAcwBzAC4AVwBhAGkAdABGAG8AcgBFAHgAaQB0ACgAMgAwADAAMAAwACkAKQAgAHsACgAgACAAIAAgACAA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS20", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "IAAgACAAJABwAHIAbwBjAGUAcwBzAC4ASwBpAGwAbAAoACkACgAgACAAIAAgACAAIAAgACAAJABuAHUAbABsACAAPQAgACQAcAByAG8AYwBlAHMAcwAuAFcAYQBpAHQARgBvAHIARQB4AGkAdAAoADEAMAAwADAAKQAKACAAIAAgACAAIAAgACAAIAB0AGgAcgBvAHcAIAAnAE8AdwBuAGUAZAAgAGgAbwBzAHQALQBwAHIAbwB0AGUAYwB0AGkAbwBuACAAYwBsAGUAYQBuAHUAcAAgAHQAaQBtAGUAZAAgAG8AdQB0ADsAIAByAGUAcABhAGkAcgAgAGIAZQBmAG8AcgBlACAAdQBuAGkAbgBzAHQAYQBsAGwAaQBuAGcALgAnAAoAIAAgACAAIAB9AAoAIAAgACAAIABpAGYAIAAoACQAcAByAG8AYwBlAHMAcwAuAEUAeABpAHQAQwBvAGQAZQAgAC0AbgBlACAAMAApACAAewAgAHQAaAByAG8A" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS21", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "dwAgACIATwB3AG4AZQBkACAAaABvAHMAdAAtAHAAcgBvAHQAZQBjAHQAaQBvAG4AIABjAGwAZQBhAG4AdQBwACAAdwBhAHMAIABuAG8AdAAgAGMAbwBuAGYAaQByAG0AZQBkACAAKABlAHgAaQB0ACAAJAAoACQAcAByAG8AYwBlAHMAcwAuAEUAeABpAHQAQwBvAGQAZQApACkAOwAgAHIAZQBwAGEAaQByACAAdwBpAHQAaAAgAGEAIABwAHIAbwB0AGUAYwB0AGkAbwBuAC0AYQB3AGEAcgBlACAAaQBuAHMAdABhAGwAbABlAHIALgAiACAAfQAKACAAIAAgACAAZQB4AGkAdAAgADAACgB9ACAAYwBhAHQAYwBoACAAewAKACAAIAAgACAAWwBDAG8AbgBzAG8AbABlAF0AOgA6AEUAcgByAG8AcgAuAFcAcgBpAHQAZQBMAGkAbgBlACgAIgBUAGUAbgBlAGIAcgBhACAAcAByAG8AdABlAGMA" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS22", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + StrCpy $0 "dABpAG8AbgAgAGMAbABlAGEAbgB1AHAAIAByAGUAZgB1AHMAZQBkADoAIAAkACgAJABfAC4ARQB4AGMAZQBwAHQAaQBvAG4ALgBNAGUAcwBzAGEAZwBlACkAIgApAAoAIAAgACAAIABlAHgAaQB0ACAAMQAKAH0AIABmAGkAbgBhAGwAbAB5ACAAewAKACAAIAAgACAAaQBmACAAKAAkAHAAcgBvAGMAZQBzAHMAKQAgAHsAIAAkAHAAcgBvAGMAZQBzAHMALgBEAGkAcwBwAG8AcwBlACgAKQAgAH0ACgAgACAAIAAgAGkAZgAgACgAJABpAG0AYQBnAGUAKQAgAHsAIAAkAGkAbQBhAGcAZQAuAEQAaQBzAHAAbwBzAGUAKAApACAAfQAKAH0ACgA=" + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS23", w r0) i.r0' + ${If} $0 = 0 + System::Call 'kernel32::GetLastError() i.r0' + !insertmacro TenebraServiceFailure "prepare protection cleanup for" + ${EndIf} + nsExec::ExecToLog /TIMEOUT=35000 `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -Command "$$s=(0..23|ForEach-Object{[Environment]::GetEnvironmentVariable('TENEBRA_RELEASE_PS'+$$_)})-join'';& ([ScriptBlock]::Create([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($$s))))"` + Pop $0 + Push $0 + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_CORE", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS0", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS1", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS2", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS3", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS4", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS5", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS6", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS7", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS8", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS9", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS10", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS11", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS12", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS13", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS14", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS15", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS16", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS17", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS18", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS19", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS20", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS21", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS22", p 0) i.r0' + System::Call 'kernel32::SetEnvironmentVariableW(w "TENEBRA_RELEASE_PS23", p 0) i.r0' + Pop $0 + !insertmacro TenebraRequireSuccess "release owned host protection before unregistering" +!macroend diff --git a/ui-desktop/src-tauri/installer-release-protection.ps1 b/ui-desktop/src-tauri/installer-release-protection.ps1 new file mode 100644 index 00000000..e12d4085 --- /dev/null +++ b/ui-desktop/src-tauri/installer-release-protection.ps1 @@ -0,0 +1,88 @@ +param([switch]$PolicyOnly) + +# Embedded as constant source in the uninstaller, never loaded from an installed +# or temporary script file. -PolicyOnly exposes only pure path/ACL checks to CI. +$ErrorActionPreference = 'Stop' + +function Get-TenebraCleanupPathChain([string]$Candidate) { + if ($Candidate -notmatch '^[A-Za-z]:\\' -or $Candidate.Substring(2).Contains(':') -or $Candidate.Contains([char]0)) { + throw 'Protection cleanup requires an absolute local installed core path.' + } + $core = [IO.Path]::GetFullPath($Candidate) + if ($core -cne $Candidate -or [IO.Path]::GetFileName($core) -ine 'tenebra-core.exe') { + throw 'Protection cleanup executable path is ambiguous.' + } + $chain = @() + for ($path = $core; $path; $path = [IO.Path]::GetDirectoryName($path)) { + $name = [IO.Path]::GetFileName($path) + if ($name -and ($name.TrimEnd(' ', '.') -cne $name)) { throw 'Ambiguous cleanup path component.' } + $chain = @($path) + $chain + } + return $chain +} + +function Assert-TenebraCleanupAcl([Security.AccessControl.RawSecurityDescriptor]$Descriptor, [bool]$File) { + $trusted = @('S-1-5-18', 'S-1-5-32-544', 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464') + if (!$Descriptor.Owner -or $Descriptor.Owner.Value -notin $trusted -or $null -eq $Descriptor.DiscretionaryAcl) { + throw 'Cleanup path needs administrator ownership and a restrictive DACL.' + } + # Ancestors may allow creation of unrelated children (the volume root does). + # Mutation, delete-child, ACL/owner changes and all writes to the EXE are denied. + $mutation = 0x520D0150L + if ($File) { $mutation = $mutation -bor 6 } + foreach ($ace in $Descriptor.DiscretionaryAcl) { + if (([int]$ace.AceFlags -band [int][Security.AccessControl.AceFlags]::InheritOnly) -ne 0) { continue } + if ($ace -isnot [Security.AccessControl.CommonAce]) { throw 'Unsupported cleanup path ACL entry.' } + if ($ace.AceQualifier -eq [Security.AccessControl.AceQualifier]::AccessDenied) { continue } + if ($ace.AceQualifier -ne [Security.AccessControl.AceQualifier]::AccessAllowed -or $ace.IsCallback) { throw 'Unsupported cleanup path ACL entry.' } + if ($ace.SecurityIdentifier.Value -notin $trusted -and (([long]$ace.AccessMask -band $mutation) -ne 0)) { + throw 'Cleanup path is writable by a non-administrator.' + } + } +} + +function Assert-TenebraCleanupImage([string[]]$Chain) { + foreach ($path in $Chain) { + $item = Get-Item -LiteralPath $path -Force + if (([int]$item.Attributes -band [int][IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Cleanup path contains a reparse point.' } + $isFile = $path -ceq $Chain[-1] + if ($item.PSIsContainer -eq $isFile) { throw 'Cleanup path has the wrong file type.' } + $acl = Get-Acl -LiteralPath $path + $descriptor = [Security.AccessControl.RawSecurityDescriptor]::new($acl.GetSecurityDescriptorBinaryForm(), 0) + Assert-TenebraCleanupAcl $descriptor $isFile + } +} + +if ($PolicyOnly) { return } + +$image = $null +$process = $null +try { + $chain = @(Get-TenebraCleanupPathChain ([Environment]::GetEnvironmentVariable('TENEBRA_RELEASE_CORE'))) + if ([IO.DriveInfo]::new($chain[0]).DriveType -ne [IO.DriveType]::Fixed) { throw 'Cleanup image must be on a local fixed drive.' } + Assert-TenebraCleanupImage $chain + $core = $chain[-1] + # Hold a non-inheritable handle denying writes/delete while launching the + # checked image. Existing hostile write handles cause this open to fail. + $image = [IO.File]::Open($core, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = $core + $start.WorkingDirectory = [IO.Path]::GetDirectoryName($core) + $start.Arguments = '--release-host-protection' + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $process = [Diagnostics.Process]::Start($start) + if (!$process.WaitForExit(20000)) { + $process.Kill() + $null = $process.WaitForExit(1000) + throw 'Owned host-protection cleanup timed out; repair before uninstalling.' + } + if ($process.ExitCode -ne 0) { throw "Owned host-protection cleanup was not confirmed (exit $($process.ExitCode)); repair with a protection-aware installer." } + exit 0 +} catch { + [Console]::Error.WriteLine("Tenebra protection cleanup refused: $($_.Exception.Message)") + exit 1 +} finally { + if ($process) { $process.Dispose() } + if ($image) { $image.Dispose() } +} diff --git a/ui-desktop/src-tauri/installer-wfp-probe.nsh b/ui-desktop/src-tauri/installer-wfp-probe.nsh new file mode 100644 index 00000000..72d687fb --- /dev/null +++ b/ui-desktop/src-tauri/installer-wfp-probe.nsh @@ -0,0 +1,60 @@ +; Read-only probe of the fixed provider/sublayer identities. It neither inspects +; nor removes foreign policy and never launches an installed executable. +; $0 = absent only when BOTH exact NOT_FOUND results prove absence, else present. +; Any other result aborts. Keep p handles/pointer-to-pointer outputs pointer-sized +; because the NSIS uninstaller is 32-bit even for the x64 bundle. +!macro TenebraProbeHostProtection + Push $1 + Push $2 + Push $3 + Push $4 + Push $5 + StrCpy $1 0 + StrCpy $0 "WFP API unavailable" + System::Call '"$SYSDIR\fwpuclnt.dll"::FwpmEngineOpen0(p 0, i 10, p 0, p 0, *p.r1) i.r0' + ${If} $0 == "0" + ${AndIf} $1 != "0" + StrCpy $2 0 + StrCpy $4 "WFP provider API unavailable" + System::Call '"$SYSDIR\fwpuclnt.dll"::FwpmProviderGetByKey0(p r1, g "{fcb43b44-9358-4cd7-a998-9e7f822d5248}", *p.r2) i.r4' + ${If} $2 != "0" + System::Call '"$SYSDIR\fwpuclnt.dll"::FwpmFreeMemory0(*p r2) v' + ${EndIf} + StrCpy $2 0 + StrCpy $5 "WFP sublayer API unavailable" + System::Call '"$SYSDIR\fwpuclnt.dll"::FwpmSubLayerGetByKey0(p r1, g "{fcb43b45-9358-4cd7-a998-9e7f822d5248}", *p.r2) i.r5' + ${If} $2 != "0" + System::Call '"$SYSDIR\fwpuclnt.dll"::FwpmFreeMemory0(*p r2) v' + ${EndIf} + StrCpy $3 "WFP close API unavailable" + System::Call '"$SYSDIR\fwpuclnt.dll"::FwpmEngineClose0(p r1) i.r3' + ${If} $3 != "0" + StrCpy $0 $3 + ${ElseIf} $4 == "-2144206843" + ${AndIf} $5 == "-2144206841" + ; FWP_E_PROVIDER_NOT_FOUND 0x80320005 / SUBLAYER_NOT_FOUND 0x80320007. + StrCpy $0 "absent" + ${Else} + ${If} $4 != "0" + ${AndIf} $4 != "-2144206843" + StrCpy $0 $4 + ${ElseIf} $5 != "0" + ${AndIf} $5 != "-2144206841" + StrCpy $0 $5 + ${Else} + StrCpy $0 "present" + ${EndIf} + ${EndIf} + ${ElseIf} $0 == "0" + StrCpy $0 "WFP returned no engine handle" + ${EndIf} + Pop $5 + Pop $4 + Pop $3 + Pop $2 + Pop $1 + ${If} $0 != "absent" + ${AndIf} $0 != "present" + !insertmacro TenebraServiceFailure "query owned host protection before unregistering" + ${EndIf} +!macroend From 436c3b1592a2bd364e4d76c548af452fcc438aaf Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:31:57 +0300 Subject: [PATCH 20/56] fix(desktop): publish preference events outside render --- .../src/screens/SettingsScreen.test.tsx | 21 +++++++++++ ui-desktop/src/screens/SettingsScreen.tsx | 37 ++++++++----------- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/ui-desktop/src/screens/SettingsScreen.test.tsx b/ui-desktop/src/screens/SettingsScreen.test.tsx index 4f2ac53d..594d2a37 100644 --- a/ui-desktop/src/screens/SettingsScreen.test.tsx +++ b/ui-desktop/src/screens/SettingsScreen.test.tsx @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { StrictMode, useEffect, useState } from "react"; import { SettingsScreen, pickActiveSection } from "./SettingsScreen"; import { renderWithProviders } from "../test/renderWithProviders"; @@ -1050,6 +1051,26 @@ describe("SettingsScreen", () => { }); describe("simple mode", () => { + it("notifies the shell outside render when React replays state updates", async () => { + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + function Shell() { + const [mode, setMode] = useState("false"); + useEffect(() => { + const listener = (event: StorageEvent) => { + if (event.key === "tenebra.simpleMode") setMode(event.newValue ?? "false"); + }; + window.addEventListener("storage", listener); + return () => window.removeEventListener("storage", listener); + }, []); + return <>{mode}; + } + renderWithProviders(); + const user = userEvent.setup(); + await user.click(screen.getByRole("switch", { name: "Simple mode" })); + await user.click(screen.getByRole("switch", { name: "Simple mode" })); + expect(screen.getByTestId("shell-mode")).toHaveTextContent("false"); + expect(errors.mock.calls.some((call) => String(call[0]).includes("Cannot update a component"))).toBe(false); + }); function simpleToggle(): HTMLElement { const row = screen.getByText("Simple mode").closest(".set-row"); if (!row) { diff --git a/ui-desktop/src/screens/SettingsScreen.tsx b/ui-desktop/src/screens/SettingsScreen.tsx index 8c9d71e3..cbe25430 100644 --- a/ui-desktop/src/screens/SettingsScreen.tsx +++ b/ui-desktop/src/screens/SettingsScreen.tsx @@ -567,8 +567,7 @@ export function SettingsScreen({ tenebra }: SettingsScreenProps) { // Multihop two-hop chain. Core-owned like the other toggles: off with no // selection until the user picks an entry and an exit node. The choices are // drawn from the active profile (the connected one, else the first stored) and - // sent by stable id; the core resolves them at connect time and falls back to a - // single hop for a pair that no longer fits the profile. + // sent by stable id; the core rejects a pair that no longer fits the profile. const multihopProfileId = tenebra.state.profile || tenebra.profiles[0]?.id || ""; const multihopNodes = @@ -622,28 +621,24 @@ export function SettingsScreen({ tenebra }: SettingsScreenProps) { ); function toggleSimpleMode() { - setSimpleMode((prev) => { - const next = !prev; - const value = next ? "true" : "false"; - localStorage.setItem("tenebra.simpleMode", value); - // Same-document writes don't fire `storage` natively (that event is for - // *other* tabs), so raise it ourselves for the app shell's listener. - window.dispatchEvent( - new StorageEvent("storage", { - key: "tenebra.simpleMode", - newValue: value, - }), - ); - return next; - }); + const next = !simpleMode; + setSimpleMode(next); + const value = next ? "true" : "false"; + localStorage.setItem("tenebra.simpleMode", value); + // Notify from the event handler: React can replay state updaters during + // render, when synchronously updating the listening app shell is invalid. + window.dispatchEvent( + new StorageEvent("storage", { + key: "tenebra.simpleMode", + newValue: value, + }), + ); } function toggleAutoFastest() { - setAutoFastestState((prev) => { - const next = !prev; - setAutoFastest(next); - return next; - }); + const next = !autoFastest; + setAutoFastestState(next); + setAutoFastest(next); } function toggleAutoInstall() { From fbe10d8fb855973fdfc56f12431d64dad107dad3 Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:34:24 +0300 Subject: [PATCH 21/56] fix(desktop): count only confirmed current TCP reachability --- ui-desktop/src/components/ServerList.test.tsx | 13 +++++++++++++ ui-desktop/src/components/ServerList.tsx | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/ui-desktop/src/components/ServerList.test.tsx b/ui-desktop/src/components/ServerList.test.tsx index 0f7a05fe..b07b6b78 100644 --- a/ui-desktop/src/components/ServerList.test.tsx +++ b/ui-desktop/src/components/ServerList.test.tsx @@ -90,6 +90,19 @@ describe("ServerList", () => { ).toBeInTheDocument(); }); + it("counts only fresh successful TCP measurements", () => { + const template = makeRows()[0]; + const rows = [ + { ...template, id: "fresh", name: "Fresh", rttMs: 27 }, + { ...template, id: "unknown", name: "Unmeasured", rttMs: null }, + { ...template, id: "old", name: "Stale", rttMs: 10, stale: true }, + { ...template, id: "failed", name: "Failed", rttMs: 0, dead: true }, + ]; + renderWithProviders(); + expect(screen.getByRole("heading", { name: /Nodes · 1 TCP reachable/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /lowest ping · now fresh/i })).toBeInTheDocument(); + }); + it("filters rows by region chip and back to all", async () => { const user = userEvent.setup(); // The region is controlled by the parent, so re-render with the value the diff --git a/ui-desktop/src/components/ServerList.tsx b/ui-desktop/src/components/ServerList.tsx index d2128990..5613fe10 100644 --- a/ui-desktop/src/components/ServerList.tsx +++ b/ui-desktop/src/components/ServerList.tsx @@ -157,7 +157,7 @@ export const ServerList = forwardRef( let best: ServerRow | null = null; let bestRtt = Infinity; for (const r of rows) { - if (!r.dead && r.rttMs !== null && r.rttMs < bestRtt) { + if (!r.dead && !r.stale && r.rttMs !== null && r.rttMs < bestRtt) { best = r; bestRtt = r.rttMs; } @@ -170,7 +170,7 @@ export const ServerList = forwardRef( // stand-in (exact while idle; connected-auto needs the prop). const isAuto = auto ?? activeNodeId === null; - const online = rows.filter((r) => !r.dead).length; + const online = rows.filter((r) => !r.dead && !r.stale && r.rttMs !== null).length; const insecureCount = rows.filter((r) => r.insecure).length; const insecureSummary = t.servers.insecureSummary .replace("{n}", String(insecureCount)) From 46bb276812c981cba7d8fc93f5c855e7fb612ef8 Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:37:58 +0300 Subject: [PATCH 22/56] feat(protection): add persistent Windows host guard --- core/protection/dns.go | 24 ++ core/protection/guard.go | 211 +++++++++++++++ core/protection/policy.go | 89 ++++++ core/protection/policy_test.go | 217 +++++++++++++++ core/protection/resolver.go | 167 ++++++++++++ core/protection/resolver_test.go | 60 +++++ core/protection/wfp_abi_windows.go | 190 +++++++++++++ core/protection/wfp_abi_windows_test.go | 181 +++++++++++++ core/protection/wfp_identity_windows.go | 211 +++++++++++++++ core/protection/wfp_other.go | 6 + core/protection/wfp_windows.go | 344 ++++++++++++++++++++++++ 11 files changed, 1700 insertions(+) create mode 100644 core/protection/dns.go create mode 100644 core/protection/guard.go create mode 100644 core/protection/policy.go create mode 100644 core/protection/policy_test.go create mode 100644 core/protection/resolver.go create mode 100644 core/protection/resolver_test.go create mode 100644 core/protection/wfp_abi_windows.go create mode 100644 core/protection/wfp_abi_windows_test.go create mode 100644 core/protection/wfp_identity_windows.go create mode 100644 core/protection/wfp_other.go create mode 100644 core/protection/wfp_windows.go diff --git a/core/protection/dns.go b/core/protection/dns.go new file mode 100644 index 00000000..62535fb8 --- /dev/null +++ b/core/protection/dns.go @@ -0,0 +1,24 @@ +package protection + +import ( + "fmt" + "net/netip" + "net/url" +) + +// ValidateDNS refuses an implicit plaintext/system bootstrap. The caller keeps +// the user's saved value; changing this requirement must be an explicit choice. +func ValidateDNS(endpoint string) error { + u, err := url.Parse(endpoint) + if err != nil || u.User != nil || u.Fragment != "" || (u.Scheme != "https" && u.Scheme != "tls") { + return fmt.Errorf("host protection requires an encrypted https:// or tls:// bootstrap resolver with a literal IP") + } + ip, err := netip.ParseAddr(u.Hostname()) + if err != nil || ip.Zone() != "" || ip.IsUnspecified() || ip.IsMulticast() { + return fmt.Errorf("host protection requires a literal-IP encrypted bootstrap resolver; hostname/system DNS is unavailable while blocked") + } + if u.Scheme == "tls" && (u.Path != "" && u.Path != "/" || u.RawQuery != "") { + return fmt.Errorf("TLS DNS bootstrap does not accept a path or query") + } + return nil +} diff --git a/core/protection/guard.go b/core/protection/guard.go new file mode 100644 index 00000000..a5369aba --- /dev/null +++ b/core/protection/guard.go @@ -0,0 +1,211 @@ +package protection + +import ( + "errors" + "sync" +) + +// State reports confirmed policy independently of the desired setting. +type State struct { + Status string `json:"status"` + Enforced bool `json:"enforced"` + Persistent bool `json:"persistent"` + Error string `json:"error,omitempty"` +} + +// Backend changes only owned policy. Replace and Remove must be atomic; a +// failed operation leaves the previous policy unchanged. Inspect verifies the +// complete four-layer default block, not just the existence of a provider. +type Backend interface { + Inspect() (present, complete bool, err error) + Replace(tunLUID uint64) error + ResolveTunnel(name, address string) (uint64, error) + Remove() error +} + +type Guard struct { + op sync.Mutex + mu sync.Mutex + backend Backend + state State + notify func() + tunnel *verifiedTunnel +} + +type verifiedTunnel struct { + name, address string + luid uint64 +} + +func New(b Backend) *Guard { + g := &Guard{backend: b, state: State{Status: "off"}} + if b == nil { + g.state.Status = "unavailable" + } + return g +} + +// SetNotify is configured once before commands begin. The callback runs without +// Guard.mu and may safely read Snapshot; it must not perform another operation. +func (g *Guard) SetNotify(f func()) { g.notify = f } +func (g *Guard) Snapshot() State { g.mu.Lock(); defer g.mu.Unlock(); return g.state } +func (g *Guard) set(s State) { + g.mu.Lock() + g.state = s + g.mu.Unlock() + if g.notify != nil { + g.notify() + } +} +func (g *Guard) confirm(s State, tunnel *verifiedTunnel) { + g.mu.Lock() + g.state, g.tunnel = s, tunnel + g.mu.Unlock() + if g.notify != nil { + g.notify() + } +} +func (g *Guard) pending() { s := g.Snapshot(); s.Status = "applying"; s.Error = ""; g.set(s) } +func (g *Guard) fail(err error) error { + s := g.Snapshot() + s.Status = "error" + s.Error = err.Error() + g.set(s) + return err +} + +// Reject reports a configuration refusal before native policy is touched. +// It preserves the last confirmed enforcement just like an apply failure. +func (g *Guard) Reject(err error) error { return g.fail(err) } +func (g *Guard) available() error { + if g.backend == nil { + return errors.New("persistent host protection is unavailable on this platform") + } + return nil +} + +// Recover never clears an existing guard on the strength of a preferences file. +// A crash may have happened between saving OFF and removing the policy. +func (g *Guard) Recover() error { + g.op.Lock() + defer g.op.Unlock() + if err := g.available(); err != nil { + return err + } + present, complete, err := g.backend.Inspect() + if err != nil { + return g.fail(err) + } + if !present { + g.confirm(State{Status: "off"}, nil) + return nil + } + g.set(State{Status: "blocked", Enforced: complete, Persistent: complete}) + return g.prepare() +} + +// Prepare closes TUN allowances before a process replacement, retaining the +// trusted engine/core bootstrap path. It is deliberately separate from Stop. +func (g *Guard) Prepare() error { + g.op.Lock() + defer g.op.Unlock() + return g.prepare() +} +func (g *Guard) prepare() error { + if err := g.available(); err != nil { + return err + } + g.pending() + if err := g.backend.Replace(0); err != nil { + return g.fail(err) + } + g.confirm(State{Status: "blocked", Enforced: true, Persistent: true}, nil) + return nil +} + +// VerifyTunnel is called only after the engine's successful probe. Resolving a TUN +// failure leaves lockdown in place; there is no fallback to a name or address. +func (g *Guard) VerifyTunnel(name, address string, systemProxy bool) error { + g.op.Lock() + defer g.op.Unlock() + if err := g.available(); err != nil { + return err + } + var luid uint64 + if !systemProxy { + var err error + luid, err = g.backend.ResolveTunnel(name, address) + if err != nil { + return g.fail(err) + } + if luid == 0 { + return g.fail(errors.New("TUN has no verified interface identity")) + } + } + g.pending() + if err := g.backend.Replace(luid); err != nil { + return g.fail(err) + } + g.confirm(State{Status: "blocked", Enforced: true, Persistent: true}, &verifiedTunnel{name, address, luid}) + return nil +} + +// TunnelPresent checks the same LUID, name and address that were permitted. +// A newly-created same-name interface cannot stand in for the verified one. +// checked=false leaves non-TUN/unprotected platforms to their existing watcher. +func (g *Guard) TunnelPresent() (checked, present bool) { + g.mu.Lock() + tunnel := g.tunnel + g.mu.Unlock() + if tunnel == nil || tunnel.luid == 0 { + return false, false + } + luid, err := g.backend.ResolveTunnel(tunnel.name, tunnel.address) + g.mu.Lock() + unchanged := tunnel == g.tunnel + g.mu.Unlock() + if !unchanged { + return true, true + } // the next tick checks the new policy + return true, err == nil && luid == tunnel.luid +} + +// Accepted marks the already-verified engine only after ALL local gates (including +// system proxy) pass. The caller publishes its connected state immediately after +// this; no intermediate active event is emitted over an unaccepted connection. +func (g *Guard) Accepted() { + g.mu.Lock() + defer g.mu.Unlock() + if g.state.Status == "blocked" && g.state.Enforced && g.tunnel != nil { + g.state.Status = "active" + } +} + +// Interrupted is metadata only. Persistent kernel policy requires no userspace +// cleanup to survive a crash, a stopped service or the relaunch limit. +func (g *Guard) Interrupted() { + g.mu.Lock() + changed := g.state.Status == "active" + if changed { + g.state.Status = "blocked" + } + g.mu.Unlock() + if changed && g.notify != nil { + g.notify() + } +} + +// Release is reserved for explicit OFF/Disconnect/uninstall, never Close. +func (g *Guard) Release() error { + g.op.Lock() + defer g.op.Unlock() + if g.backend == nil { + return nil + } + g.pending() + if err := g.backend.Remove(); err != nil { + return g.fail(err) + } + g.confirm(State{Status: "off"}, nil) + return nil +} diff --git a/core/protection/policy.go b/core/protection/policy.go new file mode 100644 index 00000000..75bdd224 --- /dev/null +++ b/core/protection/policy.go @@ -0,0 +1,89 @@ +// Package protection describes the persistent host guard without performing any +// OS operations. Only the production composition root installs a native Backend. +package protection + +import "fmt" + +type Layer uint8 + +const ( + Connect4 Layer = iota + Connect6 + Accept4 + Accept6 +) + +var Layers = [...]Layer{Connect4, Connect6, Accept4, Accept6} + +func (l Layer) Outbound() bool { return l == Connect4 || l == Connect6 } +func (l Layer) IPv6() bool { return l == Connect6 || l == Accept6 } + +type Field uint8 + +const ( + Loopback Field = iota + Interface + Application + Protocol + LocalPort + RemotePort + RemoteAddress +) +const ( + Core = "core" + Engine = "engine" + DHCP = "dhcp" +) + +type Condition struct { + Field Field + Number uint64 + Text string +} +type Rule struct { + Key string + Layer Layer + Weight uint64 + Permit bool + Conditions []Condition +} + +// Policy returns filters in decreasing priority. OR is expressed as separate +// filters; all conditions within a filter are AND. No caller application, LAN +// range, resolver, or DIRECT split exception is an input to this policy. +func Policy(tunLUID uint64) []Rule { + var rules []Rule + for _, l := range Layers { + add := func(key string, weight uint64, permit bool, c ...Condition) { + rules = append(rules, Rule{fmt.Sprintf("%d/%s", l, key), l, weight, permit, c}) + } + add("loopback", 100, true, Condition{Field: Loopback}) + if tunLUID != 0 { + add("tun", 90, true, Condition{Field: Interface, Number: tunLUID}) + } + portField := RemotePort + if !l.Outbound() { + portField = LocalPort + } + for _, proto := range []uint64{6, 17} { + add(fmt.Sprintf("dns-%d", proto), 80, false, Condition{Field: Protocol, Number: proto}, Condition{Field: portField, Number: 53}) + } + for _, app := range []string{Core, Engine} { + add(app, 70, true, Condition{Field: Application, Text: app}) + } + local, remote := uint64(68), uint64(67) + if l.IPv6() { + local, remote = 546, 547 + } + add("dhcp", 60, true, Condition{Field: Application, Text: DHCP}, Condition{Field: Protocol, Number: 17}, Condition{Field: LocalPort, Number: local}, Condition{Field: RemotePort, Number: remote}) + if l.IPv6() { + for typ := uint64(133); typ <= 136; typ++ { + for i, prefix := range []string{"fe80::/10", "ff02::/16"} { + add(fmt.Sprintf("ndp-%d-%d", typ, i), 50, true, Condition{Field: Protocol, Number: 58}, Condition{Field: LocalPort, Number: typ}, Condition{Field: RemotePort, Number: 0}, Condition{Field: RemoteAddress, Text: prefix}) + } + } + } + add("block", 1, false) + } + return rules +} diff --git a/core/protection/policy_test.go b/core/protection/policy_test.go new file mode 100644 index 00000000..f7a61984 --- /dev/null +++ b/core/protection/policy_test.go @@ -0,0 +1,217 @@ +package protection + +import ( + "errors" + "net/netip" + "testing" +) + +type packet struct { + layer Layer + app string + loop bool + luid uint64 + proto, local, remote uint64 + addr string +} + +func allowed(rules []Rule, p packet) bool { + for _, r := range rules { + if r.Layer != p.layer { + continue + } + match := true + for _, c := range r.Conditions { + switch c.Field { + case Loopback: + match = match && p.loop + case Interface: + match = match && p.luid == c.Number + case Application: + match = match && p.app == c.Text + case Protocol: + match = match && p.proto == c.Number + case LocalPort: + match = match && p.local == c.Number + case RemotePort: + match = match && p.remote == c.Number + case RemoteAddress: + a, err := netip.ParseAddr(p.addr) + match = match && err == nil && netip.MustParsePrefix(c.Text).Contains(a) + } + } + if match { + return r.Permit + } + } + return false +} + +func TestPolicyBlocksOrdinaryPhysicalAndTrustedPlainDNS(t *testing.T) { + rules := Policy(42) + for _, layer := range Layers { + p := packet{layer: layer, proto: 6, local: 50000, remote: 443} + if allowed(rules, p) { + t.Fatal("ordinary physical traffic permitted", layer) + } + p.app = Engine + if !allowed(rules, p) { + t.Fatal("engine transport blocked", layer) + } + if layer.Outbound() { + p.remote = 53 + } else { + p.local = 53 + } + if allowed(rules, p) { + t.Fatal("engine plaintext DNS escaped", layer) + } + p.luid = 42 + if !allowed(rules, p) { + t.Fatal("DNS inside TUN blocked", layer) + } + p.luid = 43 + if allowed(rules, p) { + t.Fatal("replacement uplink inherited TUN permission", layer) + } + p.loop = true + if !allowed(rules, p) { + t.Fatal("loopback blocked", layer) + } + } +} + +func TestPolicyLockdownDHCPNDPAndDefaultCoverage(t *testing.T) { + rules := Policy(0) + seen := map[Layer]int{} + for _, r := range rules { + if len(r.Conditions) == 0 && !r.Permit { + seen[r.Layer]++ + } + for _, c := range r.Conditions { + if c.Field == Interface { + t.Fatal("lockdown has TUN permit") + } + } + } + for _, layer := range Layers { + if seen[layer] != 1 { + t.Fatal("missing unique default block", layer) + } + p := packet{layer: layer, app: DHCP, proto: 17, local: 68, remote: 67} + if layer.IPv6() { + p.local, p.remote = 546, 547 + } + if !allowed(rules, p) { + t.Fatal("DHCP unavailable", layer) + } + p.app = "browser" + if allowed(rules, p) { + t.Fatal("untrusted DHCP-port bypass", layer) + } + } + for _, addr := range []string{"fe80::1", "ff02::1"} { + if !allowed(rules, packet{layer: Connect6, proto: 58, local: 135, remote: 0, addr: addr}) { + t.Fatal("NDP blocked") + } + } + if allowed(rules, packet{layer: Connect6, proto: 58, local: 128, remote: 0, addr: "fe80::1"}) { + t.Fatal("arbitrary ICMP allowed") + } + if allowed(rules, packet{layer: Connect6, proto: 58, local: 135, remote: 0, addr: "2001:db8::1"}) { + t.Fatal("offlink NDP allowed") + } +} + +type memoryBackend struct { + present, complete bool + applyErr, removeErr error + luid uint64 + applications []uint64 + removes int +} + +func (m *memoryBackend) Inspect() (bool, bool, error) { return m.present, m.complete, nil } +func (m *memoryBackend) Replace(luid uint64) error { + m.applications = append(m.applications, luid) + if m.applyErr != nil { + return m.applyErr + } + m.present, m.complete = true, true + return nil +} +func (m *memoryBackend) ResolveTunnel(string, string) (uint64, error) { + if m.luid == 0 { + return 0, errors.New("missing TUN") + } + return m.luid, nil +} +func (m *memoryBackend) Remove() error { + m.removes++ + if m.removeErr != nil { + return m.removeErr + } + m.present, m.complete = false, false + return nil +} + +func TestGuardFailureAndReleasePreserveTruth(t *testing.T) { + b := &memoryBackend{luid: 42} + g := New(b) + if err := g.Prepare(); err != nil { + t.Fatal(err) + } + if s := g.Snapshot(); s.Status != "blocked" || !s.Enforced || !s.Persistent { + t.Fatal(s) + } + g.Accepted() + if g.Snapshot().Status != "blocked" { + t.Fatal("lockdown alone was promoted without TUN/system-proxy verification") + } + if err := g.VerifyTunnel("tenebra", "172.19.0.1/30", false); err != nil { + t.Fatal(err) + } + if g.Snapshot().Status != "blocked" { + t.Fatal("unaccepted engine reported active") + } + g.Accepted() + if g.Snapshot().Status != "active" { + t.Fatal(g.Snapshot()) + } + g.Interrupted() + if g.Snapshot().Status != "blocked" || b.removes != 0 { + t.Fatal("process exit released policy") + } + b.applyErr = errors.New("transaction failed") + if g.Prepare() == nil || !g.Snapshot().Enforced || g.Snapshot().Status != "error" { + t.Fatal(g.Snapshot()) + } + b.removeErr = errors.New("cleanup failed") + if g.Release() == nil || !g.Snapshot().Enforced { + t.Fatal("failed release claimed off") + } + b.removeErr = nil + if g.Release() != nil || g.Snapshot().Enforced || g.Snapshot().Status != "off" { + t.Fatal(g.Snapshot()) + } +} + +func TestGuardRecoveryDoesNotOpenTrafficOrInventProtection(t *testing.T) { + b := &memoryBackend{} + g := New(b) + if err := g.Recover(); err != nil || len(b.applications) != 0 { + t.Fatal("startup with no policy wrote native state") + } + b.present, b.complete = true, true + if err := g.Recover(); err != nil || len(b.applications) != 1 || b.applications[0] != 0 { + t.Fatal("recovery did not lock down", err) + } + b2 := &memoryBackend{applyErr: errors.New("denied")} + g2 := New(b2) + if g2.Prepare() == nil || g2.Snapshot().Enforced { + t.Fatal("initial failed apply claims enforced") + } + if New(nil).Prepare() == nil || New(nil).Snapshot().Status != "unavailable" { + t.Fatal("nil native adapter accepted") + } +} diff --git a/core/protection/resolver.go b/core/protection/resolver.go new file mode 100644 index 00000000..9e131f4e --- /dev/null +++ b/core/protection/resolver.go @@ -0,0 +1,167 @@ +package protection + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// NewResolver creates no connections and changes no globals. The production +// entry point may install it before starting goroutines. While protected, even +// the resolver's retries use the same explicit encrypted endpoint: no OS DNS, +// proxy environment, redirected endpoint or plaintext fallback is consulted. +func NewResolver(source func() (endpoint string, required bool)) *net.Resolver { + client := &http.Client{ + Timeout: 4 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("DNS bootstrap redirects are forbidden") }, + Transport: &http.Transport{Proxy: nil, TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, TLSHandshakeTimeout: 3 * time.Second, ResponseHeaderTimeout: 3 * time.Second, MaxIdleConns: 2, MaxIdleConnsPerHost: 2, MaxConnsPerHost: 2, IdleConnTimeout: 30 * time.Second, ForceAttemptHTTP2: true}, + } + return &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + endpoint, required := source() + if !required { + return (&net.Dialer{Timeout: 4 * time.Second}).DialContext(ctx, network, address) + } + if err := ValidateDNS(endpoint); err != nil { + return nil, err + } + u, _ := url.Parse(endpoint) + if u.Scheme == "tls" { + port := u.Port() + if port == "" { + port = "853" + } + d := tls.Dialer{NetDialer: &net.Dialer{Timeout: 4 * time.Second}, Config: &tls.Config{ServerName: u.Hostname(), MinVersion: tls.VersionTLS12}} + return d.DialContext(ctx, "tcp", net.JoinHostPort(u.Hostname(), port)) + } + if u.Path == "" { + u.Path = "/dns-query" + } + return newDNSConn(ctx, u.String(), client), nil + }} +} + +// dnsConn adapts the Go resolver's TCP DNS framing to a single bounded RFC8484 +// POST. It is intentionally not a PacketConn, even when Resolver.Dial asks for +// "udp": net.Resolver then uses the stream framing specified by its contract. +type dnsConn struct { + mu sync.Mutex + ctx context.Context + cancel context.CancelFunc + endpoint string + client *http.Client + deadline time.Time + closed bool + pending []byte + response *bytes.Reader +} + +func newDNSConn(ctx context.Context, endpoint string, client *http.Client) *dnsConn { + ctx, cancel := context.WithCancel(ctx) + return &dnsConn{ctx: ctx, cancel: cancel, endpoint: endpoint, client: client} +} +func (c *dnsConn) Write(p []byte) (int, error) { + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return 0, net.ErrClosed + } + if len(c.pending)+len(p) > 65537 { + c.mu.Unlock() + return 0, errors.New("DNS query too large") + } + c.pending = append(c.pending, p...) + if len(c.pending) < 2 { + c.mu.Unlock() + return len(p), nil + } + n := int(binary.BigEndian.Uint16(c.pending)) + if n < 12 || len(c.pending) > n+2 { + c.mu.Unlock() + return 0, errors.New("invalid DNS stream frame") + } + if len(c.pending) < n+2 { + c.mu.Unlock() + return len(p), nil + } + query := append([]byte(nil), c.pending[2:]...) + c.pending = nil + deadline := c.deadline + c.mu.Unlock() + ctx, cancel := context.WithTimeout(c.ctx, 4*time.Second) + defer cancel() + if !deadline.IsZero() { + var stop context.CancelFunc + ctx, stop = context.WithDeadline(ctx, deadline) + defer stop() + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(query)) + if err != nil { + return 0, err + } + req.Header.Set("Content-Type", "application/dns-message") + req.Header.Set("Accept", "application/dns-message") + resp, err := c.client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("DNS bootstrap HTTP status %d", resp.StatusCode) + } + if strings.Split(resp.Header.Get("Content-Type"), ";")[0] != "application/dns-message" { + return 0, errors.New("DNS bootstrap returned an invalid media type") + } + answer, err := io.ReadAll(io.LimitReader(resp.Body, 65536)) + if err != nil { + return 0, err + } + if len(answer) < 12 || len(answer) > 65535 { + return 0, errors.New("DNS bootstrap returned an invalid length") + } + framed := binary.BigEndian.AppendUint16(nil, uint16(len(answer))) + framed = append(framed, answer...) + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return 0, net.ErrClosed + } + c.response = bytes.NewReader(framed) + return len(p), nil +} +func (c *dnsConn) Read(p []byte) (int, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return 0, net.ErrClosed + } + if c.response == nil { + return 0, errors.New("DNS query has not completed") + } + return c.response.Read(p) +} +func (c *dnsConn) Close() error { c.mu.Lock(); c.closed = true; c.mu.Unlock(); c.cancel(); return nil } +func (c *dnsConn) SetDeadline(t time.Time) error { + c.mu.Lock() + c.deadline = t + c.mu.Unlock() + return nil +} +func (c *dnsConn) SetReadDeadline(t time.Time) error { return c.SetDeadline(t) } +func (c *dnsConn) SetWriteDeadline(t time.Time) error { return c.SetDeadline(t) } +func (c *dnsConn) LocalAddr() net.Addr { return dnsAddr("core") } +func (c *dnsConn) RemoteAddr() net.Addr { return dnsAddr("encrypted-resolver") } + +type dnsAddr string + +func (a dnsAddr) Network() string { return "tcp" } +func (a dnsAddr) String() string { return string(a) } diff --git a/core/protection/resolver_test.go b/core/protection/resolver_test.go new file mode 100644 index 00000000..02c95513 --- /dev/null +++ b/core/protection/resolver_test.go @@ -0,0 +1,60 @@ +package protection + +import ( + "bytes" + "context" + "encoding/binary" + "io" + "net/http" + "testing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestEncryptedBootstrapRefusesPlaintextAndHostnameWithoutNetwork(t *testing.T) { + for _, value := range []string{"8.8.8.8", "udp://8.8.8.8", "https://dns.example.test/dns-query", "tls://dns.example.test", "http://1.1.1.1/dns-query", "quic://1.1.1.1"} { + if err := ValidateDNS(value); err == nil { + t.Fatal("invalid resolver accepted", value) + } + r := NewResolver(func() (string, bool) { return value, true }) + if _, err := r.Dial(context.Background(), "udp", "192.0.2.53:53"); err == nil { + t.Fatal("invalid resolver attempted fallback", value) + } + } + for _, value := range []string{"https://77.88.8.8/dns-query", "tls://1.1.1.1", "tls://[2606:4700:4700::1111]:853"} { + if err := ValidateDNS(value); err != nil { + t.Fatal(err) + } + } +} + +func TestDNSConnFramesBoundedHTTPSReplyWithoutSocket(t *testing.T) { + query := []byte{0x12, 0x34, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0} + answer := append([]byte(nil), query...) + answer[2] = 0x81 + called := 0 + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + called++ + body, _ := io.ReadAll(r.Body) + if r.Method != "POST" || r.URL.String() != "https://192.0.2.53/dns-query" || !bytes.Equal(body, query) { + t.Fatal("wrong encrypted query") + } + return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/dns-message"}}, Body: io.NopCloser(bytes.NewReader(answer))}, nil + })} + c := newDNSConn(context.Background(), "https://192.0.2.53/dns-query", client) + defer c.Close() + frame := binary.BigEndian.AppendUint16(nil, uint16(len(query))) + frame = append(frame, query...) + if _, err := c.Write(frame[:1]); err != nil { + t.Fatal(err) + } + if _, err := c.Write(frame[1:]); err != nil { + t.Fatal(err) + } + got, err := io.ReadAll(c) + if err != nil || called != 1 || !bytes.Equal(got[2:], answer) || int(binary.BigEndian.Uint16(got)) != len(answer) { + t.Fatalf("got=%x called=%d err=%v", got, called, err) + } +} diff --git a/core/protection/wfp_abi_windows.go b/core/protection/wfp_abi_windows.go new file mode 100644 index 00000000..de4dfbf1 --- /dev/null +++ b/core/protection/wfp_abi_windows.go @@ -0,0 +1,190 @@ +//go:build windows && (amd64 || arm64) + +package protection + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "runtime" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +// These are the 64-bit SDK layouts, including FWPM_FILTER0's 16-byte UNION. +// https://learn.microsoft.com/windows/win32/api/fwpmtypes/ns-fwpmtypes-fwpm_filter0 +type displayData struct{ Name, Description *uint16 } +type byteBlob struct { + Size uint32 + Data *byte +} +type wfpValue struct { + Type uint32 + Value uintptr +} +type wfpSession struct { + Key windows.GUID + Display displayData + Flags, Timeout, PID uint32 + SID *windows.SID + Username *uint16 + KernelMode uint8 +} +type wfpProvider struct { + Key windows.GUID + Display displayData + Flags uint32 + Data byteBlob + ServiceName *uint16 +} +type wfpSublayer struct { + Key windows.GUID + Display displayData + Flags uint32 + Provider *windows.GUID + Data byteBlob + Weight uint16 +} +type wfpAction struct { + Type uint32 + Key windows.GUID +} +type wfpCondition struct { + Field windows.GUID + Match uint32 + Value wfpValue +} +type wfpFilter struct { + Key windows.GUID + Display displayData + Flags uint32 + Provider *windows.GUID + Data byteBlob + Layer, Sublayer windows.GUID + Weight wfpValue + Count uint32 + Conditions *wfpCondition + Action wfpAction + Context [2]uint64 // rawContext OR providerContextKey, never two sequential fields + Reserved *windows.GUID + ID uint64 + EffectiveWeight wfpValue +} +type filterTemplate struct { + Provider *windows.GUID + Layer windows.GUID + EnumType, Flags uint32 + ProviderContext unsafe.Pointer + Count uint32 + Conditions *wfpCondition + ActionMask uint32 + Callout *windows.GUID +} +type v6Mask struct { + Address [16]byte + Prefix uint8 +} + +const ( + persistentFlag = uint32(1) + blockAction = uint32(0x1001) + permitAction = uint32(0x1002) + marker = "tenebra/persistent-host-guard/v1" +) + +// Stable ownership keys. A collision is an error unless the metadata and the +// provider relationship match; no operation deletes by display name. +var providerKey = guid("fcb43b44-9358-4cd7-a998-9e7f822d5248") +var sublayerKey = guid("fcb43b45-9358-4cd7-a998-9e7f822d5248") +var layerKeys = [4]windows.GUID{ + guid("c38d57d1-05a7-4c33-904f-7fbceee60e82"), guid("4a72393b-319f-44bc-84c3-ba54dcb3b6b4"), + guid("e1cd9fe7-f4b5-4273-96c0-592e487b8650"), guid("a3b42c97-9f04-4672-b87e-cee9c483257f"), +} +var fieldFlags = guid("632ce23b-5167-435c-86d7-e903684aa80c") +var fieldNextHop = guid("93ae8f5b-7f6f-4719-98c8-14e97429ef04") +var fieldLocalInterface = guid("4cd62a49-59c3-4969-b7f3-bda5d32890a4") +var fieldApp = guid("d78e1e87-8644-4ea5-9437-d809ecefc971") +var fieldUser = guid("af043a0a-b34d-4f86-979c-c90371af6e66") +var fieldProtocol = guid("3971ef2b-623e-4f9a-8cb1-6e79b806b9a7") +var fieldLocalPort = guid("0c1ba1af-5765-453f-af22-a8f791ac775b") +var fieldRemotePort = guid("c35a604d-d22b-4e1a-91b4-68f674ee674b") +var fieldRemoteAddress = guid("b235ae9a-1d64-49b8-a44c-5ff3d9095045") + +// GUID parsing is pure Go; it does not load a DLL or contact BFE. +func guid(s string) windows.GUID { + b, err := hex.DecodeString(strings.ReplaceAll(s, "-", "")) + if err != nil || len(b) != 16 { + panic(err) + } + g := windows.GUID{Data1: binary.BigEndian.Uint32(b[:4]), Data2: binary.BigEndian.Uint16(b[4:6]), Data3: binary.BigEndian.Uint16(b[6:8])} + copy(g.Data4[:], b[8:]) + return g +} +func filterKey(key string) windows.GUID { + h := sha256.Sum256([]byte(marker + "/" + key)) + return windows.GUID{Data1: binary.BigEndian.Uint32(h[:4]), Data2: binary.BigEndian.Uint16(h[4:6]), Data3: (binary.BigEndian.Uint16(h[6:8]) & 0x0fff) | 0x5000, Data4: [8]byte{(h[8] & 0x3f) | 0x80, h[9], h[10], h[11], h[12], h[13], h[14], h[15]}} +} + +// Pointer arguments remain typed GC roots through the entire synchronous call. +// Do not convert stack pointers to uintptr before entering an ordinary Go +// wrapper: a stack growth could otherwise invalidate the native address. +type nativeArg struct { + p unsafe.Pointer + value uintptr +} + +func ptr[T any](p *T) nativeArg { return nativeArg{p: unsafe.Pointer(p)} } +func num(n uintptr) nativeArg { return nativeArg{value: n} } + +type nativeCall func(string, ...nativeArg) error + +func callWFP(name string, args ...nativeArg) error { + dll := windows.NewLazySystemDLL("fwpuclnt.dll") + proc := dll.NewProc(name) + if err := proc.Find(); err != nil { + return err + } + values := make([]uintptr, len(args)) + for i, a := range args { + values[i] = a.value + if a.p != nil { + values[i] = uintptr(a.p) + } + } + r, _, _ := proc.Call(values...) + runtime.KeepAlive(args) + if name == "FwpmFreeMemory0" { + return nil + } // void API + if r != 0 { + return fmt.Errorf("%s: %w", name, syscall.Errno(r)) + } + return nil +} + +func transaction(call nativeCall, h uintptr, fn func() error) (err error) { + if err = call("FwpmTransactionBegin0", num(h), num(0)); err != nil { + return err + } + committed := false + defer func() { + if !committed { + abortErr := call("FwpmTransactionAbort0", num(h)) + if abortErr != nil { + err = fmt.Errorf("%w; abort: %v", err, abortErr) + } + } + }() + if err = fn(); err != nil { + return err + } + if err = call("FwpmTransactionCommit0", num(h)); err != nil { + return err + } + committed = true + return nil +} diff --git a/core/protection/wfp_abi_windows_test.go b/core/protection/wfp_abi_windows_test.go new file mode 100644 index 00000000..3b1db1cd --- /dev/null +++ b/core/protection/wfp_abi_windows_test.go @@ -0,0 +1,181 @@ +//go:build windows && (amd64 || arm64) + +package protection + +import ( + "errors" + "reflect" + "syscall" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +func TestWFPABI64WithoutNativeCalls(t *testing.T) { + f := wfpFilter{} + checks := map[string][2]uintptr{ + "filter size": {unsafe.Sizeof(f), 200}, "context": {unsafe.Offsetof(f.Context), 152}, + "reserved": {unsafe.Offsetof(f.Reserved), 168}, "id": {unsafe.Offsetof(f.ID), 176}, + "effective": {unsafe.Offsetof(f.EffectiveWeight), 184}, "value": {unsafe.Sizeof(wfpValue{}), 16}, + "condition": {unsafe.Sizeof(wfpCondition{}), 40}, "session": {unsafe.Sizeof(wfpSession{}), 72}, + "provider": {unsafe.Sizeof(wfpProvider{}), 64}, "sublayer": {unsafe.Sizeof(wfpSublayer{}), 72}, + } + for name, c := range checks { + if c[0] != c[1] { + t.Errorf("%s=%d want%d", name, c[0], c[1]) + } + } +} + +func TestWFPMarshalsOnlyScopedPersistentSoftPermits(t *testing.T) { + apps := map[string]appIdentity{} + for _, name := range []string{Core, Engine, DHCP} { + apps[name] = appIdentity{app: []byte{1, 2}, sd: []byte{3, 4, 5}} + } + var got []filterInfo + b := &windowsBackend{call: func(name string, args ...nativeArg) error { + if name != "FwpmFilterAdd0" { + t.Fatal("unexpected native call", name) + } + f := (*wfpFilter)(args[1].p) + if f.Provider == nil || *f.Provider != providerKey || f.Sublayer != sublayerKey || f.Flags != 1 || f.Context != [2]uint64{} { + t.Fatal("wrong filter lifetime/ownership") + } + got = append(got, filterInfo{f.Key, f.Layer, f.Flags, f.Action.Type, f.Count, *(*uint64)(unsafe.Pointer(f.Weight.Value))}) + if f.Count == 0 { + return nil + } + hasNextHop, hasLocal := false, false + for _, c := range unsafe.Slice(f.Conditions, f.Count) { + if c.Field == fieldUser { + blob := (*byteBlob)(unsafe.Pointer(c.Value.Value)) + if c.Value.Type != 14 || blob.Size != 3 || blob.Data == nil || *blob.Data != 3 { + t.Fatal("security descriptor is not an FWP_BYTE_BLOB") + } + } + if c.Field == fieldNextHop || c.Field == fieldLocalInterface { + hasNextHop = hasNextHop || c.Field == fieldNextHop + hasLocal = hasLocal || c.Field == fieldLocalInterface + if c.Value.Type != 4 || *(*uint64)(unsafe.Pointer(c.Value.Value)) != 42 { + t.Fatal("wrong TUN identity condition") + } + } + } + if hasNextHop || hasLocal { + inbound := f.Layer == layerKeys[Accept4] || f.Layer == layerKeys[Accept6] + if !hasNextHop || hasLocal != inbound { + t.Fatal("TUN permit does not constrain both directions of the flow") + } + } + return nil + }} + for _, r := range Policy(42) { + if err := b.addFilter(7, r, apps, nil); err != nil { + t.Fatal(err) + } + } + if !completeBlocks(got) { + t.Fatal("native form does not cover four default blocks") + } + got[0].flags = 0 // a non-block does not establish enforcement itself + for i := range got { + if got[i].count == 0 { + got[i].flags |= 32 + break + } + } + if completeBlocks(got) { + t.Fatal("disabled catch-all reported complete") + } +} + +func TestWFPOwnershipCollisionNeverDeletesForeignObjects(t *testing.T) { + data := []byte("not-tenebra") + p := &wfpProvider{Key: providerKey, Flags: 1, Data: makeBlob(data)} + var calls []string + b := &windowsBackend{call: func(name string, args ...nativeArg) error { + calls = append(calls, name) + switch name { + case "FwpmProviderGetByKey0": + *(**wfpProvider)(args[2].p) = p + return nil + case "FwpmFreeMemory0": + return nil + default: + return syscall.Errno(windows.FWP_E_SUBLAYER_NOT_FOUND) + } + }} + if _, _, err := b.owned(7); err == nil { + t.Fatal("foreign provider adopted") + } + if !reflect.DeepEqual(calls, []string{"FwpmProviderGetByKey0", "FwpmFreeMemory0"}) { + t.Fatal("foreign provider was touched", calls) + } +} + +func TestWFPDisabledOwnedPolicyCanBeRecoveredAndRemoved(t *testing.T) { + data := []byte(marker) + p := &wfpProvider{Key: providerKey, Flags: 1 | 0x10, Data: makeBlob(data)} + s := &wfpSublayer{Key: sublayerKey, Flags: 1, Provider: &providerKey, Data: makeBlob(data), Weight: 0xffff} + b := &windowsBackend{call: func(name string, args ...nativeArg) error { + switch name { + case "FwpmProviderGetByKey0": + *(**wfpProvider)(args[2].p) = p + case "FwpmSubLayerGetByKey0": + *(**wfpSublayer)(args[2].p) = s + case "FwpmFreeMemory0": + default: + t.Fatal("unexpected call", name) + } + return nil + }} + if _, _, err := b.owned(7); err != nil { + t.Fatal("disabled legitimate provider became unremovable", err) + } + stop := errors.New("fake enumeration boundary") + b.call = func(name string, args ...nativeArg) error { + if name != "FwpmFilterCreateEnumHandle0" { + t.Fatal(name) + } + template := (*filterTemplate)(args[1].p) + if template.Flags&0x18 != 0x18 { + t.Fatal("cleanup omits disabled/boot-time filters") + } + if template.Layer != layerKeys[Connect4] { + t.Fatal("enumeration must use a specific owned layer") + } + return stop + } + if _, err := b.filters(7); !errors.Is(err, stop) { + t.Fatal(err) + } +} + +func TestWFPTransactionCommitAndFailureAbortWithoutNativeCalls(t *testing.T) { + for _, fail := range []string{"", "operation", "FwpmTransactionBegin0", "FwpmTransactionCommit0"} { + t.Run(fail, func(t *testing.T) { + var calls []string + call := func(name string, _ ...nativeArg) error { + calls = append(calls, name) + if name == fail { + return errors.New("injected") + } + return nil + } + err := transaction(call, 7, func() error { return call("operation") }) + want := []string{"FwpmTransactionBegin0", "operation", "FwpmTransactionCommit0"} + switch fail { + case "operation": + want = []string{"FwpmTransactionBegin0", "operation", "FwpmTransactionAbort0"} + case "FwpmTransactionBegin0": + want = []string{"FwpmTransactionBegin0"} + case "FwpmTransactionCommit0": + want = append(want, "FwpmTransactionAbort0") + } + if !reflect.DeepEqual(calls, want) || (err == nil) != (fail == "") { + t.Fatalf("calls=%v err=%v", calls, err) + } + }) + } +} diff --git a/core/protection/wfp_identity_windows.go b/core/protection/wfp_identity_windows.go new file mode 100644 index 00000000..fe56efa0 --- /dev/null +++ b/core/protection/wfp_identity_windows.go @@ -0,0 +1,211 @@ +//go:build windows && (amd64 || arm64) + +package protection + +import ( + "errors" + "fmt" + "net/netip" + "os" + "path/filepath" + "runtime" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +func (b *windowsBackend) identities() (map[string]appIdentity, error) { + if b.enginePath == nil { + return nil, errors.New("engine executable identity unavailable") + } + engine, err := b.enginePath() + if err != nil { + return nil, err + } + core, err := os.Executable() + if err != nil { + return nil, err + } + systemDir, err := windows.GetSystemDirectory() + if err != nil { + return nil, err + } + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return nil, err + } + dhcpSID, _, _, err := windows.LookupSID("", "NT SERVICE\\Dhcp") + if err != nil { + return nil, err + } + paths := map[string]string{Core: core, Engine: engine, DHCP: filepath.Join(systemDir, "svchost.exe")} + result := make(map[string]appIdentity) + for name, path := range paths { + if err := trustedExecutable(path); err != nil { + return nil, fmt.Errorf("%s protection identity: %w", name, err) + } + sid := user.User.Sid.String() + if name == DHCP { + sid = dhcpSID.String() + } + // FWP_ACTRL_MATCH_FILTER=1. Match the account/service token as well as + // path; a same-name binary run by another account gets no exemption. + sd, err := windows.SecurityDescriptorFromString("D:(A;;CC;;;" + sid + ")") + if err != nil { + return nil, err + } + path16, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + var blob *byteBlob + if err := b.call("FwpmGetAppIdFromFileName0", ptr(path16), ptr(&blob)); err != nil { + return nil, err + } + if blob == nil || blob.Data == nil || blob.Size == 0 || blob.Size > 65536 { + if blob != nil { + b.free(unsafe.Pointer(blob)) + } + return nil, errors.New("invalid WFP executable identity") + } + app := append([]byte(nil), unsafe.Slice(blob.Data, blob.Size)...) + b.free(unsafe.Pointer(blob)) + sdBytes := append([]byte(nil), unsafe.Slice((*byte)(unsafe.Pointer(sd)), sd.Length())...) + runtime.KeepAlive(sd) + result[name] = appIdentity{app, sdBytes} + } + return result, nil +} + +// trustedExecutable is deliberately conservative. A persistent unrestricted +// application permit must never point at an ordinary user's replaceable file. +// ACL checks include owner-implied WRITE_DAC, parent DELETE_CHILD and reparse +// points. This rejects portable/user-writable installs with an actionable error. +func trustedExecutable(path string) error { + if !filepath.IsAbs(path) || strings.HasPrefix(path, `\\`) { + return errors.New("protection requires an absolute local executable path") + } + info, err := os.Stat(path) + if err != nil { + return err + } + if info.IsDir() { + return errors.New("executable path is a directory") + } + trustedInstaller, _, _, err := windows.LookupSID("", "NT SERVICE\\TrustedInstaller") + if err != nil { + return err + } + trusted := func(s *windows.SID) bool { + return s != nil && (s.String() == "S-1-5-18" || s.String() == "S-1-5-32-544" || s.String() == trustedInstaller.String()) + } + for depth, current := 0, filepath.Clean(path); ; depth, current = depth+1, filepath.Dir(current) { + name, err := windows.UTF16PtrFromString(current) + if err != nil { + return err + } + attrs, err := windows.GetFileAttributes(name) + if err != nil { + return err + } + if attrs&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return fmt.Errorf("protected executable path traverses a reparse point: %s", current) + } + sd, err := windows.GetNamedSecurityInfo(current, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION) + if err != nil { + return err + } + owner, _, err := sd.Owner() + if err != nil { + return err + } + if !trusted(owner) { + return fmt.Errorf("install Tenebra in an administrator-owned directory: %s", current) + } + acl, _, err := sd.DACL() + if err != nil { + return err + } + if acl == nil { + return fmt.Errorf("unrestricted executable ACL: %s", current) + } + // File/containing directory mutations and ancestor replacement rights. + var dangerous windows.ACCESS_MASK = 0x10000000 | 0x40000000 | 0x00010000 | 0x00040000 | 0x00080000 | 0x40 + if depth <= 1 { + dangerous |= 0x2 | 0x4 | 0x10 | 0x100 + } + for i := uint32(0); i < uint32(acl.AceCount); i++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(acl, i, &ace); err != nil { + return err + } + if ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 || ace.Header.AceType == windows.ACCESS_DENIED_ACE_TYPE { + continue + } + if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + return fmt.Errorf("unsupported executable ACL entry: %s", current) + } + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + if ace.Mask&dangerous != 0 && !trusted(sid) { + return fmt.Errorf("executable can be replaced by an untrusted identity: %s", current) + } + } + runtime.KeepAlive(sd) + if parent := filepath.Dir(current); parent == current { + break + } + if depth >= 32 { + return errors.New("executable path exceeds trust-check depth") + } + } + return nil +} + +func (b *windowsBackend) ResolveTunnel(name, address string) (uint64, error) { + prefix, err := netip.ParsePrefix(address) + if err != nil || name == "" { + return 0, errors.New("TUN identity requires its configured name and address") + } + var size uint32 = 16384 + for attempt := 0; attempt < 4; attempt++ { + if size == 0 || size > 4<<20 { + return 0, errors.New("adapter enumeration exceeded bound") + } + buf := make([]byte, size) + first := (*windows.IpAdapterAddresses)(unsafe.Pointer(&buf[0])) + err := windows.GetAdaptersAddresses(windows.AF_UNSPEC, windows.GAA_FLAG_SKIP_ANYCAST|windows.GAA_FLAG_SKIP_MULTICAST|windows.GAA_FLAG_SKIP_DNS_SERVER, 0, first, &size) + if err == windows.ERROR_BUFFER_OVERFLOW { + continue + } + if err != nil { + return 0, err + } + var found uint64 + for a := first; a != nil; a = a.Next { + if windows.UTF16PtrToString(a.FriendlyName) != name { + continue + } + if found != 0 || a.Luid == 0 || (a.IfType != 53 && a.IfType != 131) || a.OperStatus != windows.IfOperStatusUp { + return 0, errors.New("configured TUN is not a unique active virtual interface") + } + matched := false + for u := a.FirstUnicastAddress; u != nil; u = u.Next { + ip, ok := netip.AddrFromSlice(u.Address.IP()) + if ok && ip.Unmap() == prefix.Addr().Unmap() { + matched = true + } + } + if !matched { + return 0, errors.New("configured TUN does not own its expected address") + } + found = a.Luid + } + runtime.KeepAlive(buf) + if found != 0 { + return found, nil + } + return 0, errors.New("configured TUN was not found") + } + return 0, errors.New("adapter list kept changing") +} diff --git a/core/protection/wfp_other.go b/core/protection/wfp_other.go new file mode 100644 index 00000000..c371df16 --- /dev/null +++ b/core/protection/wfp_other.go @@ -0,0 +1,6 @@ +//go:build !windows || (!amd64 && !arm64) + +package protection + +// Unsupported ABIs never use the 64-bit WFP structs and never claim protection. +func NewWindowsBackend(func() (string, error)) Backend { return nil } diff --git a/core/protection/wfp_windows.go b/core/protection/wfp_windows.go new file mode 100644 index 00000000..0892aa0a --- /dev/null +++ b/core/protection/wfp_windows.go @@ -0,0 +1,344 @@ +//go:build windows && (amd64 || arm64) + +package protection + +import ( + "errors" + "net/netip" + "runtime" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +type windowsBackend struct { + enginePath func() (string, error) + call nativeCall +} + +// NewWindowsBackend is inert: no DLL is loaded, no interface is enumerated and +// no WFP session is opened until an explicit Guard operation. Production alone +// passes this backend to the daemon. Unit fixtures use an in-memory Backend. +func NewWindowsBackend(enginePath func() (string, error)) Backend { + return &windowsBackend{enginePath: enginePath, call: callWFP} +} + +func (b *windowsBackend) session(fn func(uintptr) error) error { + s := wfpSession{Timeout: 3000} + var h uintptr + if err := b.call("FwpmEngineOpen0", num(0), num(10), num(0), ptr(&s), ptr(&h)); err != nil { + return err + } + defer b.call("FwpmEngineClose0", num(h)) + return fn(h) +} +func (b *windowsBackend) free(p unsafe.Pointer) { b.call("FwpmFreeMemory0", ptr(&p)) } +func blobEqual(v byteBlob, s string) bool { + return v.Size == uint32(len(s)) && v.Data != nil && string(unsafe.Slice(v.Data, v.Size)) == s +} +func makeBlob(s []byte) byteBlob { + if len(s) == 0 { + return byteBlob{} + } + return byteBlob{Size: uint32(len(s)), Data: &s[0]} +} +func display(name string) displayData { + return displayData{Name: windows.StringToUTF16Ptr(name), Description: windows.StringToUTF16Ptr(marker)} +} +func notFound(err error, code windows.Handle) bool { return errors.Is(err, syscall.Errno(code)) } + +// owned validates both stable keys before any deletion. A partially-created +// provider is recoverable; an occupied key with foreign metadata is not ours. +func (b *windowsBackend) owned(h uintptr) (provider, sublayer bool, err error) { + s, err := b.ownedState(h) + return s.provider, s.sublayer, err +} + +type ownership struct{ provider, sublayer, enforcing bool } + +// Ownership and current enforcement are separate. Disabled/static/misweighted +// owned objects must remain removable and atomically repairable. +func (b *windowsBackend) ownedState(h uintptr) (out ownership, err error) { + var p *wfpProvider + err = b.call("FwpmProviderGetByKey0", num(h), ptr(&providerKey), ptr(&p)) + if err != nil && !notFound(err, windows.FWP_E_PROVIDER_NOT_FOUND) { + return out, err + } + if err == nil { + defer b.free(unsafe.Pointer(p)) + if p == nil || p.Key != providerKey || !blobEqual(p.Data, marker) { + return out, errors.New("WFP provider ownership mismatch") + } + out.provider = true + out.enforcing = p.Flags&persistentFlag != 0 && p.Flags&0x10 == 0 && (p.ServiceName == nil || windows.UTF16PtrToString(p.ServiceName) == "") + } + var s *wfpSublayer + err = b.call("FwpmSubLayerGetByKey0", num(h), ptr(&sublayerKey), ptr(&s)) + if err != nil && !notFound(err, windows.FWP_E_SUBLAYER_NOT_FOUND) { + return out, err + } + if err == nil { + defer b.free(unsafe.Pointer(s)) + if s == nil || s.Key != sublayerKey || s.Provider == nil || *s.Provider != providerKey || !blobEqual(s.Data, marker) { + return out, errors.New("WFP sublayer ownership mismatch") + } + out.sublayer = true + out.enforcing = out.enforcing && s.Flags&persistentFlag != 0 && s.Weight == 0xffff + } + if out.sublayer && !out.provider { + return out, errors.New("WFP sublayer has no owned provider") + } + return out, nil +} + +type filterInfo struct { + key, layer windows.GUID + flags, action, count uint32 + weight uint64 +} + +func (b *windowsBackend) filters(h uintptr) ([]filterInfo, error) { + var out []filterInfo + for _, layer := range layerKeys { + filters, err := b.filtersAtLayer(h, layer) + if err != nil { + return nil, err + } + if len(out)+len(filters) > 512 { + return nil, errors.New("too many owned WFP filters") + } + out = append(out, filters...) + } + return out, nil +} + +func (b *windowsBackend) filtersAtLayer(h uintptr, layer windows.GUID) ([]filterInfo, error) { + // SDK fwptypes.h: INCLUDE_BOOTTIME=8, INCLUDE_DISABLED=16. Cleanup must + // enumerate these too, even though they are not evidence of active policy. + template := filterTemplate{Provider: &providerKey, Layer: layer, Flags: 0x18, ActionMask: 0xffffffff} + var enum uintptr + if err := b.call("FwpmFilterCreateEnumHandle0", num(h), ptr(&template), ptr(&enum)); err != nil { + return nil, err + } + defer b.call("FwpmFilterDestroyEnumHandle0", num(h), num(enum)) + var out []filterInfo + for batch := 0; batch < 9; batch++ { + var entries **wfpFilter + var count uint32 + if err := b.call("FwpmFilterEnum0", num(h), num(enum), num(64), ptr(&entries), ptr(&count)); err != nil { + return nil, err + } + if count > 64 { + b.free(unsafe.Pointer(entries)) + return nil, errors.New("WFP enumeration exceeded batch bound") + } + if count == 0 { + if entries != nil { + b.free(unsafe.Pointer(entries)) + } + return out, nil + } + if entries == nil { + return nil, errors.New("WFP returned a nil filter array") + } + var invalid error + for _, f := range unsafe.Slice(entries, count) { + if f == nil || f.Provider == nil || *f.Provider != providerKey || f.Sublayer != sublayerKey || f.Layer != layer || !blobEqual(f.Data, marker) { + invalid = errors.New("refusing foreign filter in Tenebra provider") + break + } + var weight uint64 + if f.Weight.Type == 4 && f.Weight.Value != 0 { + weight = *(*uint64)(unsafe.Pointer(f.Weight.Value)) + } + out = append(out, filterInfo{f.Key, f.Layer, f.Flags, f.Action.Type, f.Count, weight}) + } + b.free(unsafe.Pointer(entries)) + if invalid != nil { + return nil, invalid + } + } + return nil, errors.New("too many owned WFP filters; explicit recovery required") +} + +func completeBlocks(filters []filterInfo) bool { + seen := map[windows.GUID]bool{} + for _, r := range Policy(0) { + if len(r.Conditions) != 0 { + continue + } + key := filterKey(r.Key) + for _, f := range filters { + if f.key == key && f.layer == layerKeys[r.Layer] && f.flags&1 != 0 && f.flags&0x22 == 0 && f.action == blockAction && f.count == 0 && f.weight == 1 { + seen[key] = true + } + } + } + return len(seen) == 4 +} + +func (b *windowsBackend) Inspect() (present, complete bool, err error) { + err = b.session(func(h uintptr) error { + owned, e := b.ownedState(h) + if e != nil { + return e + } + present = owned.provider || owned.sublayer + if !owned.provider { + return nil + } + filters, e := b.filters(h) + if e != nil { + return e + } + complete = owned.sublayer && owned.enforcing && completeBlocks(filters) + return nil + }) + return +} + +func (b *windowsBackend) Replace(luid uint64) error { + identities, err := b.identities() + if err != nil { + return err + } + // Explicit system/admin-only object ACL; no ordinary user can widen or remove + // a persistent exception. Independent firewalls retain their own policies. + sd, err := windows.SecurityDescriptorFromString("O:SYG:SYD:P(A;;GA;;;SY)(A;;GA;;;BA)") + if err != nil { + return err + } + return b.session(func(h uintptr) error { + return transaction(b.call, h, func() error { + // Recreate the foundation as well as filters in the SAME transaction. + // This repairs a legitimate provider disabled by BFE; disabled is an + // output-only flag and cannot be cleared by changing an Add argument. + if err := b.removeOwned(h); err != nil { + return err + } + data := []byte(marker) + provider := wfpProvider{Key: providerKey, Display: display("Tenebra persistent host protection"), Flags: 1, Data: makeBlob(data)} + if err := b.call("FwpmProviderAdd0", num(h), ptr(&provider), ptr(sd)); err != nil { + return err + } + sub := wfpSublayer{Key: sublayerKey, Display: display("Tenebra host protection"), Flags: 1, Provider: &providerKey, Data: makeBlob(data), Weight: 0xffff} + if err := b.call("FwpmSubLayerAdd0", num(h), ptr(&sub), ptr(sd)); err != nil { + return err + } + for _, r := range Policy(luid) { + if err := b.addFilter(h, r, identities, sd); err != nil { + return err + } + } + runtime.KeepAlive(data) + return nil + }) + }) +} + +func (b *windowsBackend) Remove() error { + return b.session(func(h uintptr) error { + return transaction(b.call, h, func() error { return b.removeOwned(h) }) + }) +} + +func (b *windowsBackend) removeOwned(h uintptr) error { + p, s, err := b.owned(h) + if err != nil { + return err + } + if !p && !s { + return nil + } + old, err := b.filters(h) + if err != nil { + return err + } + for _, f := range old { + key := f.key + if err := b.call("FwpmFilterDeleteByKey0", num(h), ptr(&key)); err != nil { + return err + } + } + if s { + if err := b.call("FwpmSubLayerDeleteByKey0", num(h), ptr(&sublayerKey)); err != nil { + return err + } + } + if p { + return b.call("FwpmProviderDeleteByKey0", num(h), ptr(&providerKey)) + } + return nil +} + +type appIdentity struct { + app []byte + sd []byte // self-relative descriptor, wrapped in FWP_BYTE_BLOB for conditions +} + +func (b *windowsBackend) addFilter(h uintptr, r Rule, identities map[string]appIdentity, sd *windows.SECURITY_DESCRIPTOR) error { + data := []byte(marker) + weight := new(uint64) + *weight = r.Weight + f := wfpFilter{Key: filterKey(r.Key), Display: display("Tenebra " + r.Key), Flags: 1, Provider: &providerKey, Data: makeBlob(data), Layer: layerKeys[r.Layer], Sublayer: sublayerKey, Weight: wfpValue{Type: 4, Value: uintptr(unsafe.Pointer(weight))}, Action: wfpAction{Type: blockAction}} + if r.Permit { + f.Action.Type = permitAction + } // soft permit: no CLEAR_ACTION_RIGHT + var conditions []wfpCondition + roots := []any{weight, data, identities} + add := func(field windows.GUID, match, typ uint32, value uintptr) { + conditions = append(conditions, wfpCondition{field, match, wfpValue{typ, value}}) + } + for _, c := range r.Conditions { + switch c.Field { + case Loopback: + add(fieldFlags, 6, 3, 1) // FWP_MATCH_FLAGS_ALL_SET + case Interface: + v := new(uint64) + *v = c.Number + roots = append(roots, v) + // Reauthorization uses the ORIGINAL flow layer in both packet + // directions. An inbound-established flow also needs its outgoing + // reply path constrained; local interface alone can be stale. + add(fieldNextHop, 0, 4, uintptr(unsafe.Pointer(v))) + if !r.Layer.Outbound() { + add(fieldLocalInterface, 0, 4, uintptr(unsafe.Pointer(v))) + } + case Application: + id, ok := identities[c.Text] + if !ok || len(id.app) == 0 || len(id.sd) == 0 { + return errors.New("missing trusted WFP app identity") + } + blob := &byteBlob{Size: uint32(len(id.app)), Data: &id.app[0]} + sdBlob := &byteBlob{Size: uint32(len(id.sd)), Data: &id.sd[0]} + roots = append(roots, blob, sdBlob) + add(fieldApp, 0, 12, uintptr(unsafe.Pointer(blob))) + add(fieldUser, 0, 14, uintptr(unsafe.Pointer(sdBlob))) + case Protocol: + add(fieldProtocol, 0, 1, uintptr(c.Number)) + case LocalPort: + add(fieldLocalPort, 0, 2, uintptr(c.Number)) + case RemotePort: + add(fieldRemotePort, 0, 2, uintptr(c.Number)) + case RemoteAddress: + prefix, err := netip.ParsePrefix(c.Text) + if err != nil || !prefix.Addr().Is6() { + return errors.New("invalid NDP prefix") + } + mask := &v6Mask{prefix.Addr().As16(), uint8(prefix.Bits())} + roots = append(roots, mask) + add(fieldRemoteAddress, 0, 257, uintptr(unsafe.Pointer(mask))) + default: + return errors.New("unsupported WFP condition") + } + } + if len(conditions) > 0 { + f.Conditions = &conditions[0] + f.Count = uint32(len(conditions)) + } + var id uint64 + err := b.call("FwpmFilterAdd0", num(h), ptr(&f), ptr(sd), ptr(&id)) + runtime.KeepAlive(roots) + return err +} From d77c9143b8477918118acb9970fdbef5d90c10c0 Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:38:49 +0300 Subject: [PATCH 23/56] feat(control): integrate confirmed host protection lifecycle --- adapters/windows/identity.go | 5 + cmd/tenebra-core/main.go | 26 +- cmd/tenebra-core/protection_other.go | 13 + cmd/tenebra-core/protection_windows.go | 30 ++ cmd/tenebra-core/service_windows.go | 1 + core/control/connect.go | 50 +++- core/control/daemon.go | 55 +++- core/control/protection.go | 103 +++++++ core/control/protection_proxy_test.go | 82 ++++++ core/control/protection_test.go | 376 +++++++++++++++++++++++++ core/control/protocol.go | 9 +- core/control/server_test.go | 2 + core/control/tunwatch.go | 26 +- core/control/zapret.go | 7 + 14 files changed, 744 insertions(+), 41 deletions(-) create mode 100644 adapters/windows/identity.go create mode 100644 cmd/tenebra-core/protection_other.go create mode 100644 cmd/tenebra-core/protection_windows.go create mode 100644 core/control/protection.go create mode 100644 core/control/protection_proxy_test.go create mode 100644 core/control/protection_test.go diff --git a/adapters/windows/identity.go b/adapters/windows/identity.go new file mode 100644 index 00000000..1db5fb98 --- /dev/null +++ b/adapters/windows/identity.go @@ -0,0 +1,5 @@ +package windows + +// ExecutablePath uses the same resolution as Start. The protection adapter +// validates this path and its ACL before granting it a persistent exception. +func (r *Runner) ExecutablePath() (string, error) { return r.resolveSingbox() } diff --git a/cmd/tenebra-core/main.go b/cmd/tenebra-core/main.go index 66310163..ae9cb4e8 100644 --- a/cmd/tenebra-core/main.go +++ b/cmd/tenebra-core/main.go @@ -35,6 +35,8 @@ var pipeMode = flag.Bool("pipe", false, "serve the control protocol on the named // daemon's transport without installing one, and what that daemon runs with. var socketMode = flag.Bool("socket", false, "serve the control protocol on a unix domain socket instead of stdin/stdout (macOS and Linux only)") +var releaseHostProtection = flag.Bool("release-host-protection", false, "explicitly remove only Tenebra-owned persistent host protection (administrator recovery/uninstall)") + // fileLogTail reads back the trailing lines of the process log when this run // writes one to disk — the Windows service sets it to its rotating writer's // Tail. It stays nil in the console and sidecar modes, whose diagnostics come @@ -51,6 +53,13 @@ func main() { return } flag.Parse() + if *releaseHostProtection { + if err := releaseNativeHostProtection(); err != nil { + log.Printf("host protection cleanup: %v", err) + os.Exit(1) + } + return + } // The service control manager starts us with no console and no usable // stdio, so the service path must be detected before anything touches // them. Off Windows this is always a no-op. @@ -116,6 +125,7 @@ func run(usePipe, useSocket bool) error { if err != nil { return err } + startProductionConnection(daemon) // Belt-and-suspenders for the system-proxy guard: Serve already calls // daemon.Close() (which clears any armed OS proxy) on a clean or signalled exit, // but a defer here also covers the --pipe/--socket paths and any early return, @@ -246,16 +256,18 @@ func buildDaemon() (*control.Daemon, error) { } else if cleared { log.Printf("tenebra-core: cleared a stale system proxy left by a previous run") } - // Autoconnect: if the preference is armed and a last connect is recorded, - // re-issue it now. This is the daemon's own start — shared by the sidecar, - // the --pipe console and the Windows service — so with the service the - // tunnel comes up with the machine, before anyone logs in or a UI attaches. - // The attempt runs in the background and never delays the control plane; a - // client connecting mid-attempt simply sees the connecting state. + return daemon, nil +} + +// Kept outside buildDaemon so ordinary constructor/unit fixtures never apply +// native policy or change the process resolver. Recovery precedes autoconnect. +func startProductionConnection(daemon *control.Daemon) { + configureHostProtection(daemon) + // Sidecar, --pipe console and service share this one startup attempt. It + // runs in the background; clients attaching during it see connecting. if daemon.AutoconnectOnStart() { log.Printf("tenebra-core: autoconnect: reconnecting the last profile") } - return daemon, nil } // ruleSetFiles are the RU geodata binaries that decide which resource directory diff --git a/cmd/tenebra-core/protection_other.go b/cmd/tenebra-core/protection_other.go new file mode 100644 index 00000000..d2eef8a8 --- /dev/null +++ b/cmd/tenebra-core/protection_other.go @@ -0,0 +1,13 @@ +//go:build !windows + +package main + +import ( + "errors" + "github.com/Divaaaan/tenebra/core/control" +) + +func configureHostProtection(*control.Daemon) {} +func releaseNativeHostProtection() error { + return errors.New("persistent host protection cleanup is Windows-only") +} diff --git a/cmd/tenebra-core/protection_windows.go b/cmd/tenebra-core/protection_windows.go new file mode 100644 index 00000000..bd81e2a6 --- /dev/null +++ b/cmd/tenebra-core/protection_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package main + +import ( + "errors" + "log" + "net" + + "github.com/Divaaaan/tenebra/core/control" + "github.com/Divaaaan/tenebra/core/protection" +) + +func releaseNativeHostProtection() error { + b := protection.NewWindowsBackend(nil) + if b == nil { + return errors.New("host protection cleanup is unsupported on this Windows architecture") + } + return b.Remove() // does not require the engine, settings, or a running service +} + +// Called only by real process entry points, before background jobs/autoconnect. +// Neither buildDaemon nor NewDaemon installs a resolver or invokes native WFP. +func configureHostProtection(d *control.Daemon) { + d.SetProtection(protection.New(protection.NewWindowsBackend(d.EngineExecutablePath))) + net.DefaultResolver = protection.NewResolver(d.ProtectionDNS) + if err := d.RecoverProtectionAtStartup(); err != nil { + log.Printf("tenebra-core: %v", err) + } +} diff --git a/cmd/tenebra-core/service_windows.go b/cmd/tenebra-core/service_windows.go index f1893727..c545068a 100644 --- a/cmd/tenebra-core/service_windows.go +++ b/cmd/tenebra-core/service_windows.go @@ -71,6 +71,7 @@ func (coreService) Execute(args []string, req <-chan svc.ChangeRequest, status c log.Printf("fatal: %v", err) return false, 1 } + startProductionConnection(daemon) l, err := control.ListenPipe(control.PipeName) if err != nil { log.Printf("fatal: %v", err) diff --git a/core/control/connect.go b/core/control/connect.go index 73f6710f..48daf6a8 100644 --- a/core/control/connect.go +++ b/core/control/connect.go @@ -11,6 +11,7 @@ import ( "github.com/Divaaaan/tenebra/core/fallback" "github.com/Divaaaan/tenebra/core/model" "github.com/Divaaaan/tenebra/core/profile" + "github.com/Divaaaan/tenebra/core/protection" "github.com/Divaaaan/tenebra/core/routing" "github.com/Divaaaan/tenebra/core/singbox" ) @@ -253,6 +254,14 @@ func (d *Daemon) startConnect(ctx context.Context, p profile.Profile, explicitNo return State{}, fmt.Errorf("connect: no alternative node to fail over to") } } + // Lock down before candidate pings or any process replacement. Validation + // above remains a read-only refusal; errors here never start an engine. + d.mu.Lock() + protectionRouting := d.routing + d.mu.Unlock() + if err := d.prepareProtection(protectionRouting); err != nil { + return State{}, fmt.Errorf("host protection: %w", err) + } // Choose the candidate ordering. The default is protocol preference (the // anti-DPI strategy). When the request asks for auto AND named no explicit @@ -438,8 +447,17 @@ func (d *Daemon) handleDisconnect(req Request) Response { // an in-flight relaunch/reconcile: that goroutine, blocked on connMu, wakes to // find the generation moved and yields instead of resurrecting the tunnel. d.connMu.Lock() - d.teardown(StateIdle, "", "") + cleanupErr := d.teardown(StateIdle, "", "") + d.protectionOp.Lock() + protectionErr := d.protection.Release() + d.protectionOp.Unlock() d.connMu.Unlock() + if protectionErr != nil { + protectionErr = fmt.Errorf("host protection release: %w", protectionErr) + } + if err := errors.Join(cleanupErr, protectionErr); err != nil { + return newError(req.ID, "disconnect: "+err.Error()) + } st := d.snapshotState() resp, err := newResult(req.ID, st) if err != nil { @@ -483,6 +501,7 @@ func (d *Daemon) teardown(newState ConnState, profileID, nodeID string) error { // itself on cancel; Stop is idempotent. _ = d.runner.Stop() d.wg.Wait() + d.protection.Interrupted() // Clear the OS system proxy AFTER the goroutines have drained — this is the // guard's single busiest chokepoint (every disconnect, hot-swap, connect @@ -802,6 +821,7 @@ func (d *Daemon) attemptNode(ctx context.Context, loop fallbackLoop, attempt fal // success on the current generation. if err := d.recordSuccess(ctx, loop, attempt, tracker, strat, sel); err != nil { _ = d.runner.Stop() + d.protection.Interrupted() if d.isCurrent(loop.gen) { tracker.blockedWithReason(attempt, "local setup failed") d.emitLog(LogError, err.Error()) @@ -860,6 +880,11 @@ func (d *Daemon) attemptNode(ctx context.Context, loop fallbackLoop, attempt fal // 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) error { + d.protectionOp.Lock() + defer d.protectionOp.Unlock() + if err := d.activateProtectionLocked(loop.ro, loop.tun); err != nil { + return fmt.Errorf("host protection: %w", err) + } if loop.tun.IsSystemProxy() { if err := d.armSystemProxy(loop.tun.MixedHostPort()); err != nil { return fmt.Errorf("system proxy setup failed: %w", err) @@ -898,6 +923,7 @@ 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() + d.protection.Accepted() d.setState(State{State: StateConnected, Profile: loop.profileID, Node: attempt.NodeID, Routing: d.snapshotState().Routing}) // Hand the live connection off to the watcher/poller. @@ -1084,6 +1110,7 @@ func (d *Daemon) watchProcess(ctx context.Context, gen uint64, profileID, nodeID msg = err.Error() } d.emitLog(LogError, "tunnel process exited: "+msg) + d.protection.Interrupted() // The mixed inbound died with the process, so an armed system proxy now // points at nothing — clear it immediately to restore direct connectivity. // A kill-switch relaunch below re-arms it once the tunnel is back @@ -1121,12 +1148,9 @@ const defaultRelaunchReset = 30 * time.Second // state; false means the caller should fall through to the plain error state. // uptime is how long the dead tunnel held the connection. // -// Why restart at all: strict_route only holds while sing-box runs — the moment -// the process dies, its filter rules and the tun route die with it, and traffic -// would fall back to the physical interface. The honest mitigation the daemon -// can offer is to put the tunnel (and its filters) back immediately, pinned to -// the node the user was on. During the gap the OS is unprotected; that window -// is why this relaunches eagerly rather than waiting for the user. +// Persistent host protection remains installed through process death and retry +// exhaustion. Relaunch restores availability on the user's node; it is not the +// mechanism that blocks direct traffic while the process is absent. // // The budget: a relaunch that held the tunnel up past relaunchResetAfter proved a // recovery, not one more turn of a crash-loop, so its eventual death refunds the @@ -1454,6 +1478,7 @@ func (d *Daemon) isCurrent(gen uint64) bool { // setState replaces the connection state and emits a state event reflecting it. func (d *Daemon) setState(s State) { + s.Protection = d.protection.Snapshot() d.mu.Lock() // Preserve the routing label if the new state didn't set one. if s.Routing == "" { @@ -1480,16 +1505,17 @@ func (d *Daemon) setState(s State) { } // stateEventBody projects a State into the state event payload (which omits the -// profile field — the protocol's state event carries state/node/error only). +// profile field — the protocol carries state/node/error and protection). func stateEventBody(s State) stateEvent { - return stateEvent{State: s.State, Node: s.Node, Error: s.Error} + return stateEvent{State: s.State, Node: s.Node, Error: s.Error, Protection: s.Protection} } // stateEvent is the wire body of a state event. type stateEvent struct { - State ConnState `json:"state"` - Node string `json:"node,omitempty"` - Error string `json:"error,omitempty"` + State ConnState `json:"state"` + Node string `json:"node,omitempty"` + Error string `json:"error,omitempty"` + Protection protection.State `json:"protection"` } // emitTraffic pushes a traffic counter event to the UI, if an emitter is set. diff --git a/core/control/daemon.go b/core/control/daemon.go index b2d23efa..537b6320 100644 --- a/core/control/daemon.go +++ b/core/control/daemon.go @@ -19,6 +19,7 @@ import ( "github.com/Divaaaan/tenebra/core/model" "github.com/Divaaaan/tenebra/core/nodecheck" "github.com/Divaaaan/tenebra/core/profile" + "github.com/Divaaaan/tenebra/core/protection" "github.com/Divaaaan/tenebra/core/routing" "github.com/Divaaaan/tenebra/core/singbox" "github.com/Divaaaan/tenebra/core/subscription" @@ -97,8 +98,10 @@ const defaultClientWriteTimeout = 30 * time.Second // lifecycle also spawns goroutines (traffic poll, process watch) that mutate // state, so every field touched from more than one goroutine is guarded by mu. type Daemon struct { - store *profile.Store - runner Runner + store *profile.Store + runner Runner + protection *protection.Guard + protectionOp sync.Mutex // serializes desired-setting changes with apply/activate // proxy applies and clears the OS-wide system proxy for ModeSystemProxy. It is // set once at construction (realSystemProxy in production, a fake in tests) and @@ -499,9 +502,10 @@ type Daemon struct { // with the stack pinned explicitly so the reported state always names it. func NewDaemon(store *profile.Store, runner Runner) *Daemon { d := &Daemon{ - store: store, - runner: runner, - proxy: newSystemProxyController(), + store: store, + runner: runner, + proxy: newSystemProxyController(), + protection: protection.New(nil), // 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 @@ -1017,8 +1021,10 @@ func (d *Daemon) Handle(ctx context.Context, req Request) Response { // snapshotState returns a copy of the current state under lock. func (d *Daemon) snapshotState() State { d.mu.Lock() - defer d.mu.Unlock() - return d.state + s := d.state + d.mu.Unlock() + s.Protection = d.protection.Snapshot() + return s } // snapshotRouting returns a copy of the live routing options under lock. The @@ -1650,18 +1656,38 @@ func sameStrings(a, b []string) bool { // persisted, and — unlike set_routing/set_split — applied to a live tunnel in // place: the daemon rebuilds the config for the node it is already on and // hot-swaps the sing-box process (see reapplyLive), so arming doesn't wait for -// the user to reconnect. Armed means strict_route on the tun (sing-box installs -// filter rules that drop any packet trying to escape the tunnel) plus an -// automatic relaunch if the tunnel process itself dies (see watchProcess). +// the user to reconnect. The preference alone does not claim enforcement: +// State.Protection reports the independent persistent guard's actual result. func (d *Daemon) handleSetKillSwitch(req Request) Response { + d.protectionOp.Lock() + before := d.protection.Snapshot() d.mu.Lock() changed := d.routing.KillSwitch != req.On d.routing.KillSwitch = req.On applySettingsToState(&d.state, d.routing, d.tun, d.autoconnect, d.autoFailover, d.crashReports, d.multihop) d.mu.Unlock() + var protectionErr error + if !req.On { + // Retry even if the desired value is already OFF: an earlier removal may + // have failed after settings were saved. Only explicit commands release. + protectionErr = d.protection.Release() + } else { + cur := d.snapshotState() + if (changed || before.Status != "active") && (cur.State == StateConnected || cur.State == StateConnecting || cur.Protection.Enforced || before.Status == "error") { + if err := protection.ValidateDNS(d.snapshotRouting().Normalize().DNSDirect); err != nil { + protectionErr = d.protection.Reject(err) + } else { + protectionErr = d.protection.Prepare() + } + } + } + d.protectionOp.Unlock() d.persistSettings() - if changed { + if protectionErr != nil { + return newError(req.ID, "host protection: "+protectionErr.Error()) + } + if changed || before.Status == "error" { d.reapplyLive() } @@ -1951,6 +1977,13 @@ func (d *Daemon) handleSetDNS(req Request) Response { if !routing.ValidDNSServer(req.DNSDirect) { return newError(req.ID, fmt.Sprintf("set_dns: invalid direct resolver %q", req.DNSDirect)) } + if _, required := d.ProtectionDNS(); required { + next := d.snapshotRouting() + next.DNSDirect = req.DNSDirect + if err := protection.ValidateDNS(next.Normalize().DNSDirect); err != nil { + return newError(req.ID, "set_dns: "+err.Error()) + } + } d.mu.Lock() // d.routing is always kept normalized, so "before" already holds the effective diff --git a/core/control/protection.go b/core/control/protection.go new file mode 100644 index 00000000..f919f68e --- /dev/null +++ b/core/control/protection.go @@ -0,0 +1,103 @@ +package control + +import ( + "errors" + "fmt" + + "github.com/Divaaaan/tenebra/core/protection" + "github.com/Divaaaan/tenebra/core/routing" + "github.com/Divaaaan/tenebra/core/singbox" +) + +// EngineExecutablePath exposes only the runner's exact resolved executable. +// A fake or unsupported runner cannot accidentally authorize a host binary. +func (d *Daemon) EngineExecutablePath() (string, error) { + if r, ok := d.runner.(interface{ ExecutablePath() (string, error) }); ok { + return r.ExecutablePath() + } + return "", errors.New("runner has no trusted executable identity") +} + +// SetProtection is the explicit composition seam. NewDaemon never attaches a +// native adapter; fake runners and ordinary unit fixtures cannot touch WFP. +func (d *Daemon) SetProtection(g *protection.Guard) { + if g == nil { + g = protection.New(nil) + } + d.protection = g + g.SetNotify(d.emitProtection) +} + +func (d *Daemon) emitProtection() { + s := d.snapshotState() + d.mu.Lock() + emit := d.emit + d.mu.Unlock() + if emit != nil { + emit(EventState, stateEventBody(s)) + } +} + +// RecoverProtectionAtStartup preserves/repairs existing owned policy regardless +// of settings; failure remains visible while the control plane stays available. +func (d *Daemon) RecoverProtectionAtStartup() error { + if err := d.protection.Recover(); err != nil { + return fmt.Errorf("host protection recovery: %w", err) + } + return nil +} + +func (d *Daemon) prepareProtection(ro routing.Options) error { + d.protectionOp.Lock() + defer d.protectionOp.Unlock() + d.mu.Lock() + wanted := d.routing.KillSwitch + d.mu.Unlock() + s := d.protection.Snapshot() + if !wanted { + if s.Status == "error" { + return fmt.Errorf("host protection unresolved: %s; retry OFF or Disconnect", s.Error) + } + if !s.Enforced { + return nil + } + } + if err := protection.ValidateDNS(ro.Normalize().DNSDirect); err != nil { + return d.protection.Reject(err) + } + return d.protection.Prepare() +} + +// activateProtectionLocked runs inside recordSuccess's acceptance critical +// section. Keep protectionOp held through every local gate and the connected +// publication, so a setting command cannot replace the verified policy. +func (d *Daemon) activateProtectionLocked(ro routing.Options, tun singbox.TunOptions) error { + d.mu.Lock() + wanted := d.routing.KillSwitch + d.mu.Unlock() + s := d.protection.Snapshot() + if !wanted && !s.Enforced { + if s.Status == "error" { + return fmt.Errorf("host protection unresolved: %s", s.Error) + } + return nil + } + if err := protection.ValidateDNS(ro.Normalize().DNSDirect); err != nil { + return d.protection.Reject(err) + } + name := tun.InterfaceName + if name == "" { + name = singbox.DefaultTUNName() + } + return d.protection.VerifyTunnel(name, tun.Address, tun.IsSystemProxy()) +} + +// ProtectionDNS returns the requested encrypted endpoint and whether plaintext +// fallback is forbidden. The production resolver reads this on each lookup. +func (d *Daemon) ProtectionDNS() (string, bool) { + d.mu.Lock() + ro := d.routing + d.mu.Unlock() + s := d.protection.Snapshot() + return ro.Normalize().DNSDirect, ro.KillSwitch || s.Enforced || s.Status == "error" +} diff --git a/core/control/protection_proxy_test.go b/core/control/protection_proxy_test.go new file mode 100644 index 00000000..9c89c44a --- /dev/null +++ b/core/control/protection_proxy_test.go @@ -0,0 +1,82 @@ +package control + +import ( + "context" + "errors" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Divaaaan/tenebra/core/protection" + "github.com/Divaaaan/tenebra/core/singbox" +) + +func TestHostProtectionProxyFailureCannotPublishActive(t *testing.T) { + d, r, p := coreAuditDaemon(t) + d.SetProtection(protection.New(&fakeHostProtection{})) + d.routing.KillSwitch = true + d.tun.Mode = singbox.ModeSystemProxy + d.proxy = &fakeProxyController{enableErr: errors.New("interactive proxy denied")} + var accepted atomic.Bool + d.SetEmitter(func(name string, body any) { + if state, ok := body.(stateEvent); name == EventState && ok { + if state.State == StateConnected || state.Protection.Status == "active" { + accepted.Store(true) + } + } + }) + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, "", false, false, "") + d.connMu.Unlock() + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(time.Second) + for d.snapshotState().State != StateError && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + s := d.snapshotState() + if accepted.Load() || s.State != StateError || s.Protection.Status != "blocked" || !s.Protection.Enforced || r.starts() != 1 || !strings.Contains(s.Error, "interactive proxy denied") { + t.Fatalf("accepted=%v starts=%d state=%+v", accepted.Load(), r.starts(), s) + } + if _, ok := d.lastGood.Get(p.ID); ok { + t.Fatal("failed local proxy gate recorded last-good") + } +} + +func TestHostProtectionDisconnectReportsBothCleanupFailuresAndRetries(t *testing.T) { + d, _, p := coreAuditDaemon(t) + native := &fakeHostProtection{} + d.SetProtection(protection.New(native)) + d.routing.KillSwitch = true + d.tun.Mode = singbox.ModeSystemProxy + proxy := &fakeProxyController{} + d.proxy = proxy + coreAuditConnect(t, d, p, "") + proxy.mu.Lock() + proxy.disableErr = errors.New("proxy rollback denied") + proxy.mu.Unlock() + native.mu.Lock() + native.removeErr = errors.New("WFP removal denied") + native.mu.Unlock() + resp := d.handleDisconnect(Request{ID: 1}) + if resp.Ok || !strings.Contains(resp.Error, "proxy rollback denied") || !strings.Contains(resp.Error, "WFP removal denied") { + t.Fatalf("cleanup failures lost: %+v", resp) + } + if s := d.snapshotState(); !s.Protection.Enforced || s.Protection.Status != "error" { + t.Fatal(s) + } + proxy.mu.Lock() + proxy.disableErr = nil + proxy.mu.Unlock() + native.mu.Lock() + native.removeErr = nil + native.mu.Unlock() + if resp = d.handleDisconnect(Request{ID: 2}); !resp.Ok { + t.Fatal(resp) + } + if s := d.snapshotState(); s.Protection.Enforced || s.Protection.Status != "off" || s.State != StateIdle { + t.Fatal(s) + } +} diff --git a/core/control/protection_test.go b/core/control/protection_test.go new file mode 100644 index 00000000..9d160c14 --- /dev/null +++ b/core/control/protection_test.go @@ -0,0 +1,376 @@ +package control + +import ( + "context" + "errors" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Divaaaan/tenebra/core/protection" +) + +type fakeHostProtection struct { + mu sync.Mutex + applyErr, resolveErr, removeErr error + installed bool + applied []uint64 + removed int + resolvedLUID uint64 + resolveCalls int +} + +func (f *fakeHostProtection) Inspect() (bool, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.installed, f.installed, nil +} +func (f *fakeHostProtection) Replace(luid uint64) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.applyErr != nil { + return f.applyErr + } + f.installed = true + f.applied = append(f.applied, luid) + return nil +} +func (f *fakeHostProtection) ResolveTunnel(string, string) (uint64, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.resolveCalls++ + if f.resolvedLUID != 0 { + return f.resolvedLUID, f.resolveErr + } + return 42, f.resolveErr +} +func (f *fakeHostProtection) Remove() error { + f.mu.Lock() + defer f.mu.Unlock() + f.removed++ + if f.removeErr != nil { + return f.removeErr + } + f.installed = false + return nil +} + +func TestHostProtectionApplyFailureStartsNoEngine(t *testing.T) { + d, r, p := coreAuditDaemon(t) + d.SetProtection(protection.New(&fakeHostProtection{applyErr: errors.New("denied")})) + d.routing.KillSwitch = true + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, "", false, false, "") + d.connMu.Unlock() + if err == nil || r.starts() != 0 || d.snapshotState().Protection.Enforced { + t.Fatalf("err=%v starts=%d state=%+v", err, r.starts(), d.snapshotState()) + } +} + +func TestHostProtectionTunFailureDoesNotAcceptNodeOrFallback(t *testing.T) { + d, r, p := coreAuditDaemon(t) + d.SetProtection(protection.New(&fakeHostProtection{resolveErr: errors.New("wrong TUN")})) + d.routing.KillSwitch = true + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, "", false, false, "") + d.connMu.Unlock() + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(time.Second) + for d.snapshotState().State != StateError && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + s := d.snapshotState() + if s.State != StateError || s.Protection.Status != "error" || !s.Protection.Enforced || r.starts() != 1 { + t.Fatalf("starts=%d state=%+v", r.starts(), s) + } + if _, ok := d.lastGood.Get(p.ID); ok { + t.Fatal("local protection failure recorded last-good") + } +} + +func TestHostProtectionTeardownRetainsAndExplicitDisconnectReleases(t *testing.T) { + d, _, p := coreAuditDaemon(t) + f := &fakeHostProtection{} + d.SetProtection(protection.New(f)) + d.routing.KillSwitch = true + s := coreAuditConnect(t, d, p, "") + if s.Protection.Status != "active" { + t.Fatal(s.Protection) + } + d.connMu.Lock() + d.teardown(StateIdle, "", "") + d.connMu.Unlock() + if s = d.snapshotState(); s.Protection.Status != "blocked" || !s.Protection.Enforced { + t.Fatal(s.Protection) + } + if resp := d.handleDisconnect(Request{ID: 1}); !resp.Ok { + t.Fatal(resp) + } + if s = d.snapshotState(); s.Protection.Status != "off" || s.Protection.Enforced { + t.Fatal(s.Protection) + } +} + +func TestHostProtectionOffFailureRemainsVisibleAndRetryWorks(t *testing.T) { + d, _, p := coreAuditDaemon(t) + f := &fakeHostProtection{} + d.SetProtection(protection.New(f)) + d.routing.KillSwitch = true + coreAuditConnect(t, d, p, "") + f.mu.Lock() + f.removeErr = errors.New("locked") + f.mu.Unlock() + if resp := d.handleSetKillSwitch(Request{ID: 1, On: false}); resp.Ok { + t.Fatal("failed cleanup acknowledged") + } + if s := d.snapshotState(); s.Protection.Status != "error" || !s.Protection.Enforced { + t.Fatal(s.Protection) + } + f.mu.Lock() + f.removeErr = nil + f.mu.Unlock() + if resp := d.handleSetKillSwitch(Request{ID: 2, On: false}); !resp.Ok { + t.Fatal(resp) + } + if s := d.snapshotState(); s.Protection.Status != "off" || s.Protection.Enforced { + t.Fatal(s.Protection) + } +} + +func TestHostProtectionDefaultConstructorCannotApplyNative(t *testing.T) { + d, r, p := coreAuditDaemon(t) + if s := d.snapshotState().Protection; s.Status != "unavailable" { + t.Fatal(s) + } + d.routing.KillSwitch = true + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, "", false, false, "") + d.connMu.Unlock() + if err == nil || r.starts() != 0 { + t.Fatal("missing injected backend accepted", err) + } +} + +func TestHostProtectionRepeatedOnKeepsVerifiedTunnel(t *testing.T) { + d, r, p := coreAuditDaemon(t) + f := &fakeHostProtection{} + d.SetProtection(protection.New(f)) + d.routing.KillSwitch = true + coreAuditConnect(t, d, p, "") + if resp := d.handleSetKillSwitch(Request{ID: 1, On: true}); !resp.Ok { + t.Fatal(resp) + } + if s := d.snapshotState(); s.Protection.Status != "active" || r.starts() != 1 { + t.Fatalf("repeated ON revoked live TUN: %+v", s) + } + f.mu.Lock() + defer f.mu.Unlock() + if len(f.applied) != 2 || f.applied[1] != 42 { + t.Fatal(f.applied) + } +} + +func TestHostProtectionAcceptanceSerializesToggle(t *testing.T) { + for _, offFirst := range []bool{false, true} { + t.Run(map[bool]string{false: "repeated-on", true: "off-on"}[offFirst], func(t *testing.T) { + d, _, p := coreAuditDaemon(t) + f := &fakeHostProtection{} + d.SetProtection(protection.New(f)) + d.routing.KillSwitch = true + verified, release := make(chan struct{}), make(chan struct{}) + var once sync.Once + d.logSink = func(_, msg string) { + if strings.HasPrefix(msg, "connect: up on ") { + once.Do(func() { close(verified); <-release }) + } + } + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + defer unblock() + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, "", false, false, "") + d.connMu.Unlock() + if err != nil { + t.Fatal(err) + } + select { + case <-verified: + case <-time.After(time.Second): + t.Fatal("verification barrier not reached") + } + done := make(chan Response, 1) + go func() { + if offFirst { + if resp := d.handleSetKillSwitch(Request{ID: 1, On: false}); !resp.Ok { + done <- resp + return + } + } + done <- d.handleSetKillSwitch(Request{ID: 2, On: true}) + }() + select { + case <-done: + t.Fatal("toggle replaced verified policy before acceptance") + case <-time.After(20 * time.Millisecond): + } + unblock() + select { + case resp := <-done: + if !resp.Ok { + t.Fatal(resp) + } + case <-time.After(time.Second): + t.Fatal("toggle stayed blocked") + } + deadline := time.Now().Add(time.Second) + for d.snapshotState().Protection.Status != "active" && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if s := d.snapshotState(); s.State != StateConnected || s.Protection.Status != "active" { + t.Fatal(s) + } + f.mu.Lock() + defer f.mu.Unlock() + if len(f.applied) == 0 || f.applied[len(f.applied)-1] != 42 { + t.Fatalf("active without verified TUN: %v", f.applied) + } + }) + } +} + +func TestHostProtectionRetryOnAfterInitialApplyFailure(t *testing.T) { + d, r, p := coreAuditDaemon(t) + f := &fakeHostProtection{applyErr: errors.New("injected")} + d.SetProtection(protection.New(f)) + d.routing.KillSwitch = true + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, "", false, false, "") + d.connMu.Unlock() + if err == nil { + t.Fatal("initial apply should fail") + } + f.mu.Lock() + f.applyErr = nil + f.mu.Unlock() + if resp := d.handleSetKillSwitch(Request{ID: 1, On: true}); !resp.Ok { + t.Fatal(resp) + } + if s := d.snapshotState(); s.Protection.Status != "blocked" || !s.Protection.Enforced || r.starts() != 0 { + t.Fatalf("retry did not apply idle lockdown: %+v", s) + } +} + +type refusingStopRunner struct { + *fakeRunner + refuse atomic.Bool +} + +func (r *refusingStopRunner) Stop() error { + if r.refuse.Load() { + return errors.New("injected stop refusal") + } + return r.fakeRunner.Stop() +} + +func TestHostProtectionLostVerifiedTunDemotesEvenWhenStopFails(t *testing.T) { + d, r, p := coreAuditDaemon(t) + wrapped := &refusingStopRunner{fakeRunner: r} + d.runner = wrapped + f := &fakeHostProtection{} + d.SetProtection(protection.New(f)) + d.routing.KillSwitch = true + d.tunWatchInterval = time.Millisecond + d.ifacePresent = func(string) bool { return true } // an identically named replacement is present + coreAuditConnect(t, d, p, "") + deadline := time.Now().Add(time.Second) + for { + f.mu.Lock() + checked := f.resolveCalls > 1 + f.mu.Unlock() + if checked { + break + } + if time.Now().After(deadline) { + t.Fatal("watcher did not verify original TUN identity") + } + time.Sleep(time.Millisecond) + } + wrapped.refuse.Store(true) + defer wrapped.refuse.Store(false) + f.mu.Lock() + f.resolvedLUID = 99 + f.mu.Unlock() + deadline = time.Now().Add(time.Second) + for d.snapshotState().State != StateError && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if s := d.snapshotState(); s.State != StateError || s.Protection.Status != "blocked" || !s.Protection.Enforced || r.starts() != 1 { + t.Fatalf("lost TUN still accepted: %+v", s) + } +} + +func TestHostProtectionCrashBudgetStillBlocks(t *testing.T) { + d, r, p := coreAuditDaemon(t) + f := &fakeHostProtection{} + d.SetProtection(protection.New(f)) + d.routing.KillSwitch = true + coreAuditConnect(t, d, p, "") + d.mu.Lock() + d.relaunches = maxRelaunches + d.mu.Unlock() + r.exit(errors.New("engine crash")) + deadline := time.Now().Add(time.Second) + for d.snapshotState().State != StateError && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if s := d.snapshotState(); s.State != StateError || s.Protection.Status != "blocked" || !s.Protection.Persistent || r.starts() != 1 { + t.Fatal(s) + } + f.mu.Lock() + defer f.mu.Unlock() + if f.removed != 0 { + t.Fatal("crash cap removed persistent guard") + } +} + +func TestHostProtectionStateEventCarriesConfirmedResult(t *testing.T) { + s := State{State: StateError, Protection: protection.State{Status: "error", Enforced: true, Persistent: true, Error: "injected"}} + if event := stateEventBody(s); event.Protection != s.Protection { + t.Fatal(event) + } +} + +func TestHostProtectionPlainBootstrapRefusalPreservesSettingAndNoNetwork(t *testing.T) { + d, r, p := coreAuditDaemon(t) + d.SetProtection(protection.New(&fakeHostProtection{})) + d.routing.KillSwitch = true + d.routing.DNSDirect = "udp://192.0.2.53" + for _, lookup := range d.nodeLookups() { + if _, err := lookup(context.Background(), "example.test"); err == nil { + t.Fatal("bootstrap fell back to plain DNS") + } + } + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, "", false, false, "") + d.connMu.Unlock() + if err == nil || r.starts() != 0 || d.snapshotState().Protection.Status != "error" { + t.Fatal("plaintext bootstrap did not fail closed", err) + } + if d.snapshotRouting().DNSDirect != "udp://192.0.2.53" { + t.Fatal("saved resolver was silently overwritten") + } + if resp := d.handleSetDNS(Request{ID: 1, DNSDirect: "https://77.88.8.8/dns-query"}); !resp.Ok { + t.Fatal(resp) + } + before := d.snapshotRouting().DNSDirect + if resp := d.handleSetDNS(Request{ID: 2, DNSDirect: "udp://192.0.2.53"}); resp.Ok { + t.Fatal("protected settings accepted plaintext") + } + if d.snapshotRouting().DNSDirect != before { + t.Fatal("rejected setting overwrote current resolver") + } +} diff --git a/core/control/protocol.go b/core/control/protocol.go index 89aae2a4..3aa21353 100644 --- a/core/control/protocol.go +++ b/core/control/protocol.go @@ -21,6 +21,7 @@ import ( "io" "github.com/Divaaaan/tenebra/core/model" + "github.com/Divaaaan/tenebra/core/protection" ) // Command names. These are the cmd values a Request may carry. @@ -307,10 +308,10 @@ type State struct { // connect will use; an empty/off split omits them. Split string `json:"split,omitempty"` SplitApps []string `json:"split_apps,omitempty"` - // KillSwitch reports whether the kill switch is armed (strict_route on the - // tun, plus an automatic relaunch if the tunnel process dies). Omitted when - // off, like the split fields. - KillSwitch bool `json:"kill_switch,omitempty"` + // KillSwitch is the desired preference. Protection separately reports + // confirmed host enforcement; a true setting is never evidence of applied rules. + KillSwitch bool `json:"kill_switch,omitempty"` + Protection protection.State `json:"protection"` // TLSFragment reports whether forced TLS ClientHello fragmentation is armed — // every TLS-bearing outbound carries tls.fragment. Omitted when off, like the // kill switch. The adaptive walk still reaches fragmentation per-node on a diff --git a/core/control/server_test.go b/core/control/server_test.go index e1178f68..2e2f91f3 100644 --- a/core/control/server_test.go +++ b/core/control/server_test.go @@ -14,6 +14,7 @@ import ( "github.com/Divaaaan/tenebra/core/fallback" "github.com/Divaaaan/tenebra/core/model" "github.com/Divaaaan/tenebra/core/profile" + "github.com/Divaaaan/tenebra/core/protection" ) // harness drives a Server over two pipes with a fake runner, demultiplexing the @@ -40,6 +41,7 @@ func newHarness(t *testing.T) *harness { } runner := newFakeRunner() d := NewDaemon(store, runner) + d.SetProtection(protection.New(&fakeHostProtection{})) // Shrink the fallback-loop timings so tests don't wait out real warmups/budgets. // The fake runner's Probe answers instantly, so a blocked candidate must burn // its whole (tiny) budget before the loop gives up on it — keep the budget diff --git a/core/control/tunwatch.go b/core/control/tunwatch.go index f40b3c4f..8806fc1a 100644 --- a/core/control/tunwatch.go +++ b/core/control/tunwatch.go @@ -51,16 +51,23 @@ func (d *Daemon) watchTunInterface(ctx context.Context, gen uint64) { if name == "" { return } + present := func() bool { + if checked, exists := d.protection.TunnelPresent(); checked { + return exists + } + return d.ifacePresent(name) + } - // Wait for it to come up first: reporting "gone" for an interface that has not - // appeared yet would turn a slow start into a failure. - appeared := false + // A protected interface was already observed by VerifyTunnel before Active. + // If it vanished before this watcher starts, do not wait and silently give up. + appeared, _ := d.protection.TunnelPresent() + // Unprotected platforms still wait for their first name-based observation. deadline := time.Now().Add(tunAppearBudget) - for time.Now().Before(deadline) { + for !appeared && time.Now().Before(deadline) { if !d.isCurrent(gen) || ctx.Err() != nil { return } - if d.ifacePresent(name) { + if present() { appeared = true break } @@ -83,7 +90,7 @@ func (d *Daemon) watchTunInterface(ctx context.Context, gen uint64) { if !d.isCurrent(gen) { return // superseded; a newer connection owns the state } - if d.ifacePresent(name) { + if present() { continue } // Give it one grace beat: an adapter can flicker while the stack @@ -98,7 +105,7 @@ func (d *Daemon) watchTunInterface(ctx context.Context, gen uint64) { return case <-time.After(grace): } - if !d.isCurrent(gen) || d.ifacePresent(name) { + if !d.isCurrent(gen) || present() { continue } @@ -106,6 +113,9 @@ func (d *Daemon) watchTunInterface(ctx context.Context, gen uint64) { // Whatever sing-box said before losing its adapter is the only explanation // available, and it is exactly what was missing while this was diagnosed. d.emitSingboxTail() + // Confirmed loss invalidates active protection even when Stop fails or + // the process never sends a Done event. The persistent block stays owned. + d.protection.Interrupted() // Stop the orphan rather than inventing a state here. A process with no // interface carries nothing, and stopping it lands on watchProcess — the // one path that already disarms the system proxy, spends the kill-switch @@ -113,6 +123,8 @@ func (d *Daemon) watchTunInterface(ctx context.Context, gen uint64) { // how the state ends up disagreeing with reality, which is this bug. if err := d.runner.Stop(); err != nil { d.emitLog(LogError, "could not stop the tunnel process: "+err.Error()) + cur := d.snapshotState() + d.setState(State{State: StateError, Profile: cur.Profile, Node: cur.Node, Error: "tunnel interface disappeared; could not stop engine: " + err.Error()}) } return } diff --git a/core/control/zapret.go b/core/control/zapret.go index c66a901f..f097081a 100644 --- a/core/control/zapret.go +++ b/core/control/zapret.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "errors" "fmt" + "net" "os" "path/filepath" "strings" @@ -12,6 +13,7 @@ import ( "time" "github.com/Divaaaan/tenebra/core/dnswire" + "github.com/Divaaaan/tenebra/core/protection" "github.com/Divaaaan/tenebra/core/zapret" ) @@ -278,6 +280,11 @@ func (d *Daemon) excludeNodesFromZapret(dir string) { // function was written to replace, and silence about it leaves a user whose // nodes are still being desynced with nothing to go on. func (d *Daemon) nodeLookups() []zapret.Lookup { + if endpoint, required := d.ProtectionDNS(); required { + if err := protection.ValidateDNS(endpoint); err != nil { + return []zapret.Lookup{func(context.Context, string) ([]net.IP, error) { return nil, err }} + } + } d.mu.Lock() direct := strings.TrimSpace(d.routing.DNSDirect) d.mu.Unlock() From 7aff666a35cbb7eb9423966fc27e13903688fb7d Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:39:24 +0300 Subject: [PATCH 24/56] fix(protection): reuse the lazy WFP module handle --- core/protection/wfp_abi_windows.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/core/protection/wfp_abi_windows.go b/core/protection/wfp_abi_windows.go index de4dfbf1..433c12c4 100644 --- a/core/protection/wfp_abi_windows.go +++ b/core/protection/wfp_abi_windows.go @@ -142,9 +142,12 @@ func num(n uintptr) nativeArg { return nativeArg{value: n} } type nativeCall func(string, ...nativeArg) error +// Lazy construction performs no native call. Reuse the loaded module instead +// of accumulating a LoadLibrary reference for each filter operation. +var wfpDLL = windows.NewLazySystemDLL("fwpuclnt.dll") + func callWFP(name string, args ...nativeArg) error { - dll := windows.NewLazySystemDLL("fwpuclnt.dll") - proc := dll.NewProc(name) + proc := wfpDLL.NewProc(name) if err := proc.Find(); err != nil { return err } From d734845e7247b356ed56fabd344fd4ae23185d84 Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:52:55 +0300 Subject: [PATCH 25/56] fix(control): serialize protected DNS settings with guard changes --- core/control/daemon.go | 6 +++ .../protection_dns_concurrency_test.go | 50 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 core/control/protection_dns_concurrency_test.go diff --git a/core/control/daemon.go b/core/control/daemon.go index 537b6320..6a8fea4f 100644 --- a/core/control/daemon.go +++ b/core/control/daemon.go @@ -1977,10 +1977,15 @@ func (d *Daemon) handleSetDNS(req Request) Response { if !routing.ValidDNSServer(req.DNSDirect) { return newError(req.ID, fmt.Sprintf("set_dns: invalid direct resolver %q", req.DNSDirect)) } + // Serialize the protection check and preference write with ON/OFF. Otherwise + // ON can validate the old encrypted endpoint while this command saves a new + // plaintext endpoint based on an earlier unprotected snapshot. + d.protectionOp.Lock() if _, required := d.ProtectionDNS(); required { next := d.snapshotRouting() next.DNSDirect = req.DNSDirect if err := protection.ValidateDNS(next.Normalize().DNSDirect); err != nil { + d.protectionOp.Unlock() return newError(req.ID, "set_dns: "+err.Error()) } } @@ -2000,6 +2005,7 @@ func (d *Daemon) handleSetDNS(req Request) Response { changed := dnsPrefsDiffer(before, d.routing) applySettingsToState(&d.state, d.routing, d.tun, d.autoconnect, d.autoFailover, d.crashReports, d.multihop) d.mu.Unlock() + d.protectionOp.Unlock() // reapplyLive acquires connMu and later protectionOp d.persistSettings() if changed { diff --git a/core/control/protection_dns_concurrency_test.go b/core/control/protection_dns_concurrency_test.go new file mode 100644 index 00000000..b0385e11 --- /dev/null +++ b/core/control/protection_dns_concurrency_test.go @@ -0,0 +1,50 @@ +package control + +import ( + "sync" + "testing" + "time" + + "github.com/Divaaaan/tenebra/core/protection" +) + +func TestHostProtectionDNSValidationSerializesWithOn(t *testing.T) { + d, _, _ := coreAuditDaemon(t) + d.SetProtection(protection.New(&fakeHostProtection{})) + before := d.snapshotRouting().DNSDirect + + // ON owns protectionOp while it validates/applies policy. A DNS command + // arriving then must validate against the eventual ON value, not an earlier + // unprotected snapshot. This models the setting boundary without native I/O. + d.protectionOp.Lock() + var unlockOnce sync.Once + unlock := func() { unlockOnce.Do(d.protectionOp.Unlock) } + defer unlock() + started := make(chan struct{}) + done := make(chan Response, 1) + go func() { + close(started) + done <- d.handleSetDNS(Request{ID: 1, DNSDirect: "udp://192.0.2.53"}) + }() + <-started + select { + case resp := <-done: + t.Fatalf("DNS setting crossed the ON critical section: %+v", resp) + case <-time.After(20 * time.Millisecond): + } + d.mu.Lock() + d.routing.KillSwitch = true + d.mu.Unlock() + unlock() + select { + case resp := <-done: + if resp.Ok { + t.Fatal("ON accepted a racing plaintext bootstrap") + } + case <-time.After(time.Second): + t.Fatal("DNS command did not leave its critical section") + } + if got := d.snapshotRouting().DNSDirect; got != before { + t.Fatalf("rejected DNS overwritten: %q, want %q", got, before) + } +} From 470b2cdf3123a382d3578ff72d4e692fcf746ff0 Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:56:18 +0300 Subject: [PATCH 26/56] fix(control): fence connection acceptance against teardown cancellation --- core/control/connect.go | 12 +++ core/control/protection_proxy_test.go | 107 ++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/core/control/connect.go b/core/control/connect.go index 48daf6a8..32ffb766 100644 --- a/core/control/connect.go +++ b/core/control/connect.go @@ -477,6 +477,12 @@ func (d *Daemon) handleDisconnect(req Request) Response { // 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) error { + // Acceptance holds protectionOp until Active/Connected is published. Claim + // cancellation under the same fence so that publication either finishes + // before teardown, or observes this generation as cancelled before any gates. + // Lock order is connMu -> protectionOp -> mu; recordSuccess never waits on + // connMu, and setting commands release protectionOp before reapplyLive. + d.protectionOp.Lock() d.mu.Lock() cancel := d.cancel d.cancel = nil @@ -495,6 +501,9 @@ func (d *Daemon) teardown(newState ConnState, profileID, nodeID string) error { if cancel != nil { cancel() } + // An old fallback goroutine may be waiting to enter recordSuccess. It must + // acquire the fence, see cancellation and drain, so never hold it for wg.Wait. + d.protectionOp.Unlock() // Stop the process and wait for connection goroutines (the fallback loop, then // any watcher/poller it started) to finish before we declare the new state, so // events don't interleave across connections. The loop also stops the runner @@ -882,6 +891,9 @@ func (d *Daemon) attemptNode(ctx context.Context, loop fallbackLoop, attempt fal func (d *Daemon) recordSuccess(ctx context.Context, loop fallbackLoop, attempt fallback.Attempt, tracker *attemptTracker, strat fallback.Strategy, sel selectorShape) error { d.protectionOp.Lock() defer d.protectionOp.Unlock() + if ctx.Err() != nil || !d.isCurrent(loop.gen) { + return context.Canceled + } if err := d.activateProtectionLocked(loop.ro, loop.tun); err != nil { return fmt.Errorf("host protection: %w", err) } diff --git a/core/control/protection_proxy_test.go b/core/control/protection_proxy_test.go index 9c89c44a..561380f3 100644 --- a/core/control/protection_proxy_test.go +++ b/core/control/protection_proxy_test.go @@ -4,14 +4,121 @@ import ( "context" "errors" "strings" + "sync" "sync/atomic" "testing" "time" + "github.com/Divaaaan/tenebra/core/fallback" "github.com/Divaaaan/tenebra/core/protection" "github.com/Divaaaan/tenebra/core/singbox" ) +func TestHostProtectionDisconnectCannotPublishCancelledAcceptance(t *testing.T) { + d, _, p := coreAuditDaemon(t) + d.SetProtection(protection.New(&fakeHostProtection{})) + d.routing.KillSwitch = true + d.tun.Mode = singbox.ModeSystemProxy + accepting, release := make(chan struct{}), make(chan struct{}) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + defer unblock() + d.logSink = func(_, msg string) { + if strings.HasPrefix(msg, "connect: up on ") { + close(accepting) + <-release // all gates passed, but Active/Connected has not been published + } + } + var cancelled, publishedAfterCancel atomic.Bool + d.SetEmitter(func(name string, body any) { + if state, ok := body.(stateEvent); name == EventState && ok && cancelled.Load() { + // Interrupted can report blocked with the previous connection phase + // while teardown drains. Only an active publication claims acceptance. + if state.Protection.Status == "active" { + publishedAfterCancel.Store(true) + } + } + }) + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, "", false, false, "") + d.connMu.Unlock() + if err != nil { + t.Fatal(err) + } + select { + case <-accepting: + case <-time.After(time.Second): + t.Fatal("acceptance barrier not reached") + } + // Observe real teardown cancellation. In the broken ordering it happens + // while the acceptance goroutine is still parked in the log callback. + cancelledEvent := make(chan struct{}) + d.mu.Lock() + cancel := d.cancel + d.cancel = func() { + cancel() + cancelled.Store(true) + close(cancelledEvent) + } + d.mu.Unlock() + done := make(chan Response, 1) + go func() { done <- d.handleDisconnect(Request{ID: 1}) }() + select { + case <-cancelledEvent: + case <-time.After(50 * time.Millisecond): + // A fenced teardown waits until acceptance has finished publishing. + } + unblock() + select { + case resp := <-done: + if !resp.Ok { + t.Fatal(resp) + } + case <-time.After(time.Second): + t.Fatal("disconnect did not drain acceptance") + } + if publishedAfterCancel.Load() { + t.Fatal("cancelled connection published Active/Connected during disconnect") + } + if s := d.snapshotState(); s.State != StateIdle || s.Protection.Status != "off" { + t.Fatalf("disconnect did not finish cleanup: %+v", s) + } +} + +func TestHostProtectionSupersededAcceptanceSkipsLocalGates(t *testing.T) { + for _, cause := range []string{"cancelled", "generation"} { + t.Run(cause, func(t *testing.T) { + d, _, _ := coreAuditDaemon(t) + native := &fakeHostProtection{} + d.SetProtection(protection.New(native)) + d.routing.KillSwitch = true + d.tun.Mode = singbox.ModeSystemProxy + proxy := &fakeProxyController{} + d.proxy = proxy + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + loop := fallbackLoop{gen: d.generation, ro: d.routing, tun: d.tun} + if cause == "cancelled" { + cancel() + } else { + loop.gen++ + } + err := d.recordSuccess(ctx, loop, fallback.Attempt{}, nil, fallback.Strategy{}, selectorShape{}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("superseded acceptance returned %v", err) + } + if s := d.snapshotState(); s.Protection.Status != "off" || proxy.enables() != 0 { + t.Fatalf("superseded acceptance applied local gates: proxy=%d state=%+v", proxy.enables(), s) + } + native.mu.Lock() + defer native.mu.Unlock() + if len(native.applied) != 0 { + t.Fatalf("superseded acceptance replaced native policy: %v", native.applied) + } + }) + } +} + func TestHostProtectionProxyFailureCannotPublishActive(t *testing.T) { d, r, p := coreAuditDaemon(t) d.SetProtection(protection.New(&fakeHostProtection{})) From bcd926bd3e0d05df4dc2044e2f4b86491ef596be Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:57:09 +0300 Subject: [PATCH 27/56] fix(protection): preserve explicit non-Windows engine compatibility --- cmd/tenebra-core/protection_other.go | 2 +- core/control/daemon.go | 2 +- core/control/protection.go | 14 +++++- core/control/protection_legacy_test.go | 69 ++++++++++++++++++++++++++ core/protection/guard.go | 24 ++++++--- 5 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 core/control/protection_legacy_test.go diff --git a/cmd/tenebra-core/protection_other.go b/cmd/tenebra-core/protection_other.go index d2eef8a8..a97dfac2 100644 --- a/cmd/tenebra-core/protection_other.go +++ b/cmd/tenebra-core/protection_other.go @@ -7,7 +7,7 @@ import ( "github.com/Divaaaan/tenebra/core/control" ) -func configureHostProtection(*control.Daemon) {} +func configureHostProtection(d *control.Daemon) { d.UseLegacyEngineProtection() } func releaseNativeHostProtection() error { return errors.New("persistent host protection cleanup is Windows-only") } diff --git a/core/control/daemon.go b/core/control/daemon.go index 6a8fea4f..efa2cc5a 100644 --- a/core/control/daemon.go +++ b/core/control/daemon.go @@ -1671,7 +1671,7 @@ func (d *Daemon) handleSetKillSwitch(req Request) Response { // Retry even if the desired value is already OFF: an earlier removal may // have failed after settings were saved. Only explicit commands release. protectionErr = d.protection.Release() - } else { + } else if !d.protection.LegacyEngineOnly() { cur := d.snapshotState() if (changed || before.Status != "active") && (cur.State == StateConnected || cur.State == StateConnecting || cur.Protection.Enforced || before.Status == "error") { if err := protection.ValidateDNS(d.snapshotRouting().Normalize().DNSDirect); err != nil { diff --git a/core/control/protection.go b/core/control/protection.go index f919f68e..f9a00153 100644 --- a/core/control/protection.go +++ b/core/control/protection.go @@ -28,6 +28,12 @@ func (d *Daemon) SetProtection(g *protection.Guard) { g.SetNotify(d.emitProtection) } +// UseLegacyEngineProtection is selected only by the non-Windows production +// composition root. Default/missing-backend constructors remain fail closed. +func (d *Daemon) UseLegacyEngineProtection() { + d.SetProtection(protection.NewLegacyEngineOnly()) +} + func (d *Daemon) emitProtection() { s := d.snapshotState() d.mu.Lock() @@ -50,6 +56,9 @@ func (d *Daemon) RecoverProtectionAtStartup() error { func (d *Daemon) prepareProtection(ro routing.Options) error { d.protectionOp.Lock() defer d.protectionOp.Unlock() + if d.protection.LegacyEngineOnly() { + return nil + } d.mu.Lock() wanted := d.routing.KillSwitch d.mu.Unlock() @@ -72,6 +81,9 @@ func (d *Daemon) prepareProtection(ro routing.Options) error { // section. Keep protectionOp held through every local gate and the connected // publication, so a setting command cannot replace the verified policy. func (d *Daemon) activateProtectionLocked(ro routing.Options, tun singbox.TunOptions) error { + if d.protection.LegacyEngineOnly() { + return nil + } d.mu.Lock() wanted := d.routing.KillSwitch d.mu.Unlock() @@ -99,5 +111,5 @@ func (d *Daemon) ProtectionDNS() (string, bool) { ro := d.routing d.mu.Unlock() s := d.protection.Snapshot() - return ro.Normalize().DNSDirect, ro.KillSwitch || s.Enforced || s.Status == "error" + return ro.Normalize().DNSDirect, !d.protection.LegacyEngineOnly() && (ro.KillSwitch || s.Enforced || s.Status == "error") } diff --git a/core/control/protection_legacy_test.go b/core/control/protection_legacy_test.go new file mode 100644 index 00000000..1645c939 --- /dev/null +++ b/core/control/protection_legacy_test.go @@ -0,0 +1,69 @@ +package control + +import ( + "context" + "testing" + "time" +) + +func TestHostProtectionLegacyEngineCompatibility(t *testing.T) { + d, r, p := coreAuditDaemon(t) + d.UseLegacyEngineProtection() + d.routing.KillSwitch = true // existing saved preference, never migrated OFF + d.routing.DNSDirect = "udp://192.0.2.53" + state := coreAuditConnect(t, d, p, "") + checkWarning := func() { + t.Helper() + state = d.snapshotState() + if state.Protection.Status != "unavailable" || state.Protection.Enforced || state.Protection.Persistent || state.Protection.Error == "" { + t.Fatalf("legacy routing claimed independent protection: %+v", state.Protection) + } + if stateEventBody(state).Protection != state.Protection { + t.Fatal("state event lost unavailable explanation") + } + } + checkWarning() + if !state.KillSwitch { + t.Fatal("saved preference was silently migrated OFF") + } + if endpoint, strict := d.ProtectionDNS(); strict || endpoint != "udp://192.0.2.53" { + t.Fatalf("legacy DNS choice changed: endpoint=%q strict=%t", endpoint, strict) + } + if strict, _ := tunFromConfig(t, r.startCfgs()[0]); !strict { + t.Fatal("legacy strict_route disappeared") + } + for i, on := range []bool{false, true} { + if resp := d.handleSetKillSwitch(Request{ID: int64(i + 1), On: on}); !resp.Ok { + t.Fatal(resp) + } + deadline := time.Now().Add(time.Second) + for d.snapshotState().State != StateConnected && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + checkWarning() + if state.State != StateConnected || state.KillSwitch != on { + t.Fatal(state) + } + cfgs := r.startCfgs() + if strict, _ := tunFromConfig(t, cfgs[len(cfgs)-1]); strict != on { + t.Fatalf("legacy strict_route=%t, want %t", strict, on) + } + } + if resp := d.handleSetDNS(Request{ID: 3, DNSDirect: "udp://192.0.2.54"}); !resp.Ok { + t.Fatal("legacy plaintext setting unexpectedly rejected", resp) + } + if endpoint, strict := d.ProtectionDNS(); strict || endpoint != "udp://192.0.2.54" { + t.Fatal(endpoint, strict) + } +} + +func TestHostProtectionMissingBackendDoesNotInferLegacy(t *testing.T) { + d, r, p := coreAuditDaemon(t) + d.routing.KillSwitch = true + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, "", false, false, "") + d.connMu.Unlock() + if err == nil || r.starts() != 0 || d.snapshotState().Protection.Status != "unavailable" { + t.Fatal("ordinary missing backend silently downgraded", err, r.starts()) + } +} diff --git a/core/protection/guard.go b/core/protection/guard.go index a5369aba..707d01d6 100644 --- a/core/protection/guard.go +++ b/core/protection/guard.go @@ -24,12 +24,13 @@ type Backend interface { } type Guard struct { - op sync.Mutex - mu sync.Mutex - backend Backend - state State - notify func() - tunnel *verifiedTunnel + op sync.Mutex + mu sync.Mutex + backend Backend + state State + notify func() + tunnel *verifiedTunnel + legacyEngineOnly bool // immutable, explicitly selected by non-Windows composition } type verifiedTunnel struct { @@ -45,6 +46,17 @@ func New(b Backend) *Guard { return g } +// NewLegacyEngineOnly preserves preexisting non-Windows engine routing without +// claiming independent host protection. A missing Backend never selects this. +func NewLegacyEngineOnly() *Guard { + g := New(nil) + g.legacyEngineOnly = true + g.state.Error = "Persistent host protection is unavailable on this platform; legacy engine routing only, while the engine is running." + return g +} + +func (g *Guard) LegacyEngineOnly() bool { return g.legacyEngineOnly } + // SetNotify is configured once before commands begin. The callback runs without // Guard.mu and may safely read Snapshot; it must not perform another operation. func (g *Guard) SetNotify(f func()) { g.notify = f } From 0eed1caf70bde3c51a65bc543c1163aea968335e Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:52:44 +0300 Subject: [PATCH 28/56] docs: define Windows host protection acceptance gates --- README.md | 29 ++--- docs/host-protection-acceptance.md | 175 +++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 13 deletions(-) create mode 100644 docs/host-protection-acceptance.md diff --git a/README.md b/README.md index 5474d443..79ba3eb6 100644 --- a/README.md +++ b/README.md @@ -8,19 +8,19 @@ [![Platform](https://img.shields.io/badge/platform-Windows_%7C_macOS_%7C_Linux-0e0e0e.svg)](#project-status) **A cross-platform VPN client built on [sing-box](https://github.com/SagerNet/sing-box).**
-Desktop first — Windows is user-ready; macOS and Linux ship but are for advanced users (see below). The same Go core drives an Android client, in alpha and installed by hand; iOS is a scaffold. +Desktop first — Windows has an installer-managed service; the current audit candidate still requires native acceptance. macOS and Linux ship for advanced users (see below). The same Go core drives an Android client, in alpha and installed by hand; iOS is a scaffold. A total eclipse: intercepted noise enters the dark, one clean signal leaves it. In tenebris lux. > **Project status — early development.** The desktop client is the current -> focus. The core, the control protocol and the UI are in good shape and well -> tested, and the Windows tunnel path (wintun + sing-box under the service) is -> exercised against real servers rather than only in tests — but no automated -> test stands up a real tunnel on any platform, and the macOS and Linux tunnels -> have had no privileged live run signed off. Treat this as pre-release: not -> yet "production-ready", +> focus. Earlier Windows releases have been exercised against real servers; +> that evidence does not establish native acceptance of the current audit +> candidate, including its persistent host guard. The required packet, BFE and +> reboot gates are [documented here](docs/host-protection-acceptance.md). +> macOS and Linux have had no privileged live-tunnel run signed off. +> Treat this as pre-release: not yet "production-ready", > and expect things to move around. See > [Project status](#project-status) for the honest breakdown. @@ -82,9 +82,12 @@ Everything below is implemented in this repo today (the UI features are desktop) minimized to the tray), single-instance, live traffic graphs, light/dark themes, and English / Russian UI. -The kill-switch (drop proxied traffic instead of leaking when the tunnel drops) is a -UI toggle — best-effort by design, with the exact guarantee described in the -[changelog](CHANGELOG.md); LAN bypass is a core routing option. +The v0.5.11 kill switch was best-effort; its behavior is recorded in the +[changelog](CHANGELOG.md). The current Windows audit candidate adds persistent +host protection, with desired settings separate from confirmed policy state. +Its engine/service-death and reboot guarantees still require +[native acceptance](docs/host-protection-acceptance.md); they are not established +by unit tests or prior-release tunnel runs. LAN bypass remains a routing option. ## DPI bypass @@ -175,7 +178,7 @@ get one: | Go core (parsing, profiles, routing, config gen, fallback, leak logic) | Implemented, unit-tested, no third-party deps | | Control protocol (core ↔ UI) | Implemented; covered by Go tests **and** a real-binary e2e | | Desktop UI (Tauri 2 + React) | Implemented: all screens, reactive tray, notifications, deep links, autostart, i18n, themes | -| Windows tunnel (wintun + sing-box) | Implemented — a background **service** runs the tunnel, so the app connects without an elevated GUI; installer sets it up, the in-app updater refreshes both app and service | +| Windows tunnel (wintun + sing-box) | A background **service** owns the tunnel; installer/update code coordinates app and service. The current audit candidate still needs standard-user installer, live-tunnel and [host-protection acceptance](docs/host-protection-acceptance.md). INC-01 remains open; cause unknown. | | macOS tunnel (utun + sing-box) | Builds and runs — universal `.app`/DMG — but see the **macOS note** below: it needs a hand-installed root daemon and is not yet a click-to-run product. No live-tunnel sign-off yet | | Linux tunnel (`/dev/net/tun` + sing-box) | Builds and runs — a root **systemd service** owns the tunnel, installed by an Arch package or a `sudo` script; see the **Linux note** below. No live-tunnel sign-off yet | | Android (`VpnService` + libbox) | **Alpha, hand-installed** — a Kotlin / Compose client in [`ui-android/`](ui-android/README.md) builds and runs on a device: subscription import, node list with latency badges, an AUTO exit, switching the live exit without a reconnect, connect-on-boot, a Quick Settings tile, in-app logs and crash reports. Routing is *Global* only and there is no DPI bypass. CI builds a debug APK; a tagged release carries a signed one only once the signing key is in CI secrets | @@ -205,8 +208,8 @@ The click-to-run macOS path — a signed, notarized build with an `SMAppService` daemon bundled inside the app (so it installs and updates like the Windows service) — needs an Apple Developer ID and is **planned, not done**. Until then, use the DMG only if you're comfortable running the install script yourself. -**Windows users are unaffected** — the Windows installer sets up the service and -the updater keeps everything current automatically. +Windows uses an installer-managed service; the current audit candidate's +installation and update path still requires [delivery acceptance](docs/delivery-acceptance.md). ### Linux note — the tunnel needs a root service diff --git a/docs/host-protection-acceptance.md b/docs/host-protection-acceptance.md new file mode 100644 index 00000000..ec301c3e --- /dev/null +++ b/docs/host-protection-acceptance.md @@ -0,0 +1,175 @@ +# Windows host protection: candidate contract and acceptance + +Status at the 2026-09-11 audit candidate: **implemented in source; native packet, +installer and reboot acceptance remains pending**. This document describes the +T05 candidate integrated at `7aff666`. It does not change the evidence for the +previous v0.5.11 release or imply that a published installer contains this guard. +Passing unit tests, a successful build or a green hosted CI run is not proof of +traffic blocking. Release approval requires the native gates below against the +exact candidate binaries. + +**INC-01 remains open: cause unknown.** The reported Windows connection failure +has not been causally reproduced or explained by these fixes. Neither the guard +implementation nor a passing unrelated subscription proves that incident fixed. + +## Implemented contract + +The Windows amd64/arm64 backend installs persistent WFP objects in four ALE +authorization layers: connect and receive/accept, each for IPv4 and IPv6. Once +applied, its intended contract is to block ordinary host application traffic +outside verified allowed paths even if the engine or Tenebra service dies. +Ordinary service stop, daemon Close, update and exhausted engine retries retain +the policy. The engine supervisor assigns its suspended child to a job before +resuming it, so service death is intended to terminate the engine as well. + +Allowed paths are genuine loopback, the exact verified TUN interface, trusted +core/engine executable identities plus execution SID, service-scoped DHCP and +minimal IPv6 NDP. Physical plaintext DNS is blocked above the application +exceptions. Core bootstrap uses certificate-verified DoH/DoT to an explicit +literal-IP endpoint; invalid settings or resolver failure have no silent +plaintext/OS-DNS fallback. Saved resolver settings are not silently replaced. +Engine transport, encrypted bootstrap and configured DIRECT routes carried by +the engine are deliberate exceptions; application/domain/LAN split choices do +not become general host firewall permits. + +Before an engine replacement, the daemon applies lockdown without a TUN permit. +It adds the verified TUN only after the engine probe and publishes Active only +after all local gates, including system proxy, succeed. A replacement adapter +with the same name cannot inherit the old LUID permission. System-proxy mode +uses loopback plus engine egress and grants no TUN exception. + +`kill_switch` is the desired setting. `protection` is separate evidence: + +| Status | Meaning | +| --- | --- | +| `off` | No confirmed owned policy; the first idle ON arms the next connection and does not itself assert enforcement. | +| `applying` | An operation is pending; previous confirmed enforcement flags remain. | +| `blocked` | Confirmed policy without an accepted engine connection. | +| `active` | Confirmed policy plus an accepted engine connection. | +| `error` | Apply, inspection or cleanup failed; previous confirmed flags remain, and the error must be visible. | +| `unavailable` | No supported protection backend. | + +`enforced` and `persistent` describe last-confirmed policy, not an independent +packet measurement. Service loss cannot justify displaying a live Active +connection. Startup inspects existing policy before autoconnect and repairs it +to lockdown even when preferences say OFF, because cleanup may have been +interrupted. IPC remains available for explicit recovery if that operation fails. + +## Ownership and maintenance + +The provider is `fcb43b44-9358-4cd7-a998-9e7f822d5248`; the sublayer is +`fcb43b45-9358-4cd7-a998-9e7f822d5248`. Both use the marker +`tenebra/persistent-host-guard/v1`. Replacement and removal validate ownership +and relationships and commit one transaction. Disabled owned objects remain +removable; they are not enforcement evidence. Foreign metadata or unresolved +references cause an error, not a broad firewall reset. + +- **OFF / explicit Disconnect:** request owned cleanup and require confirmation. + Saving OFF, closing the window, or stopping the service is insufficient. + Failed cleanup stays visible and can be retried even if the saved preference + already says OFF. A separate proxy-restore failure must also remain visible. +- **Update / same-version repair:** preserve the guard while the checked service + stop and coordinated GUI/core replacement run. The replacement core recovers + policy before autoconnect. Do not remove the guard as an update workaround. +- **Explicit uninstall:** after confirmed service stop, the installer probes both + fixed WFP GUIDs. Confirmed absence allows legacy/pre-T05 uninstall without an + unsupported CLI call. If either exists, the installed, trusted T05-capable + `tenebra-core.exe --release-host-protection` must exit successfully and a + second probe must confirm both absent before service/files are deleted. + Missing/unsupported core, foreign ownership, uncertainty, timeout or cleanup + failure aborts uninstall and preserves the recovery binary. Probe errors are + not absence. Ordinary update mode never invokes this remover. +- **Rollback to pre-T05:** explicitly release and confirm owned cleanup with a + T05-capable core before replacing it with an older binary. Keep a compatible + remover in a protected installation until that succeeds. An old core cannot + be expected to repair or remove the new policy. Never recover by deleting all + firewall rules, deleting unknown WFP objects, or running an arbitrary + user-writable executable elevated. + +The remover does not initialize a daemon, require an engine, or read profiles. +Its zero exit means owned cleanup succeeded or no owned policy existed. The +installer bounds the cleanup child to 20 seconds and its wrapper to 35 seconds; +the read-only WFP probes and general WFP RPCs remain synchronous without an +overall caller-enforced deadline. See [delivery acceptance](delivery-acceptance.md) +for the separate service/installer/proxy gates. + +## Boundaries that must stay explicit + +This contract covers ordinary host IPv4/IPv6 application flows. It does not +claim protection before BFE initializes, while BFE is deliberately unavailable, +for forwarded Hyper-V/WSL/container traffic, against administrator/kernel +adversaries, or against competing hard-permit/callout behavior. + +The implementation deliberately leaves the provider ServiceName unset, following +the SDK and [WFP object management](https://learn.microsoft.com/en-us/windows/win32/fwp/object-management). +The [provider reference](https://learn.microsoft.com/en-us/windows/win32/api/fwpmtypes/ns-fwpmtypes-fwpm_provider0) +has conflicting disabled-state wording. The implementation choice is settled; +post-BFE/reboot behavior still requires measured acceptance. Persistent filters +are not a separate boot-time packet policy. + +Established-flow safety depends on ALE reauthorization and actual interface +conditions. If the packet gates show an escape after commit, reject this +candidate; a separately reviewed packet/callout design is required before that +promise can be made. Do not turn the observed escape into an undocumented grace +period. [Microsoft ALE reauthorization](https://learn.microsoft.com/en-us/windows/win32/fwp/ale-re-authorization). + +## Isolated VM procedure + +These are acceptance instructions, not an executed result or authorization to +run on a workstation. **Do not inspect, change or interact with host Hiddify.** +All service, process, route, registry, firewall and adapter mutations belong only +in the disposable guest. A packet observer must see the guest's isolated uplink +independently of the application; an in-app IP check alone is insufficient. + +Use one Windows 11 VM, 4 GiB fixed RAM, two vCPUs, host CPU Maximum=50%, no +parallel builds and at most 15 minutes per acceptance phase. Provisioning/OOBE +is a separate phase. Use prebuilt artifacts; record their commit, versions and +SHA-256, guest Windows build, VM ID and guest BIOS UUID. The two IDs are distinct. +Before any native harness action require the expected hypervisor identity, +an affirmative invocation flag, and the provisioned guest marker +`C:\ProgramData\TenebraAcceptance\isolated-vm.json` with matching `schema=1`, +`vmId`, `guestUuid`, `vmName`, `runNonce`, `disposable=true`, and +`allowNativeAcceptance=true`. Guest checks must reject a workstation or ambiguous +identity. The host separately attests the allocation and CPU cap. + +Take a clean checkpoint. Inventory owned WFP objects and unrelated firewall +policy; retain the trusted remover and console access. Establish working IPv4 +and IPv6 external test endpoints before the run; lack of an IPv6 route means +the IPv6 gate is untested, not passed. Capture both families and UDP/TCP port 53 +at the independent uplink. Tag test payloads and timestamp policy commit, +process/service exit, BFE readiness and GUI state. Correlate payload sequence +numbers generated after commit; label any pre-commit in-flight packets separately +instead of inventing a post-commit grace period. Bound probes to 10 packets/s, +64 KiB responses and hard deadlines. Use fresh plus preexisting outbound TCP, +UDP and QUIC flows and independently initiated inbound-accepted flows. + +## Required packet and lifecycle gates + +Every row is **pending native acceptance**. Record the exact triggering event, +packet capture interval, state/ownership evidence, expected outcome and result. + +| Gate | Trigger and required observation | +| --- | --- | +| Initial lockdown | Start direct v4/v6 flows before ON, then connect to apply lockdown. From confirmed commit onward, their next payloads and new ordinary physical flows must not reach the uplink. Idle first ON alone is not a commit. | +| Accepted TUN | Verify the configured name/address/LUID, successful engine probe and local gates. Ordinary traffic must reach the remote endpoint through the tunnel; no ordinary direct physical payload. A failed local gate must never publish Active/Connected. | +| Inbound and reauthorization | Keep inbound-accepted and outbound flows alive across initial commit, reconnect and default-route/next-hop replacement. Replies and new packets must not escape to the physical path after the relevant policy change. | +| System proxy and DIRECT choices | Test mixed/system-proxy mode and app/domain/LAN DIRECT choices. Loopback clients may use the engine; an ordinary process attempting the same physical destination directly remains blocked. Record intended engine DIRECT traffic separately. | +| DNS and bootstrap | Attempt ordinary and trusted-core UDP/TCP port-53 DNS on the physical path; none may escape. Verify encrypted literal-IP bootstrap and successful hostname-server connection. Invalid/hostname/plaintext endpoints, bad certificates, redirects, outage and timeout must fail visibly without plaintext retry. | +| Engine / service death | Kill the engine, hard-kill the service, and separately request graceful service Stop. Record engine/job termination. Keep probes running: policy remains, no unintended physical payload, and UI loses live Active status. | +| Retry exhaustion / replacement | Trigger five immediate engine crashes and a normal reconnect. Lockdown persists after retry exhaustion and throughout replacement; only a newly verified accepted engine permits recovery. | +| TUN loss / route change | Remove the verified guest TUN, replace it with a same-name/different-LUID adapter, and introduce a new physical uplink/default route. No substitute gains the TUN permit; no direct escape occurs even before the watcher reacts. Failed engine Stop remains visible. | +| DHCP / NDP / resume | Renew v4/v6 leases, exercise IPv6 neighbor/router discovery, sleep/resume and change the guest uplink. Required configuration traffic works; arbitrary LAN payload and non-permitted ICMP/data stay blocked. Reconnect revalidates the TUN. | +| Update / repair gap | Run candidate upgrade and same-version repair with policy present. Capture the full checked-stop/replacement/start gap; policy persists and old executable exceptions are replaced on successful recovery. A failed update must not silently release protection. | +| BFE restart without Tenebra | With persistent policy installed and Tenebra kept stopped in the guest, restart BFE. Record the BFE-down interval separately as outside scope. After BFE is ready and before Tenebra restarts, confirm non-disabled persistent objects and zero unintended physical payload. Only then test core recovery. | +| Reboot without Tenebra | Keep Tenebra from autostarting for this guest-only case, retain policy and reboot. Capture before boot through BFE readiness and subsequent probes. Separate pre-BFE traffic from the gate: once BFE loads policy, blocking must work before any Tenebra process starts. Then start Tenebra and verify lockdown/reconnect recovery. | +| Failed apply / commit | Inject a controlled apply or commit failure. Compare owned inventory and packets: previous committed policy remains, no partial permit set or transient direct payload, no false Active. Include disabled owned provider/filter recovery and refusal of foreign ownership. | +| Identity rejection | Use guest fixtures with a user-writable executable path, reparse traversal, wrong execution identity or colliding TUN name. Protection must reject uncertain identity without widening old policy. Restore the checkpoint after malicious fixtures. | +| OFF / Disconnect / retry | Explicitly release and verify both owned GUIDs and filters absent, intended direct connectivity restored, and unrelated firewall inventory unchanged. Inject cleanup failure: preserve owned policy/error and retry successfully even with preference already OFF. Test proxy-restore errors separately. | +| Uninstall / legacy / rollback | Cover successful T05 cleanup, retained binary on failed cleanup, second-probe failure, legacy absence, and legacy/missing remover with objects present. Upgrade never clears policy. A pre-T05 rollback occurs only after confirmed cleanup with the compatible remover. | + +Pass requires zero unintended physical payload and plaintext DNS in the covered +blocked intervals for both IP families, measured tunnel recovery when Active, +and unchanged unrelated firewall inventory after release. Missing captures, +untested address families, UI-only evidence, or inability to exercise BFE/reboot +leave the corresponding gate open. Revert the disposable checkpoint on failure; +never perform workstation cleanup as a substitute. From ea741101aa59534b9d43f6c1c5537aaf27635899 Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:03:32 +0300 Subject: [PATCH 29/56] fix: gate platform-specific Rust helpers for strict CI --- ui-desktop/src-tauri/src/backend/mod.rs | 1 + ui-desktop/src-tauri/src/backend/pipe.rs | 2 ++ ui-desktop/src-tauri/src/backend/service_policy.rs | 1 + ui-desktop/src-tauri/src/lib.rs | 4 ++-- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/ui-desktop/src-tauri/src/backend/mod.rs b/ui-desktop/src-tauri/src/backend/mod.rs index 1db72e78..3a4e53a3 100644 --- a/ui-desktop/src-tauri/src/backend/mod.rs +++ b/ui-desktop/src-tauri/src/backend/mod.rs @@ -23,6 +23,7 @@ pub mod mock; pub mod pipe; #[cfg(windows)] pub(crate) mod pipe_io; +#[cfg(any(windows, test))] pub(crate) mod service_policy; pub mod sidecar; #[cfg(test)] diff --git a/ui-desktop/src-tauri/src/backend/pipe.rs b/ui-desktop/src-tauri/src/backend/pipe.rs index 239a2f8f..3b375c4b 100644 --- a/ui-desktop/src-tauri/src/backend/pipe.rs +++ b/ui-desktop/src-tauri/src/backend/pipe.rs @@ -42,6 +42,7 @@ 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, }; +#[cfg(test)] use windows_sys::Win32::System::Pipes::{WaitNamedPipeW, NMPWAIT_NOWAIT}; use super::wire::{obj, read_loop, WireClient, WireSession}; @@ -124,6 +125,7 @@ fn name_from(value: Option<&str>) -> Option { /// instance is momentarily taken — it is between accepting a client and creating /// the next instance — reads as absent. Callers should treat `false` as "not /// this instant" and look again, never as "there is no service on this machine". +#[cfg(test)] pub fn is_listening(name: &str) -> bool { let wide: Vec = name.encode_utf16().chain(std::iter::once(0)).collect(); // SAFETY: `wide` is a valid NUL-terminated wide string that outlives the diff --git a/ui-desktop/src-tauri/src/backend/service_policy.rs b/ui-desktop/src-tauri/src/backend/service_policy.rs index 68c7b7cf..a0f52042 100644 --- a/ui-desktop/src-tauri/src/backend/service_policy.rs +++ b/ui-desktop/src-tauri/src/backend/service_policy.rs @@ -11,6 +11,7 @@ pub fn verify_version(actual: Option<&str>, expected: &str) -> Result<(), String } // Installer registrations contain one quoted absolute executable and no args. +#[cfg(windows)] 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() { diff --git a/ui-desktop/src-tauri/src/lib.rs b/ui-desktop/src-tauri/src/lib.rs index 1e053034..6072565f 100644 --- a/ui-desktop/src-tauri/src/lib.rs +++ b/ui-desktop/src-tauri/src/lib.rs @@ -13,7 +13,7 @@ mod tray; mod update_channel; use std::sync::{Arc, Mutex}; -#[cfg(any(windows, target_os = "linux"))] +#[cfg(any(target_os = "linux", all(windows, test)))] use std::time::{Duration, Instant}; use serde_json::json; @@ -302,7 +302,7 @@ fn watch_for_a_late_daemon(path: String, sink: Arc) { /// reporting whether it ever did. Split out from the watch thread so its /// schedule — look first, then wait, and always look at least once — can be /// tested without a real pipe, a real socket, or real seconds. -#[cfg(any(windows, target_os = "linux"))] +#[cfg(any(target_os = "linux", all(windows, test)))] fn await_probe(mut probe: impl FnMut() -> bool, tick: Duration, window: Duration) -> bool { let deadline = Instant::now() + window; loop { From 688eec3e5696a553b090ff0eb0d5f6e26e7bdadb Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:08:01 +0300 Subject: [PATCH 30/56] fix(protection): read native union pointers without integer conversion --- core/protection/wfp_abi_windows.go | 8 ++++++++ core/protection/wfp_abi_windows_test.go | 6 +++--- core/protection/wfp_windows.go | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/core/protection/wfp_abi_windows.go b/core/protection/wfp_abi_windows.go index 433c12c4..5fa115e4 100644 --- a/core/protection/wfp_abi_windows.go +++ b/core/protection/wfp_abi_windows.go @@ -26,6 +26,14 @@ type wfpValue struct { Type uint32 Value uintptr } + +// pointer reads the pointer member of the SDK's value union without rebuilding +// a pointer from an integer. Call only for pointer-valued FWP data types; inline +// UINT8/16/32 remain numbers so Go's GC never scans them as pointers. +func (v *wfpValue) pointer() unsafe.Pointer { + return *(*unsafe.Pointer)(unsafe.Pointer(&v.Value)) +} + type wfpSession struct { Key windows.GUID Display displayData diff --git a/core/protection/wfp_abi_windows_test.go b/core/protection/wfp_abi_windows_test.go index 3b1db1cd..a3dff20f 100644 --- a/core/protection/wfp_abi_windows_test.go +++ b/core/protection/wfp_abi_windows_test.go @@ -42,14 +42,14 @@ func TestWFPMarshalsOnlyScopedPersistentSoftPermits(t *testing.T) { if f.Provider == nil || *f.Provider != providerKey || f.Sublayer != sublayerKey || f.Flags != 1 || f.Context != [2]uint64{} { t.Fatal("wrong filter lifetime/ownership") } - got = append(got, filterInfo{f.Key, f.Layer, f.Flags, f.Action.Type, f.Count, *(*uint64)(unsafe.Pointer(f.Weight.Value))}) + got = append(got, filterInfo{f.Key, f.Layer, f.Flags, f.Action.Type, f.Count, *(*uint64)(f.Weight.pointer())}) if f.Count == 0 { return nil } hasNextHop, hasLocal := false, false for _, c := range unsafe.Slice(f.Conditions, f.Count) { if c.Field == fieldUser { - blob := (*byteBlob)(unsafe.Pointer(c.Value.Value)) + blob := (*byteBlob)(c.Value.pointer()) if c.Value.Type != 14 || blob.Size != 3 || blob.Data == nil || *blob.Data != 3 { t.Fatal("security descriptor is not an FWP_BYTE_BLOB") } @@ -57,7 +57,7 @@ func TestWFPMarshalsOnlyScopedPersistentSoftPermits(t *testing.T) { if c.Field == fieldNextHop || c.Field == fieldLocalInterface { hasNextHop = hasNextHop || c.Field == fieldNextHop hasLocal = hasLocal || c.Field == fieldLocalInterface - if c.Value.Type != 4 || *(*uint64)(unsafe.Pointer(c.Value.Value)) != 42 { + if c.Value.Type != 4 || *(*uint64)(c.Value.pointer()) != 42 { t.Fatal("wrong TUN identity condition") } } diff --git a/core/protection/wfp_windows.go b/core/protection/wfp_windows.go index 0892aa0a..7e372f78 100644 --- a/core/protection/wfp_windows.go +++ b/core/protection/wfp_windows.go @@ -150,7 +150,7 @@ func (b *windowsBackend) filtersAtLayer(h uintptr, layer windows.GUID) ([]filter } var weight uint64 if f.Weight.Type == 4 && f.Weight.Value != 0 { - weight = *(*uint64)(unsafe.Pointer(f.Weight.Value)) + weight = *(*uint64)(f.Weight.pointer()) } out = append(out, filterInfo{f.Key, f.Layer, f.Flags, f.Action.Type, f.Count, weight}) } From c09d19d4885e8cea0d7c6b7b76fe2796ecf4fe19 Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:08:09 +0300 Subject: [PATCH 31/56] fix: limit wire test receiver import to test builds --- ui-desktop/src-tauri/src/backend/wire.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui-desktop/src-tauri/src/backend/wire.rs b/ui-desktop/src-tauri/src/backend/wire.rs index d0f0d693..7b13e5cf 100644 --- a/ui-desktop/src-tauri/src/backend/wire.rs +++ b/ui-desktop/src-tauri/src/backend/wire.rs @@ -16,7 +16,9 @@ use std::collections::HashMap; use std::io::{BufRead, BufReader, Read, Write}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::mpsc::{self, Receiver, Sender}; +#[cfg(test)] +use std::sync::mpsc::Receiver; +use std::sync::mpsc::{self, Sender}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; From c7f576d503e5b6cc249708040f8fce7883995d46 Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:09:39 +0300 Subject: [PATCH 32/56] fix: specify cancellation test channel payload type --- ui-desktop/src-tauri/src/backend/pipe_io.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-desktop/src-tauri/src/backend/pipe_io.rs b/ui-desktop/src-tauri/src/backend/pipe_io.rs index 13f40358..9baad162 100644 --- a/ui-desktop/src-tauri/src/backend/pipe_io.rs +++ b/ui-desktop/src-tauri/src/backend/pipe_io.rs @@ -335,7 +335,7 @@ mod tests { ); let server_name = name.clone(); let (ready_tx, ready_rx) = mpsc::channel(); - let (release_tx, release_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( From f4c7e1c81f412ca84834680052b8a1f2a7b246da Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:18:44 +0300 Subject: [PATCH 33/56] fix(control): publish connecting before launching fallback --- core/control/connect.go | 9 ++- .../control/connect_publication_audit_test.go | 81 +++++++++++++++++++ 2 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 core/control/connect_publication_audit_test.go diff --git a/core/control/connect.go b/core/control/connect.go index 32ffb766..3a85ba56 100644 --- a/core/control/connect.go +++ b/core/control/connect.go @@ -349,16 +349,17 @@ func (d *Daemon) startConnect(ctx context.Context, p profile.Profile, explicitNo remember: remember, requestedNode: requestedNode, } + // Publish the initial phase before the worker can finish, so a fast result + // cannot be followed by a stale Connecting event. The caller holds connMu + // through this publication and the launch, excluding a concurrent teardown. + st := State{State: StateConnecting, Profile: p.ID, Routing: string(ro.Mode)} + d.setState(st) d.wg.Add(1) go func() { defer d.wg.Done() d.runFallback(runCtx, loop) }() - // connect reports connecting immediately; connected/error arrive later as - // state events from the loop. - st := State{State: StateConnecting, Profile: p.ID, Routing: string(ro.Mode)} - d.setState(st) return d.snapshotState(), nil } diff --git a/core/control/connect_publication_audit_test.go b/core/control/connect_publication_audit_test.go new file mode 100644 index 00000000..0bdb8961 --- /dev/null +++ b/core/control/connect_publication_audit_test.go @@ -0,0 +1,81 @@ +package control + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestConnectInitialPublicationPrecedesFallbackCompletion(t *testing.T) { + d, _, p := coreAuditDaemon(t) + initial, release, connected := make(chan struct{}), make(chan struct{}), make(chan struct{}) + var releaseOnce, connectedOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + defer unblock() + var connecting atomic.Int32 + var mu sync.Mutex + var published []ConnState + d.SetEmitter(func(name string, body any) { + state, ok := body.(stateEvent) + if name != EventState || !ok { + return + } + // The first Connecting comes from teardown. Park the connect's own + // initial publication before delivery, as a preemption before enqueue + // can do; no production state or scheduler hooks are changed. + if state.State == StateConnecting && connecting.Add(1) == 2 { + close(initial) + <-release + } + mu.Lock() + published = append(published, state.State) + mu.Unlock() + if state.State == StateConnected { + connectedOnce.Do(func() { close(connected) }) + } + }) + done := make(chan error, 1) + go func() { + d.connMu.Lock() + _, err := d.startConnect(context.Background(), p, "", false, false, "") + d.connMu.Unlock() + done <- err + }() + select { + case <-initial: + case <-time.After(time.Second): + t.Fatal("initial publication barrier not reached") + } + select { + case <-connected: + case <-time.After(50 * time.Millisecond): + // Correct ordering may not start fallback until this publication returns. + } + unblock() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("connect did not return") + } + select { + case <-connected: + case <-time.After(time.Second): + t.Fatal("fallback never connected") + } + mu.Lock() + states := append([]ConnState(nil), published...) + mu.Unlock() + finished := false + for _, state := range states { + if state == StateConnected { + finished = true + } else if finished && state == StateConnecting { + t.Fatalf("initial state overtook fallback result: events=%v final=%s", states, d.snapshotState().State) + } + } +} From 3f322c934e87a441d51e3a9abeb774d0d9ede363 Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:19:52 +0300 Subject: [PATCH 34/56] test(control): wait for real replacement connections in lifecycle fixtures --- core/control/health_test.go | 15 +++++++++++++-- core/control/killswitch_tun_test.go | 30 ++++++++++++++++++++++------- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/core/control/health_test.go b/core/control/health_test.go index 03ec3677..ceb051cf 100644 --- a/core/control/health_test.go +++ b/core/control/health_test.go @@ -202,12 +202,19 @@ func TestHealthWatchStopsOnDisconnect(t *testing.T) { func TestHealthWatchYieldsToUserCommand(t *testing.T) { h := newHarness(t) p := seedMultiProto(t, h) + // This fixture exercises reconnect arbitration. Live exit probes must fail; + // otherwise the watchdog successfully switches exits and honours the shared + // production cooldown instead of reaching the reconnect barrier promptly. + h.runner.failAllVia() probe := &scriptedProbe{verdict: func(int) error { return errors.New("probe: node down") }} h.tuneHealth(5*time.Millisecond, 100*time.Millisecond, 3, probe.fn) parked := make(chan struct{}) release := make(chan struct{}) + var releaseOnce sync.Once + unpark := func() { releaseOnce.Do(func() { close(release) }) } + defer unpark() var once sync.Once h.daemon.beforeReconnect = func() { once.Do(func() { close(parked) }) @@ -219,7 +226,11 @@ func TestHealthWatchYieldsToUserCommand(t *testing.T) { h.awaitState(StateConnected) // The watchdog trips and the failover reconnect parks before claiming connMu. - <-parked + select { + case <-parked: + case <-time.After(3 * time.Second): + t.Fatal("watchdog did not reach the reconnect barrier") + } starts := h.runner.starts() // The user disconnects while the failover is parked. @@ -228,7 +239,7 @@ func TestHealthWatchYieldsToUserCommand(t *testing.T) { h.awaitState(StateIdle) // Release the failover: it must see the bumped generation and yield. - close(release) + unpark() time.Sleep(50 * time.Millisecond) // give the goroutine a chance to (wrongly) act if got := h.runner.starts(); got != starts { t.Errorf("failover started a tunnel over the user's disconnect (starts %d -> %d)", starts, got) diff --git a/core/control/killswitch_tun_test.go b/core/control/killswitch_tun_test.go index 64595634..53eaca25 100644 --- a/core/control/killswitch_tun_test.go +++ b/core/control/killswitch_tun_test.go @@ -18,6 +18,22 @@ import ( // walk), relaunching a tunnel whose process died while the switch was armed, // and persisting both preferences across a daemon restart. +// awaitRestartConnected requires the replacement's connecting transition and +// exact process count. Protection notifications may repeat Connected for the old +// process (including OFF), so they cannot acknowledge a completed restart. +func (h *harness) awaitRestartConnected(starts int) map[string]any { + h.t.Helper() + h.awaitState(StateConnecting) + ev := h.awaitState(StateConnected) + if got := h.runner.starts(); got != starts { + h.t.Fatalf("connected after restart: starts = %d, want %d", got, starts) + } + if st := h.daemon.snapshotState(); st.State != StateConnected || st.Node != ev["node"] { + h.t.Fatalf("restart event does not match current connection: event=%v state=%+v", ev, st) + } + return ev +} + // tunFromConfig extracts strict_route and the stack from the (single) tun // inbound of a built config. func tunFromConfig(t *testing.T, cfgJSON []byte) (strictRoute bool, stack string) { @@ -123,7 +139,7 @@ func TestSetKillSwitchLiveHotSwapsSameNode(t *testing.T) { } // The swap dips through connecting and lands connected on the same node. - re := h.awaitState(StateConnected) + re := h.awaitRestartConnected(2) if re["node"] != node { t.Errorf("reconnected node = %v, want the same node %s", re["node"], node) } @@ -209,7 +225,7 @@ func TestSetTunLiveHotSwapsSameNode(t *testing.T) { h.send(Request{ID: 2, Cmd: CmdSetTun, Stack: singbox.StackMixed}) h.await() - re := h.awaitState(StateConnected) + re := h.awaitRestartConnected(2) if re["node"] != connected["node"] { t.Errorf("reconnected node = %v, want %v", re["node"], connected["node"]) } @@ -258,7 +274,7 @@ func TestKillSwitchRelaunchesDeadTunnel(t *testing.T) { h.runner.exit(errors.New("boom")) h.awaitLogContains("kill switch: tunnel process died") - re := h.awaitState(StateConnected) + re := h.awaitRestartConnected(2) if re["node"] != connected["node"] { t.Errorf("relaunched node = %v, want %v", re["node"], connected["node"]) } @@ -309,7 +325,7 @@ func TestKillSwitchRelaunchBudget(t *testing.T) { for i := 0; i < maxRelaunches; i++ { h.runner.exit(errors.New("boom")) - h.awaitState(StateConnected) // each death within budget is answered + h.awaitRestartConnected(i + 2) // each death within budget is answered } // Back-to-back deaths never clear the reset window, so the budget still runs // out and the daemon gives up (see the honest wording in killSwitchRelaunch). @@ -323,9 +339,9 @@ func TestKillSwitchRelaunchBudget(t *testing.T) { // A user reconnect resets the budget: the next death relaunches again. h.send(Request{ID: 3, Cmd: CmdConnect, Profile: p.ID}) h.await() - h.awaitState(StateConnected) + h.awaitRestartConnected(maxRelaunches + 2) h.runner.exit(errors.New("boom")) - h.awaitState(StateConnected) + h.awaitRestartConnected(maxRelaunches + 3) } // TestReapplyDefersWhenNodeVanished: if the connected node is gone from the @@ -630,7 +646,7 @@ func TestKillSwitchRelaunchBudgetRefundedByUptime(t *testing.T) { for i := 0; i < maxRelaunches+3; i++ { advance(2 * defaultRelaunchReset) h.runner.exit(errors.New("boom")) - h.awaitState(StateConnected) // relaunched, not degraded to error + h.awaitRestartConnected(i + 2) // relaunched, not degraded to error } } From ab157d1cb5c538b7320e3d4894eaa2080e81a52a Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:24:02 +0300 Subject: [PATCH 35/56] test(control): align protection wire and reapply fixtures across hosts --- core/control/protection_test.go | 3 +++ core/control/protocol_test.go | 14 +++++++++----- core/control/reapply_test.go | 4 ++-- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/core/control/protection_test.go b/core/control/protection_test.go index 9d160c14..314bdd08 100644 --- a/core/control/protection_test.go +++ b/core/control/protection_test.go @@ -284,6 +284,9 @@ func TestHostProtectionLostVerifiedTunDemotesEvenWhenStopFails(t *testing.T) { d.SetProtection(protection.New(f)) d.routing.KillSwitch = true d.tunWatchInterval = time.Millisecond + // This fake Windows guard verifies a named interface on every test host; + // macOS production intentionally leaves its kernel-selected utun name empty. + d.tun.InterfaceName = "tenebra-test" d.ifacePresent = func(string) bool { return true } // an identically named replacement is present coreAuditConnect(t, d, p, "") deadline := time.Now().Add(time.Second) diff --git a/core/control/protocol_test.go b/core/control/protocol_test.go index 2528c2ad..499cea7a 100644 --- a/core/control/protocol_test.go +++ b/core/control/protocol_test.go @@ -6,6 +6,8 @@ import ( "reflect" "strings" "testing" + + "github.com/Divaaaan/tenebra/core/protection" ) func TestRequestRoundTrip(t *testing.T) { @@ -61,7 +63,7 @@ func TestDecodeRequestBadJSON(t *testing.T) { func TestResponseMarshalShape(t *testing.T) { // A success response with data echoes id and ok and carries the data object. - resp, err := newResult(7, State{State: StateConnecting, Node: "n3"}) + resp, err := newResult(7, State{State: StateConnecting, Node: "n3", Protection: protection.State{Status: "off"}}) if err != nil { t.Fatalf("newResult: %v", err) } @@ -70,11 +72,13 @@ func TestResponseMarshalShape(t *testing.T) { t.Fatalf("marshal: %v", err) } got := string(b) - // Every empty field drops out except zapret_auto_update, which rides the wire + // Protection always carries explicit enforcement and persistence, including + // false; a client must never infer protection from a requested setting. Other + // empty fields drop out except zapret_auto_update, which rides the wire // even as false: it is the one flag here whose default is on, so a client // meeting an absence has to guess, and guessing "on" turns a user's "stop // updating the bundle" back into "keep updating it" (see State). - want := `{"id":7,"ok":true,"data":{"state":"connecting","node":"n3","zapret_auto_update":false}}` + want := `{"id":7,"ok":true,"data":{"state":"connecting","node":"n3","protection":{"status":"off","enforced":false,"persistent":false},"zapret_auto_update":false}}` if got != want { t.Errorf("response =\n %s\nwant %s", got, want) } @@ -102,8 +106,8 @@ func TestMarshalEventMergesFields(t *testing.T) { { name: "state", ev: EventState, - body: stateEvent{State: StateConnected, Node: "n3"}, - want: `{"event":"state","state":"connected","node":"n3"}`, + body: stateEvent{State: StateConnected, Node: "n3", Protection: protection.State{Status: "active", Enforced: true, Persistent: true}}, + want: `{"event":"state","state":"connected","node":"n3","protection":{"status":"active","enforced":true,"persistent":true}}`, }, { name: "traffic", diff --git a/core/control/reapply_test.go b/core/control/reapply_test.go index bf28113e..7f78da18 100644 --- a/core/control/reapply_test.go +++ b/core/control/reapply_test.go @@ -66,7 +66,7 @@ func TestReapplyMovesToAFreeTunAddress(t *testing.T) { h.send(Request{ID: 2, Cmd: CmdSetKillSwitch, On: true}) h.await() - h.awaitState(StateConnected) + h.awaitRestartConnected(2) cfgs := h.runner.startCfgs() second := tunAddressOf(t, cfgs[len(cfgs)-1]) @@ -108,7 +108,7 @@ func TestSuccessfulReapplyStaysConnected(t *testing.T) { h.send(Request{ID: 2, Cmd: CmdSetKillSwitch, On: true}) h.await() - again := h.awaitState(StateConnected) + again := h.awaitRestartConnected(2) if again["node"] != connected["node"] { t.Errorf("re-apply moved the session to %v, want the same node %v", again["node"], connected["node"]) } From 76df0effb1df53ddd9de5a93e04f73fa7a304404 Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:27:04 +0300 Subject: [PATCH 36/56] test(control): verify proxy restoration and inert TUN watch with fakes --- core/control/proxy_mode_control_test.go | 30 ++++++++++++++++--------- core/control/tunwatch_test.go | 11 ++++++++- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/core/control/proxy_mode_control_test.go b/core/control/proxy_mode_control_test.go index d49a5396..e4792206 100644 --- a/core/control/proxy_mode_control_test.go +++ b/core/control/proxy_mode_control_test.go @@ -153,23 +153,33 @@ func TestSetProxyModeLiveHotSwapArmsAndDisarms(t *testing.T) { // mixed inbound and arms the OS proxy once the swapped tunnel comes up. h.send(Request{ID: 2, Cmd: CmdSetProxyMode, ProxyMode: "system-proxy"}) h.await() - h.waitStarts(2) - h.awaitLogContains("system proxy: OS now routing") - if f.enables() != 1 { - t.Errorf("enables = %d after swap to system-proxy, want 1", f.enables()) + h.awaitRestartConnected(2) + if f.enables() != 1 || f.disables() != 0 || f.lastHostPort() != "127.0.0.1:2080" { + t.Errorf("proxy after swap: enables=%d restores=%d target=%q, want 1/0/127.0.0.1:2080", f.enables(), f.disables(), f.lastHostPort()) + } + h.daemon.mu.Lock() + applied := h.daemon.proxyArmed && h.daemon.proxyApplied + h.daemon.mu.Unlock() + if !applied { + t.Error("connected system-proxy mode has no confirmed proxy ownership") } if got := firstInboundType(t, lastCfg(t, h)); got != "mixed" { t.Errorf("hot-swapped inbound type = %q, want mixed", got) } - // Switch back to tun while connected: the teardown clears the OS proxy before - // the tun tunnel comes up. + // Switch back to tun while connected: the teardown restores the previous + // proxy settings exactly once before the tun tunnel comes up. h.send(Request{ID: 3, Cmd: CmdSetProxyMode, ProxyMode: "tun"}) h.await() - h.waitStarts(3) - h.awaitLogContains("system proxy: cleared") - if f.disables() < 1 { - t.Errorf("switching back to tun did not clear the proxy (disables=%d)", f.disables()) + h.awaitRestartConnected(3) + if f.enables() != 1 || f.disables() != 1 { + t.Errorf("proxy after restoring tun: enables=%d restores=%d, want 1/1", f.enables(), f.disables()) + } + h.daemon.mu.Lock() + pending := h.daemon.proxyArmed || h.daemon.proxyApplied || h.daemon.proxyTarget != "" + h.daemon.mu.Unlock() + if pending { + t.Error("successful proxy restore retained ownership") } if got := firstInboundType(t, lastCfg(t, h)); got != "tun" { t.Errorf("swapped-back inbound type = %q, want tun", got) diff --git a/core/control/tunwatch_test.go b/core/control/tunwatch_test.go index 228a68ec..7d4ca7a1 100644 --- a/core/control/tunwatch_test.go +++ b/core/control/tunwatch_test.go @@ -127,7 +127,9 @@ func TestTunWatchLeavesAHealthyTunnelAlone(t *testing.T) { // absent one is normal and must not be read as a dead tunnel. func TestTunWatchInertInSystemProxyMode(t *testing.T) { h := newHarness(t) - h.daemon.ifacePresent = func(string) bool { return false } + f := h.useFakeProxy() + var looks atomic.Int32 + h.daemon.ifacePresent = func(string) bool { looks.Add(1); return false } h.daemon.tunWatchInterval = 20 * time.Millisecond h.daemon.mu.Lock() h.daemon.tun.Mode = singbox.ModeSystemProxy @@ -137,11 +139,18 @@ func TestTunWatchInertInSystemProxyMode(t *testing.T) { h.send(Request{ID: 1, Cmd: CmdConnect, Profile: p.ID}) h.await() h.awaitState(StateConnected) + beforeStops := h.runner.stops() + if f.enables() != 1 || f.lastHostPort() != "127.0.0.1:2080" { + t.Fatalf("system proxy was not applied before connected: enables=%d target=%q", f.enables(), f.lastHostPort()) + } time.Sleep(300 * time.Millisecond) if got := h.daemon.snapshotState().State; got != StateConnected { t.Errorf("state = %q in system-proxy mode, want connected", got) } + if looks.Load() != 0 || h.runner.stops() != beforeStops || f.disables() != 0 { + t.Errorf("proxy-mode watch was not inert: lookups=%d stops=%d (before=%d) proxy restores=%d", looks.Load(), h.runner.stops(), beforeStops, f.disables()) + } } // TestTunWatchDisabledByZeroInterval keeps the escape hatch honest: platforms From 795b2d0a3bc03cd65c44085fa0ea5542fd689f2f Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:27:10 +0300 Subject: [PATCH 37/56] fix(desktop): place service probe before test module --- ui-desktop/src-tauri/src/lib.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ui-desktop/src-tauri/src/lib.rs b/ui-desktop/src-tauri/src/lib.rs index 6072565f..27609bb8 100644 --- a/ui-desktop/src-tauri/src/lib.rs +++ b/ui-desktop/src-tauri/src/lib.rs @@ -1233,6 +1233,12 @@ fn update_notice(lang: Lang, version: &str) -> (&'static str, String) { } } +/// 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")) +} + #[cfg(test)] mod tests { use super::*; @@ -1549,9 +1555,3 @@ 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")) -} From cf894a07d14e12d4c9e4c7e2f0c6ba16fa035d61 Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:02:09 +0300 Subject: [PATCH 38/56] fix(windows): admit fully elevated installer peers --- core/control/peer_admission_windows_test.go | 134 ++++++++++++++++++++ core/control/peer_auth_windows.go | 69 ++++++++-- 2 files changed, 193 insertions(+), 10 deletions(-) create mode 100644 core/control/peer_admission_windows_test.go diff --git a/core/control/peer_admission_windows_test.go b/core/control/peer_admission_windows_test.go new file mode 100644 index 00000000..2f94c7b6 --- /dev/null +++ b/core/control/peer_admission_windows_test.go @@ -0,0 +1,134 @@ +//go:build windows + +package control + +import ( + "encoding/binary" + "errors" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +func TestWindowsPeerAdmission(t *testing.T) { + const self = "S-1-5-18" + const console = "S-1-5-21-1-1001" + const other = "S-1-5-21-1-1000" + tests := []struct { + name, peer, self, console string + admin bool + consoleErr error + want bool + }{ + {"elevated installer under different admin", other, self, console, true, nil, true}, + {"elevated installer before logon", other, self, "", true, errors.New("no console"), true}, + {"elevated installer with failed self lookup", other, "", console, true, nil, true}, + {"filtered different admin", other, self, console, false, nil, false}, + {"ordinary console user", console, self, console, false, nil, true}, + {"ordinary unrelated user", other, self, console, false, nil, false}, + {"ordinary user with missing console", other, self, "", false, errors.New("no console"), false}, + {"daemon account before logon", self, self, "", false, errors.New("no console"), true}, + {"unknown SID despite admin claim", "", self, console, true, nil, false}, + {"empty identities", "", "", "", false, nil, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := windowsPeerAllowed(tt.peer, tt.self, tt.admin, func() (string, error) { return tt.console, tt.consoleErr }, func(string) {}) + if got != tt.want { + t.Fatalf("channel admission = %v, want %v", got, tt.want) + } + if got && tt.peer == console && !tt.admin && peerPrivileged(tt.peer, tt.self, tt.admin) { + t.Fatal("ordinary console user's channel must not grant privileged commands") + } + }) + } +} + +func TestGroupEnabledRejectsContradictoryDenyOnly(t *testing.T) { + admins, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + t.Fatal(err) + } + groups := []windows.SIDAndAttributes{{Sid: admins, Attributes: windows.SE_GROUP_ENABLED | windows.SE_GROUP_USE_FOR_DENY_ONLY}} + if groupEnabled(groups, admins) { + t.Fatal("deny-only membership must never grant authority, even with the enabled bit") + } +} + +// Each fixture represents complete results from the token-information boundary; +// the policy below is production code, with only the Windows query substituted. +func TestTokenFullAdminRights(t *testing.T) { + tests := []struct { + name string + elevated, restricted, integrity uint32 + errorClass, shortClass uint32 + invalidSID, outsideBuffer, missingIntegrityAttribute bool + want bool + }{ + {name: "full elevated admin", elevated: 1, integrity: 0x3000, want: true}, + {name: "system integrity", elevated: 1, integrity: 0x4000, want: true}, + {name: "not elevated", integrity: 0x3000}, + {name: "restricted elevated token", elevated: 1, restricted: 1, integrity: 0x3000}, + {name: "low integrity elevated token", elevated: 1, integrity: 0x1000}, + {name: "medium integrity elevated token", elevated: 1, integrity: 0x2000}, + {name: "medium plus integrity elevated token", elevated: 1, integrity: 0x2100}, + {name: "elevation query error", elevated: 1, integrity: 0x3000, errorClass: windows.TokenElevation}, + {name: "restriction query error", elevated: 1, integrity: 0x3000, errorClass: windows.TokenHasRestrictions}, + {name: "integrity query error", elevated: 1, integrity: 0x3000, errorClass: windows.TokenIntegrityLevel}, + {name: "short elevation result", elevated: 1, integrity: 0x3000, shortClass: windows.TokenElevation}, + {name: "short restriction result", elevated: 1, integrity: 0x3000, shortClass: windows.TokenHasRestrictions}, + {name: "short integrity result", elevated: 1, integrity: 0x3000, shortClass: windows.TokenIntegrityLevel}, + {name: "invalid integrity authority", elevated: 1, integrity: 0x3000, invalidSID: true}, + {name: "integrity SID outside returned buffer", elevated: 1, integrity: 0x3000, outsideBuffer: true}, + {name: "missing integrity attribute", elevated: 1, integrity: 0x3000, missingIntegrityAttribute: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + query := func(_ windows.Token, class uint32, info *byte, size uint32, out *uint32) error { + if class == tt.errorClass { + return windows.ERROR_ACCESS_DENIED + } + buffer := unsafe.Slice(info, size) + switch class { + case windows.TokenElevation: + binary.LittleEndian.PutUint32(buffer, tt.elevated) + *out = 4 + case windows.TokenHasRestrictions: + binary.LittleEndian.PutUint32(buffer, tt.restricted) + *out = 4 + case windows.TokenIntegrityLevel: + header := int(unsafe.Sizeof(windows.Tokenmandatorylabel{})) + if len(buffer) < header+12 { + return windows.ERROR_INSUFFICIENT_BUFFER + } + label := (*windows.Tokenmandatorylabel)(unsafe.Pointer(info)) + label.Label.Attributes = windows.SE_GROUP_INTEGRITY + if tt.missingIntegrityAttribute { + label.Label.Attributes = 0 + } + label.Label.Sid = (*windows.SID)(unsafe.Add(unsafe.Pointer(info), header)) + sid := buffer[header : header+12] + copy(sid, []byte{1, 1, 0, 0, 0, 0, 0, 16, 0, 0, 0, 0}) + binary.LittleEndian.PutUint32(sid[8:], tt.integrity) + if tt.invalidSID { + sid[7] = 5 + } + *out = uint32(header + 12) + if tt.outsideBuffer { + label.Label.Sid = (*windows.SID)(unsafe.Add(unsafe.Pointer(info), *out)) + } + default: + return windows.ERROR_INVALID_PARAMETER + } + if class == tt.shortClass { + *out = 1 + } + return nil + } + if got := tokenHasFullAdminRights(0, query); got != tt.want { + t.Fatalf("full admin rights = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/core/control/peer_auth_windows.go b/core/control/peer_auth_windows.go index 14e0150e..69d334e3 100644 --- a/core/control/peer_auth_windows.go +++ b/core/control/peer_auth_windows.go @@ -3,8 +3,10 @@ package control import ( + "encoding/binary" "fmt" "net" + "unsafe" "golang.org/x/sys/windows" ) @@ -12,8 +14,9 @@ import ( // authorizePeer decides whether the just-accepted named-pipe peer may drive the // daemon, and whether it holds the daemon's own authority (see peerPrivileged). // It resolves the connecting process's user SID and administrative membership -// and runs the shared policies against the console user's SID (see peer_auth.go -// for the trust rationale). +// and admits a fully elevated administrator or the shared self/console policy. +// In particular, an installer elevated with another account must still reach +// the LocalSystem service while the ordinary desktop user remains logged in. // // A conn whose peer cannot be identified is REFUSED. The production listener // only ever yields winio pipe conns, whose client process is always resolvable, @@ -31,7 +34,7 @@ func (d *Daemon) authorizePeer(conn net.Conn) (allowed, privileged bool) { // check below still governs. Empty never matches a real peer SID. self = "" } - if !peerAllowed(sid, self, consoleUserSID, func(msg string) { + if !windowsPeerAllowed(sid, self, admin, consoleUserSID, func(msg string) { d.emitLog(LogWarn, msg) }) { return false, false @@ -80,20 +83,20 @@ func processIdentity(pid uint32) (sid string, admin, ok bool) { return tu.User.Sid.String(), tokenIsAdmin(tok), true } -// tokenIsAdmin reports whether tok carries BUILTIN\Administrators as an ENABLED -// group. +// tokenIsAdmin reports whether tok has unrestricted, elevated administrative +// authority at High integrity or above, including enabled Administrators. // // The group list is walked directly rather than asking CheckTokenMembership, // for two reasons. CheckTokenMembership wants an impersonation token, which // would mean duplicating a token opened from someone else's process; and the -// attribute check is exactly the distinction that matters here. A UAC-filtered +// attribute check distinguishes the group memberships. A UAC-filtered // token — what every non-elevated process of an administrator runs with — still // LISTS Administrators, but marks it SE_GROUP_USE_FOR_DENY_ONLY with // SE_GROUP_ENABLED cleared. Treating that as administrative would hand the // service's authority to any process the user launched by double-clicking it, // which is the escalation this check exists to stop; the elevated half of the // same account, obtained through the UAC prompt, has the group enabled and -// passes. +// passes, provided the token has not been restricted or lowered in integrity. // // A token whose groups can't be read is not administrative as far as this // answers: the caller then falls back on the peer==self shortcut, which is @@ -107,16 +110,16 @@ func tokenIsAdmin(tok windows.Token) bool { if err != nil { return false } - return groupEnabled(groups.AllGroups(), admins) + return groupEnabled(groups.AllGroups(), admins) && tokenHasFullAdminRights(tok, windows.GetTokenInformation) } // groupEnabled reports whether want appears in groups as an ENABLED membership. -// It is the whole of the deny-only distinction tokenIsAdmin rests on, split out +// It enforces the deny-only distinction tokenIsAdmin rests on, split out // so it can be tested against a group list a test builds by hand — a real // UAC-filtered token cannot be minted inside a test process. func groupEnabled(groups []windows.SIDAndAttributes, want *windows.SID) bool { for _, g := range groups { - if g.Attributes&windows.SE_GROUP_ENABLED == 0 { + if g.Sid == nil || g.Attributes&windows.SE_GROUP_ENABLED == 0 || g.Attributes&windows.SE_GROUP_USE_FOR_DENY_ONLY != 0 { continue } if windows.EqualSid(g.Sid, want) { @@ -159,3 +162,49 @@ func consoleUserSID() (string, error) { } return tu.User.Sid.String(), nil } + +func windowsPeerAllowed(peer, self string, admin bool, console consoleUser, warn func(string)) bool { + // Full administrators already control this service. Requiring them also to + // own the console session breaks over-the-shoulder UAC and unattended setup. + // A failed identity lookup must never turn an admin claim into admission. + if peer != "" && admin { + return true + } + return peerAllowed(peer, self, console, warn) +} + +type tokenInformationQuery func(windows.Token, uint32, *byte, uint32, *uint32) error + +func tokenHasFullAdminRights(tok windows.Token, query tokenInformationQuery) bool { + var elevated, restricted, size uint32 + if err := query(tok, windows.TokenElevation, (*byte)(unsafe.Pointer(&elevated)), 4, &size); err != nil || size != 4 || elevated == 0 { + return false + } + // Unlike IsTokenRestricted (which only checks restricting SIDs), this also + // rejects tokens filtered by removing privileges or disabling groups. + if err := query(tok, windows.TokenHasRestrictions, (*byte)(unsafe.Pointer(&restricted)), 4, &size); err != nil || size != 4 || restricted != 0 { + return false + } + // TOKEN_MANDATORY_LABEL plus a SID fits in 128 bytes (maximum SID: 68). + // Keep the SID in the returned buffer and validate its framing before use. + var buffer [128]byte + header := uintptr(unsafe.Sizeof(windows.Tokenmandatorylabel{})) + if err := query(tok, windows.TokenIntegrityLevel, &buffer[0], uint32(len(buffer)), &size); err != nil || uintptr(size) < header+12 || size > uint32(len(buffer)) { + return false + } + label := (*windows.Tokenmandatorylabel)(unsafe.Pointer(&buffer[0])) + start := uintptr(unsafe.Pointer(&buffer[0])) + ptr := uintptr(unsafe.Pointer(label.Label.Sid)) + if label.Label.Attributes&windows.SE_GROUP_INTEGRITY == 0 || ptr < start+header || ptr-start > uintptr(size)-12 { + return false + } + // Integrity labels use revision 1, exactly one RID, and authority 16. + // Reading from buffer rather than dereferencing the returned pointer keeps + // malformed or truncated results fail-closed. + sid := buffer[ptr-start : ptr-start+12] + if sid[0] != 1 || sid[1] != 1 || sid[2] != 0 || sid[3] != 0 || sid[4] != 0 || sid[5] != 0 || sid[6] != 0 || sid[7] != 16 { + return false + } + const highIntegrityRID = 0x3000 + return binary.LittleEndian.Uint32(sid[8:]) >= highIntegrityRID +} From ab0c0e9181b388f5974fb19eeffa399a6a9681cc Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:16:07 +0300 Subject: [PATCH 39/56] ci(macos): retain bounded Go race diagnostics --- .github/workflows/ci.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8147c103..dc1c3413 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -217,6 +217,7 @@ jobs: # files are gated on `//go:build darwin`, so this is the only job that # exercises them. The hosted macOS runners are all arm64. runs-on: macos-14 + timeout-minutes: 25 steps: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 @@ -225,7 +226,25 @@ jobs: cache: false - run: go vet ./... - run: go build ./... - - run: go test ./... -race -count=1 + - name: Run Go race tests with retained diagnostics + id: race + timeout-minutes: 12 + shell: bash + # A package timeout emits goroutine stacks; JSON streams the last active + # test even if the outer step limit is reached during build or execution. + # pipefail keeps a failing test red when tee successfully saves its log. + run: | + set -o pipefail + go test ./... -race -count=1 -timeout=4m -json 2>&1 | tee "$RUNNER_TEMP/tenebra-macos-go-race.log" + - name: Retain macOS Go test progress and failure stacks + if: ${{ always() && steps.race.outcome != 'skipped' }} + timeout-minutes: 2 + uses: actions/upload-artifact@v6 + with: + name: tenebra-macos-go-race-log + path: ${{ runner.temp }}/tenebra-macos-go-race.log + retention-days: 7 + if-no-files-found: error # TODO(macos): add the Tauri universal-DMG bundle build here once the # darwin resource fetch (scripts/fetch-resources.sh: sing-box darwin + # lipo) and the externalBin/notarization caveat are resolved. See From 8a38bda81882794b94162f0a6c61329788e8d368 Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:40:42 +0300 Subject: [PATCH 40/56] fix(windows): allow interactive service image authentication --- .../service_process_access_windows.go | 135 +++++++++++ .../service_process_access_windows_test.go | 224 ++++++++++++++++++ cmd/tenebra-core/service_windows.go | 4 + docs/windows-service-authentication.md | 36 +++ 4 files changed, 399 insertions(+) create mode 100644 cmd/tenebra-core/service_process_access_windows.go create mode 100644 cmd/tenebra-core/service_process_access_windows_test.go create mode 100644 docs/windows-service-authentication.md diff --git a/cmd/tenebra-core/service_process_access_windows.go b/cmd/tenebra-core/service_process_access_windows.go new file mode 100644 index 00000000..1d6e2ece --- /dev/null +++ b/cmd/tenebra-core/service_process_access_windows.go @@ -0,0 +1,135 @@ +//go:build windows + +package main + +import ( + "errors" + "fmt" + "os" + "runtime" + + "golang.org/x/sys/windows" +) + +// The GUI authenticates the pipe server using SCM, its process image, and a +// retained process handle. A LocalSystem process's inherited DACL need not let +// an ordinary console user query that image. Grant just that metadata right on +// OUR process before listening; never weaken the GUI's identity checks or grant +// process memory, duplication, termination, token, or security-editing rights. +// This is a process-lifetime ACL change, not a machine/service/token policy. +func enableServiceProcessQuery() error { + identity, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return fmt.Errorf("read service identity: %w", err) + } + if identity.User.Sid == nil || !identity.User.Sid.IsWellKnown(windows.WinLocalSystemSid) { + return errors.New("service must run as LocalSystem") + } + process, err := windows.OpenProcess(windows.READ_CONTROL|windows.WRITE_DAC, false, uint32(os.Getpid())) + if err != nil { + return fmt.Errorf("open own process security: %w", err) + } + defer windows.CloseHandle(process) + original, err := windows.GetSecurityInfo(process, windows.SE_KERNEL_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + return fmt.Errorf("read own process DACL: %w", err) + } + updated, err := serviceProcessQueryACL(original) + if err != nil { + return err + } + // DACL only: preserve the owner, primary group, SACL/integrity label, and + // protection flags. The merge preserves all existing grants and denials. + if err := windows.SetSecurityInfo(process, windows.SE_KERNEL_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, updated, nil); err != nil { + return fmt.Errorf("grant own process metadata query: %w", err) + } + actual, err := windows.GetSecurityInfo(process, windows.SE_KERNEL_OBJECT, windows.DACL_SECURITY_INFORMATION) + if err != nil { + return fmt.Errorf("read back own process DACL: %w", err) + } + if err := verifyServiceProcessQueryACL(updated, actual); err != nil { + return err + } + before, _, err := original.Control() + if err != nil { + return fmt.Errorf("read original process DACL flags: %w", err) + } + after, _, err := actual.Control() + if err != nil || before&windows.SE_DACL_PROTECTED != after&windows.SE_DACL_PROTECTED { + return errors.New("own process DACL protection changed") + } + return nil +} + +func serviceProcessQueryACL(original *windows.SECURITY_DESCRIPTOR) (*windows.ACL, error) { + if original == nil || !original.IsValid() { + return nil, errors.New("invalid own process security descriptor") + } + dacl, _, err := original.DACL() + if err != nil || dacl == nil { + return nil, errors.New("own process must have an explicit non-null DACL") + } + interactive, err := windows.CreateWellKnownSid(windows.WinInteractiveSid) + if err != nil { + return nil, fmt.Errorf("create interactive SID: %w", err) + } + entries := []windows.EXPLICIT_ACCESS{{ + AccessPermissions: windows.PROCESS_QUERY_LIMITED_INFORMATION, + AccessMode: windows.GRANT_ACCESS, + Inheritance: windows.NO_INHERITANCE, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_WELL_KNOWN_GROUP, + TrusteeValue: windows.TrusteeValueFromSID(interactive), + }, + }} + merged, err := windows.ACLFromEntries(entries, dacl) + runtime.KeepAlive(interactive) + if err != nil { + return nil, fmt.Errorf("merge own process metadata query ACL: %w", err) + } + return merged, nil +} + +// Compare the entire DACL, not only the new ACE: a failed/partial readback must +// not publish a service as ready, nor conceal lost existing permissions. +func verifyServiceProcessQueryACL(expected *windows.ACL, actual *windows.SECURITY_DESCRIPTOR) error { + if actual == nil || !actual.IsValid() { + return errors.New("invalid own process security readback") + } + dacl, _, err := actual.DACL() + if err != nil || dacl == nil || expected == nil { + return errors.New("own process security readback has no explicit DACL") + } + want, err := processACLString(expected) + if err != nil { + return err + } + got, err := processACLString(dacl) + if err != nil { + return err + } + if want != got { + return errors.New("own process metadata-query DACL readback differs") + } + return nil +} + +func processACLString(dacl *windows.ACL) (string, error) { + if dacl == nil { + return "", errors.New("null process DACL") + } + sd, err := windows.NewSecurityDescriptor() + if err != nil { + return "", err + } + if err := sd.SetDACL(dacl, true, false); err != nil { + return "", err + } + value := sd.String() + runtime.KeepAlive(dacl) + if value == "" { + return "", errors.New("cannot encode process DACL") + } + return value, nil +} diff --git a/cmd/tenebra-core/service_process_access_windows_test.go b/cmd/tenebra-core/service_process_access_windows_test.go new file mode 100644 index 00000000..81b39466 --- /dev/null +++ b/cmd/tenebra-core/service_process_access_windows_test.go @@ -0,0 +1,224 @@ +//go:build windows + +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" + "testing" + "unsafe" + + "golang.org/x/sys/windows" +) + +// Execute cannot be started in a unit test: it owns a real service and daemon. +// Guard its startup boundary without launching either on the developer machine. +func TestServicePublishesQueryableProcessBeforeDaemon(t *testing.T) { + file, err := parser.ParseFile(token.NewFileSet(), "service_windows.go", nil, 0) + if err != nil { + t.Fatal(err) + } + var execute *ast.FuncDecl + for _, declaration := range file.Decls { + if fn, ok := declaration.(*ast.FuncDecl); ok && fn.Name.Name == "Execute" && fn.Recv != nil { + execute = fn + } + } + if execute == nil { + t.Fatal("service Execute method missing") + } + guard, daemon := token.NoPos, token.NoPos + ast.Inspect(execute, func(node ast.Node) bool { + if call, ok := node.(*ast.CallExpr); ok { + if name, ok := call.Fun.(*ast.Ident); ok { + switch name.Name { + case "enableServiceProcessQuery": + guard = call.Pos() + case "buildDaemon": + daemon = call.Pos() + } + } + } + return true + }) + if guard == token.NoPos || daemon == token.NoPos || guard >= daemon { + t.Fatal("service must grant and verify its metadata query ACL before daemon construction/listening/Running") + } + // A grant/readback failure must return a failed service start, rather than + // merely logging and continuing toward the listener or Running state. + guardedFailure := false + for _, statement := range execute.Body.List { + branch, ok := statement.(*ast.IfStmt) + if !ok || branch.Init == nil { + continue + } + assignment, ok := branch.Init.(*ast.AssignStmt) + if !ok || len(assignment.Rhs) != 1 { + continue + } + call, ok := assignment.Rhs[0].(*ast.CallExpr) + if !ok { + continue + } + name, ok := call.Fun.(*ast.Ident) + if !ok || name.Name != "enableServiceProcessQuery" { + continue + } + condition, ok := branch.Cond.(*ast.BinaryExpr) + if !ok || condition.Op != token.NEQ { + t.Fatal("process-query startup error is not checked") + } + left, leftOK := condition.X.(*ast.Ident) + right, rightOK := condition.Y.(*ast.Ident) + if !leftOK || !rightOK || left.Name != "err" || right.Name != "nil" { + t.Fatal("process-query startup error condition changed") + } + last, ok := branch.Body.List[len(branch.Body.List)-1].(*ast.ReturnStmt) + if ok && len(last.Results) == 2 { + failure, ok := last.Results[1].(*ast.BasicLit) + guardedFailure = ok && failure.Kind == token.INT && failure.Value == "1" + } + } + if !guardedFailure { + t.Fatal("process-query ACL failure can continue service startup") + } +} + +// These tests use only in-memory security descriptors and the Windows ACL +// parser/merger. They never open a service, process, pipe, or network interface. +func TestServiceProcessQueryACLAddsOnlyInteractiveMetadata(t *testing.T) { + original := processDescriptor(t, "O:SYG:SYD:P(A;;GA;;;SY)(A;;GA;;;BA)") + before := original.String() + merged, err := serviceProcessQueryACL(original) + if err != nil { + t.Fatal(err) + } + text, err := processACLString(merged) + if err != nil { + t.Fatal(err) + } + if original.String() != before { + t.Fatal("merging changed the original descriptor") + } + for _, retained := range []string{"(A;;GA;;;SY)", "(A;;GA;;;BA)"} { + if !strings.Contains(text, retained) { + t.Fatalf("lost original ACE %s: %s", retained, text) + } + } + if merged.AceCount != 3 { + t.Fatalf("expected exactly the two original ACEs and interactive query grant: %s", text) + } + iu, err := windows.CreateWellKnownSid(windows.WinInteractiveSid) + if err != nil { + t.Fatal(err) + } + found := false + for i := uint32(0); i < uint32(merged.AceCount); i++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(merged, i, &ace); err != nil { + t.Fatal(err) + } + if ace.Header.AceType == windows.ACCESS_ALLOWED_ACE_TYPE && windows.EqualSid((*windows.SID)(unsafe.Pointer(&ace.SidStart)), iu) { + found = true + if ace.Mask != windows.PROCESS_QUERY_LIMITED_INFORMATION || ace.Header.AceFlags != 0 { + t.Fatalf("interactive grant widened or became inheritable: mask=%#x flags=%#x", ace.Mask, ace.Header.AceFlags) + } + } + } + if !found { + t.Fatal("interactive metadata query ACE missing") + } + // Re-applying on a descriptor that already carries the grant adds nothing. + second, err := serviceProcessQueryACL(processDescriptor(t, text)) + if err != nil { + t.Fatal(err) + } + again, err := processACLString(second) + if err != nil || again != text { + t.Fatalf("grant is not idempotent: first=%s again=%s err=%v", text, again, err) + } +} + +func TestServiceProcessQueryACLPreservesExistingDenialsAndGrants(t *testing.T) { + // Existing denials stay authoritative, including a denial of query itself. + // The grant never revokes them or rewrites a machine's stricter policy. + for _, sddl := range []string{ + "D:P(D;;0x1;;;IU)(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x20000;;;LS)", + "D:P(D;;0x1000;;;WD)(A;;GA;;;SY)(A;;GA;;;BA)", + } { + original := processDescriptor(t, sddl) + merged, err := serviceProcessQueryACL(original) + if err != nil { + t.Fatal(err) + } + text, err := processACLString(merged) + if err != nil { + t.Fatal(err) + } + originalACL, _, _ := original.DACL() + if merged.AceCount != originalACL.AceCount+1 { + t.Fatalf("existing entries lost or unexpected entries added: %s", text) + } + for _, ace := range strings.Split(original.String(), "(")[1:] { + if !strings.Contains(text, "("+ace) { + t.Fatalf("existing ACE changed: (%s in %s", ace, text) + } + } + } +} + +func TestServiceProcessQueryACLRejectsUnrestrictedOrInvalidDescriptor(t *testing.T) { + for name, original := range map[string]*windows.SECURITY_DESCRIPTOR{ + "nil": nil, + "invalid": new(windows.SECURITY_DESCRIPTOR), + "absent": processDescriptor(t, "O:SY"), + "null": processDescriptor(t, "D:NO_ACCESS_CONTROL"), + } { + t.Run(name, func(t *testing.T) { + if _, err := serviceProcessQueryACL(original); err == nil { + t.Fatal("accepted an invalid or fully permissive process DACL") + } + }) + } +} + +func TestServiceProcessQueryACLReadbackRejectsMissingOrWidenedEntries(t *testing.T) { + original := processDescriptor(t, "D:P(D;;0x1;;;IU)(A;;GA;;;SY)(A;;GA;;;BA)") + expected, err := serviceProcessQueryACL(original) + if err != nil { + t.Fatal(err) + } + text, err := processACLString(expected) + if err != nil { + t.Fatal(err) + } + if err := verifyServiceProcessQueryACL(expected, processDescriptor(t, text)); err != nil { + t.Fatal(err) + } + for name, actual := range map[string]*windows.SECURITY_DESCRIPTOR{ + "nil": nil, + "invalid": new(windows.SECURITY_DESCRIPTOR), + "null": processDescriptor(t, "D:NO_ACCESS_CONTROL"), + "missing-grant": original, + "lost-denial": processDescriptor(t, "D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x1000;;;IU)"), + "lost-admin": processDescriptor(t, "D:(D;;0x1;;;IU)(A;;GA;;;SY)(A;;0x1000;;;IU)"), + "broad-iu": processDescriptor(t, "D:(D;;0x1;;;IU)(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;IU)"), + } { + t.Run(name, func(t *testing.T) { + if err := verifyServiceProcessQueryACL(expected, actual); err == nil { + t.Fatal("unverified process ACL accepted as ready") + } + }) + } +} + +func processDescriptor(t *testing.T, sddl string) *windows.SECURITY_DESCRIPTOR { + t.Helper() + sd, err := windows.SecurityDescriptorFromString(sddl) + if err != nil { + t.Fatal(err) + } + return sd +} diff --git a/cmd/tenebra-core/service_windows.go b/cmd/tenebra-core/service_windows.go index c545068a..bcb53fd2 100644 --- a/cmd/tenebra-core/service_windows.go +++ b/cmd/tenebra-core/service_windows.go @@ -62,6 +62,10 @@ type coreService struct{} func (coreService) Execute(args []string, req <-chan svc.ChangeRequest, status chan<- svc.Status) (svcSpecificEC bool, exitCode uint32) { status <- svc.Status{State: svc.StartPending} + if err := enableServiceProcessQuery(); err != nil { + log.Printf("fatal: service process authentication: %v", err) + return false, 1 + } if err := configureServicePaths(); err != nil { log.Printf("fatal: %v", err) return false, 1 diff --git a/docs/windows-service-authentication.md b/docs/windows-service-authentication.md new file mode 100644 index 00000000..910035e1 --- /dev/null +++ b/docs/windows-service-authentication.md @@ -0,0 +1,36 @@ +# Windows service authentication + +The desktop opens the control pipe with the exact client mask `0x120083` +and identification-only impersonation. Before sending a request, Rust checks +the pipe server PID against the running, own-process LocalSystem Tenebra +service, reads the registered image and actual process image, and repeats +the PID/status check while retaining the process handle. + +On Windows, a LocalSystem process can inherit a DACL that denies ordinary +users even `PROCESS_QUERY_LIMITED_INFORMATION`. A pipe connection and SCM +queries can therefore succeed while the process-image authentication fails +with access denied. This was reproduced in a clean Windows guest. + +Before constructing the daemon, listening, or reporting Running, the service +adds one non-inheritable `INTERACTIVE` (`S-1-5-4`) grant of exactly `0x1000` +to its own process DACL. It preserves existing ACEs, owner, group, SACL and +protection flags; it verifies the complete resulting DACL. Read, merge, +write or readback failures abort startup. Null or invalid DACLs are rejected. +Existing deny entries remain authoritative and are never removed to bypass +a stricter policy. + +This permits process metadata queries, including the executable path. It +does not grant process memory access, handle duplication, termination, +suspension, injection, token access or ACL changes. The ACL exists only for +the current service process and is recreated on each start. It changes no +machine-wide policy and does not weaken the desktop's server checks. + +The unit tests manipulate in-memory security descriptors only. Native +acceptance must separately verify the installed service from the ordinary +console-user token and from an elevated installer token. Source checks and +an SCM Running state alone do not prove the connection works. + +References: Microsoft documents the +[process access rights](https://learn.microsoft.com/en-us/windows/win32/procthread/process-security-and-access-rights), +[ACL merge behavior](https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setentriesinaclw), +and [handle-based security updates](https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setsecurityinfo). From a6ae7d6e89b30f5c33f5d1db7745afcb5dc0c0a3 Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:42:45 +0300 Subject: [PATCH 41/56] docs: align Windows pipe authentication contract --- docs/control-protocol.md | 62 ++++++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 04214f5a..070cbd62 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -127,44 +127,57 @@ always serves the well-known name. (The unix transport is symmetric here instead: both ends honour `TENEBRA_SOCKET`.) The GUI dials with `SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION`, capping -impersonation at identification: an instance-squatter admitted by the DACL -(see below) could learn who the client is, but cannot act as it. +impersonation at identification. A server that receives a connection cannot +use that connection to impersonate the client with greater authority. ### Pipe security The pipe is created with the SDDL -`D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)`, admitting exactly three -identities: +`D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x120083;;;IU)`. Its transport DACL admits +three identities: - **SYSTEM** — the service itself; - **Administrators** — elevated processes; -- **INTERACTIVE** — any locally logged-in user. This is what lets the - unprivileged GUI drive the privileged service, and it is the same trust - decision Tailscale's LocalAPI pipe makes on Windows. +- **INTERACTIVE** — locally logged-in users can open a client connection. + The exact client mask grants read/write data, read attributes, read control + and synchronization. It excludes `FILE_CREATE_PIPE_INSTANCE`, security + modification and generic-write access. + +Transport access is followed by peer authentication. The service reads the +kernel-reported client PID and token. It admits its own account, the current +console user, or a fully elevated administrator. Administrative admission +requires enabled Administrators membership, elevation, High integrity or +above, and no token restrictions; a deny-only or filtered membership does not +qualify. This lets an installer elevated as another account reach the service +while the ordinary console user remains logged in. Failed peer identity +lookups are rejected; an ordinary non-console user is rejected as well. The honest limits of that model: -- the tunnel is machine-wide, and so is control over it: *any* interactive - local user — not just the one who started the GUI — can drive the tunnel, - see its state and events, and take the session over. On a genuinely - multi-user machine that is a real sharing of control, not an oversight. +- the tunnel is machine-wide; the current console user can control it and + inspect its state even if another user originally started it. Fully elevated + administrators already administer the service and are also admitted. - processes of the same user are not defended against each other; same-user malware already owns the session. -- remote (network-logon) callers never carry the INTERACTIVE SID, so reaching - the pipe remotely requires administrator credentials — a caller that already - administers the machine. +- the listener rejects remote pipe clients; the local interactive grant is + not remote network access. Driving the tunnel is where that trust stops. The commands that hand the daemon executable code need more than admission — see [Commands that need the daemon's own authority](#commands-that-need-the-daemons-own-authority). -The listener claims the name with `FILE_FLAG_FIRST_PIPE_INSTANCE`, so if -something else already holds it the service fails loudly at start instead of -silently sharing the name. That flag does not stop an *already-admitted* -identity from adding instances to the bound name later (on pipes, -`GENERIC_WRITE` implies `FILE_CREATE_PIPE_INSTANCE`) — which is another face -of the same trust statement: interactive users are trusted with this control -surface. +The listener claims the name exclusively for its first instance. A preexisting +pipe name makes service startup fail. The interactive ACE also prevents an +ordinary client from adding competing instances after startup: its mask does +not include `FILE_CREATE_PIPE_INSTANCE` (`0x4`), which generic write would grant. +The GUI requests the same minimal mask rather than `GENERIC_READ|GENERIC_WRITE`. + +Before sending IPC payload, the GUI additionally verifies the connected pipe's +server PID against the running LocalSystem own-process service, its registered +and actual executable paths, and a repeated PID/status check while retaining +the process handle. The service grants ordinary interactive users only the +process metadata-query right needed for this check; see +[Windows service authentication](windows-service-authentication.md). ### Unix-socket security @@ -176,11 +189,12 @@ accepted connection is authenticated from credentials the kernel attached to it, which the peer cannot forge or change after connecting: `LOCAL_PEERCRED` on macOS, `SO_PEERCRED` on Linux. -The policy those credentials feed is shared with Windows, which resolves the -caller's SID instead: a peer is admitted if it is the daemon's own account +The base policy those credentials feed is shared with Windows, which resolves +the caller's SID instead: a peer is admitted if it is the daemon's own account (root, so an elevated same-account helper is not locked out) or the user of the interactive session. That is narrower than the historical "any local user" the -pipe DACL still grants, and it is where the two platforms differ in what +pipe DACL grants at the transport layer. Windows additionally admits the fully +elevated administrators described above. The two Unix platforms differ in what "interactive session" means: - macOS reads the owner of `/dev/console`, which the window server chowns to From e24a5c114b24ca2a198b0ac6b20fdb4bd50540f7 Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:50:02 +0300 Subject: [PATCH 42/56] ci(android): skip obsolete SDK tools download --- .github/workflows/android.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 0b877f85..834cbff2 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -59,6 +59,10 @@ jobs: java-version: '17' # The SDK + build-tools Gradle needs to assemble the APK. - uses: android-actions/setup-android@v4 + with: + # Gradle installs the required platform/build-tools. Avoid the obsolete + # SDK Tools archive pulled in by the action's default "tools" package. + packages: platform-tools # The NDK gomobile needs to bind BOTH .aars. Pinned to r28 (what the # sing-box porting notes target); the generic SDK setup does not pin an NDK. - uses: nttld/setup-ndk@v1 @@ -171,6 +175,8 @@ jobs: distribution: temurin java-version: '17' - uses: android-actions/setup-android@v4 + with: + packages: platform-tools - uses: nttld/setup-ndk@v1 id: ndk with: From d6ad83a93c7925ebc560c8797d82bc9f83ac2850 Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:03:39 +0300 Subject: [PATCH 43/56] fix(windows): accept native token restriction BOOLEAN result --- core/control/peer_admission_windows_test.go | 22 ++++++++++++++++++++- core/control/peer_auth_windows.go | 13 ++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/core/control/peer_admission_windows_test.go b/core/control/peer_admission_windows_test.go index 2f94c7b6..681fb568 100644 --- a/core/control/peer_admission_windows_test.go +++ b/core/control/peer_admission_windows_test.go @@ -63,11 +63,24 @@ func TestTokenFullAdminRights(t *testing.T) { name string elevated, restricted, integrity uint32 errorClass, shortClass uint32 + restrictionResult []byte + restrictionPadding byte invalidSID, outsideBuffer, missingIntegrityAttribute bool want bool }{ {name: "full elevated admin", elevated: 1, integrity: 0x3000, want: true}, {name: "system integrity", elevated: 1, integrity: 0x4000, want: true}, + {name: "native one byte unrestricted BOOLEAN", elevated: 1, integrity: 0x3000, restrictionResult: []byte{0}, want: true}, + {name: "one byte excludes bytes outside returned result", elevated: 1, integrity: 0x3000, restrictionResult: []byte{0}, restrictionPadding: 0xff, want: true}, + {name: "one byte restricted BOOLEAN", elevated: 1, integrity: 0x3000, restrictionResult: []byte{1}}, + {name: "one byte noncanonical nonzero BOOLEAN", elevated: 1, integrity: 0x3000, restrictionResult: []byte{0x80}}, + {name: "four byte restriction in second byte", elevated: 1, integrity: 0x3000, restrictionResult: []byte{0, 1, 0, 0}}, + {name: "four byte restriction in third byte", elevated: 1, integrity: 0x3000, restrictionResult: []byte{0, 0, 1, 0}}, + {name: "four byte restriction in fourth byte", elevated: 1, integrity: 0x3000, restrictionResult: []byte{0, 0, 0, 0x80}}, + {name: "zero byte restriction result", elevated: 1, integrity: 0x3000, restrictionResult: []byte{}}, + {name: "two byte restriction result", elevated: 1, integrity: 0x3000, restrictionResult: []byte{0, 0}}, + {name: "three byte restriction result", elevated: 1, integrity: 0x3000, restrictionResult: []byte{0, 0, 0}}, + {name: "oversize restriction result", elevated: 1, integrity: 0x3000, restrictionResult: []byte{0, 0, 0, 0, 0}}, {name: "not elevated", integrity: 0x3000}, {name: "restricted elevated token", elevated: 1, restricted: 1, integrity: 0x3000}, {name: "low integrity elevated token", elevated: 1, integrity: 0x1000}, @@ -97,6 +110,13 @@ func TestTokenFullAdminRights(t *testing.T) { case windows.TokenHasRestrictions: binary.LittleEndian.PutUint32(buffer, tt.restricted) *out = 4 + if tt.restrictionResult != nil { + for i := range buffer { + buffer[i] = tt.restrictionPadding + } + copy(buffer, tt.restrictionResult) + *out = uint32(len(tt.restrictionResult)) + } case windows.TokenIntegrityLevel: header := int(unsafe.Sizeof(windows.Tokenmandatorylabel{})) if len(buffer) < header+12 { @@ -122,7 +142,7 @@ func TestTokenFullAdminRights(t *testing.T) { return windows.ERROR_INVALID_PARAMETER } if class == tt.shortClass { - *out = 1 + *out = 2 } return nil } diff --git a/core/control/peer_auth_windows.go b/core/control/peer_auth_windows.go index 69d334e3..f2676bbe 100644 --- a/core/control/peer_auth_windows.go +++ b/core/control/peer_auth_windows.go @@ -176,15 +176,24 @@ func windowsPeerAllowed(peer, self string, admin bool, console consoleUser, warn type tokenInformationQuery func(windows.Token, uint32, *byte, uint32, *uint32) error func tokenHasFullAdminRights(tok windows.Token, query tokenInformationQuery) bool { - var elevated, restricted, size uint32 + var elevated, size uint32 if err := query(tok, windows.TokenElevation, (*byte)(unsafe.Pointer(&elevated)), 4, &size); err != nil || size != 4 || elevated == 0 { return false } // Unlike IsTokenRestricted (which only checks restricting SIDs), this also // rejects tokens filtered by removing privileges or disabling groups. - if err := query(tok, windows.TokenHasRestrictions, (*byte)(unsafe.Pointer(&restricted)), 4, &size); err != nil || size != 4 || restricted != 0 { + // Windows also returns this as a one-byte BOOLEAN, although the documented + // form is a DWORD. Only those two sizes are valid; every returned byte must + // be zero. Do not read padding beyond the reported result. + var restricted [4]byte + if err := query(tok, windows.TokenHasRestrictions, &restricted[0], uint32(len(restricted)), &size); err != nil || (size != 1 && size != 4) { return false } + for _, b := range restricted[:size] { + if b != 0 { + return false + } + } // TOKEN_MANDATORY_LABEL plus a SID fits in 128 bytes (maximum SID: 68). // Keep the SID in the returned buffer and validate its framing before use. var buffer [128]byte From c69f7df629a4c98c60e236078df0a98804cc0202 Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:25:35 +0300 Subject: [PATCH 44/56] feat(desktop): refine simple connection view and subscription onboarding --- ui-desktop/src/App.simple-onboarding.test.tsx | 138 +++++ ui-desktop/src/App.simple.test.tsx | 2 +- ui-desktop/src/App.tsx | 32 +- ui-desktop/src/App.tunconflict.test.tsx | 2 +- .../src/components/SimpleSetup.test.tsx | 17 +- ui-desktop/src/components/SimpleSetup.tsx | 54 +- ui-desktop/src/components/SimpleView.test.tsx | 46 +- ui-desktop/src/components/SimpleView.tsx | 346 +++++------ ui-desktop/src/components/TopBar.tsx | 4 +- ui-desktop/src/i18n/strings.ts | 66 ++- ui-desktop/src/lib/importError.ts | 1 + ui-desktop/src/styles/shell.css | 15 +- ui-desktop/src/styles/simple.css | 555 +++++------------- ui-desktop/src/styles/tokens.css | 3 + 14 files changed, 605 insertions(+), 676 deletions(-) create mode 100644 ui-desktop/src/App.simple-onboarding.test.tsx diff --git a/ui-desktop/src/App.simple-onboarding.test.tsx b/ui-desktop/src/App.simple-onboarding.test.tsx new file mode 100644 index 00000000..579512ff --- /dev/null +++ b/ui-desktop/src/App.simple-onboarding.test.tsx @@ -0,0 +1,138 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; + +import { App } from "./App"; +import { makeNode, makeProfile } from "./test/fixtures"; +import { renderWithProviders } from "./test/renderWithProviders"; + +const mocks = vi.hoisted(() => ({ + status: vi.fn(), + listProfiles: vi.fn(), + importSubscription: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + ping: vi.fn(), + checkNodes: vi.fn(), + checkCrashReport: vi.fn(), + onState: vi.fn(), + onTraffic: vi.fn(), + onLog: vi.fn(), + onProfilesChanged: vi.fn(), + onAttempts: vi.fn(), + onPickProgress: vi.fn(), + onTrayConnect: vi.fn(), + onTrayShow: vi.fn(), + onDeepLink: vi.fn(), + takeLaunchDeepLinks: vi.fn(), +})); + +vi.mock("./api", () => ({ + api: { + status: mocks.status, + listProfiles: mocks.listProfiles, + importSubscription: mocks.importSubscription, + connect: mocks.connect, + disconnect: mocks.disconnect, + ping: mocks.ping, + checkNodes: mocks.checkNodes, + checkCrashReport: mocks.checkCrashReport, + }, + onState: mocks.onState, + onTraffic: mocks.onTraffic, + onLog: mocks.onLog, + onProfilesChanged: mocks.onProfilesChanged, + onAttempts: mocks.onAttempts, + onPickProgress: mocks.onPickProgress, + onTrayConnect: mocks.onTrayConnect, + onTrayShow: mocks.onTrayShow, + onDeepLink: mocks.onDeepLink, + takeLaunchDeepLinks: mocks.takeLaunchDeepLinks, +})); + +vi.mock("./lib/updates", () => ({ + checkForUpdate: vi.fn().mockResolvedValue(null), + inAppUpdatesSupported: vi.fn().mockResolvedValue(true), + installUpdate: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(() => { + localStorage.clear(); + localStorage.setItem("tenebra.simpleMode", "1"); + for (const listener of [ + mocks.onState, + mocks.onTraffic, + mocks.onLog, + mocks.onProfilesChanged, + mocks.onAttempts, + mocks.onPickProgress, + mocks.onTrayConnect, + mocks.onTrayShow, + mocks.onDeepLink, + ]) { + // Register without delivering any event, including a profiles notification. + listener.mockResolvedValue(() => {}); + } + mocks.takeLaunchDeepLinks.mockResolvedValue([]); + mocks.status.mockResolvedValue({ state: "idle", crash_reports_asked: true }); + mocks.ping.mockResolvedValue([]); + mocks.checkCrashReport.mockResolvedValue(null); + mocks.connect.mockResolvedValue({ state: "connecting" }); + mocks.disconnect.mockResolvedValue({ state: "idle" }); +}); + +it("retries a failed list refresh without importing the same subscription twice", async () => { + const profile = makeProfile({ id: "saved", name: "Saved subscription", nodes: [makeNode({ name: "Saved server" })] }); + mocks.listProfiles.mockResolvedValueOnce([]).mockRejectedValueOnce(new Error("list timeout")).mockResolvedValue([profile]); + mocks.importSubscription.mockResolvedValue(profile); + renderWithProviders(); + const input = await screen.findByRole("textbox", { name: /subscription link/i }); + fireEvent.change(input, { target: { value: "https://example.invalid/retry" } }); + fireEvent.click(screen.getByRole("button", { name: "Import" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Your subscription was saved"); + fireEvent.click(screen.getByRole("button", { name: "Import" })); + expect(await screen.findByRole("option", { name: "Saved server" })).toBeInTheDocument(); + expect(mocks.importSubscription).toHaveBeenCalledTimes(1); + expect(mocks.listProfiles).toHaveBeenCalledTimes(3); +}); + +it("refreshes after the first inline import without a profile event and makes the imported server connectable", async () => { + const url = "https://subscription.example.invalid/demo"; + const node = makeNode({ id: "imported-node", name: "Imported Amsterdam" }); + const profile = makeProfile({ + id: "imported-profile", + name: "subscription.example.invalid", + url, + nodes: [node], + }); + + // Keep the real useTenebra hook. Its first list is empty; only a subsequent + // explicit refresh can expose the stored profile. The import reply itself + // neither changes the hook's state nor invokes an event listener. + mocks.listProfiles.mockResolvedValueOnce([]).mockResolvedValue([profile]); + mocks.importSubscription.mockResolvedValue(profile); + mocks.checkNodes.mockResolvedValue({ best: node.id, results: [] }); + + renderWithProviders(); + await waitFor(() => expect(mocks.onProfilesChanged).toHaveBeenCalledTimes(1)); + expect(mocks.listProfiles).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("button", { name: "Connect" })).toBeNull(); + + fireEvent.change(screen.getByRole("textbox", { name: /subscription link/i }), { + target: { value: url }, + }); + fireEvent.click(screen.getByRole("button", { name: "Import" })); + + await waitFor(() => expect(mocks.listProfiles).toHaveBeenCalledTimes(2)); + expect(mocks.importSubscription).toHaveBeenCalledWith(url, profile.name); + expect(await screen.findByRole("option", { name: node.name })).toBeInTheDocument(); + const connect = await screen.findByRole("button", { name: "Connect" }); + expect(connect).toBeEnabled(); + expect(screen.queryByRole("textbox", { name: /subscription link/i })).toBeNull(); + expect(mocks.connect).not.toHaveBeenCalled(); + + // Import grants no implicit permission to connect. The existing primary + // action must use the imported profile only after an explicit user click. + fireEvent.click(connect); + await waitFor(() => expect(mocks.connect).toHaveBeenCalledTimes(1)); + expect(mocks.connect.mock.calls[0].slice(0, 2)).toEqual([profile.id, node.id]); +}); diff --git a/ui-desktop/src/App.simple.test.tsx b/ui-desktop/src/App.simple.test.tsx index 18a92c19..4839bb68 100644 --- a/ui-desktop/src/App.simple.test.tsx +++ b/ui-desktop/src/App.simple.test.tsx @@ -163,7 +163,7 @@ describe("App simple mode", () => { ).not.toBeInTheDocument(); // The minimal picker and its automatic option are present. expect( - screen.getByRole("option", { name: "Automatic — fastest" }), + screen.getByRole("option", { name: "Automatic selection" }), ).toBeInTheDocument(); }); diff --git a/ui-desktop/src/App.tsx b/ui-desktop/src/App.tsx index 65865ff0..1d6031cf 100644 --- a/ui-desktop/src/App.tsx +++ b/ui-desktop/src/App.tsx @@ -30,7 +30,7 @@ import { useTenebra } from "./state/useTenebra"; import { useI18n } from "./i18n/I18nContext"; import { describeCoreError, isTunConflict } from "./i18n/strings"; import { pushToast } from "./lib/toast"; -import type { RoutingMode, State } from "./api"; +import type { Profile, RoutingMode, State } from "./api"; import { api, onDeepLink, @@ -116,6 +116,7 @@ export function App() { * instead of an untitled entry: they pasted a link, not a name, and asking * for one would be a step for nothing. */ + const pendingSimpleImport = useRef<{ url: string; profile: Profile } | null>(null); const handleSimpleSubscribe = useCallback(async (url: string) => { let name = "VPN"; try { @@ -124,8 +125,22 @@ export function App() { // Not a URL the parser likes — the core will reject it with a better // message than anything guessed here. } - await api.importSubscription(url, name); - }, []); + // Import does not emit a profiles event. Retain its result when the refresh + // fails, so retrying the same link does not create a second subscription. + const imported = pendingSimpleImport.current?.url === url + ? pendingSimpleImport.current.profile + : await api.importSubscription(url, name); + pendingSimpleImport.current = { url, profile: imported }; + try { + await tenebra.refreshProfiles(); + } catch { + throw new Error("subscription_refresh_pending"); + } + setSelectedProfileId(imported.id); + setSelectedNodeId(""); + pendingSimpleImport.current = null; + pushToast(t.toast.profileImported.replace("{name}", imported.name)); + }, [tenebra.refreshProfiles, t]); // Simple mode: the Settings toggle writes `tenebra.simpleMode`; we mirror it here // and swap the whole shell for SimpleView when it's on. A cross-window write @@ -701,7 +716,11 @@ export function App() { // eclipse easter egg still rides along; the console/toast layers do too. return (
- {!simpleMode && } + {!simpleMode && { + localStorage.setItem(SIMPLE_MODE_KEY, "true"); + window.dispatchEvent(new CustomEvent("tenebra:simple-mode")); + }} />} tenebra.setKillSwitch(true)} onDisable={() => tenebra.setKillSwitch(false)} @@ -771,6 +790,9 @@ export function App() { setOverlay("profiles")} + onSettings={() => setOverlay("settings")} reportNudge={nudge} /> ) : (<> diff --git a/ui-desktop/src/App.tunconflict.test.tsx b/ui-desktop/src/App.tunconflict.test.tsx index 1017106e..2188b8ed 100644 --- a/ui-desktop/src/App.tunconflict.test.tsx +++ b/ui-desktop/src/App.tunconflict.test.tsx @@ -186,7 +186,7 @@ describe("App tun-conflict override", () => { fireEvent.click(primaryButton()); await screen.findByRole("alertdialog"); // Mid-question the button is held down — that part was never the bug. - expect(primaryButton()).toBeDisabled(); + expect(screen.getByRole("button", { name: en.simple.preparing })).toBeDisabled(); fireEvent.click( screen.getByRole("button", { name: en.daemon.tunConflictOverrideCancel }), diff --git a/ui-desktop/src/components/SimpleSetup.test.tsx b/ui-desktop/src/components/SimpleSetup.test.tsx index 45f81097..cd779ad5 100644 --- a/ui-desktop/src/components/SimpleSetup.test.tsx +++ b/ui-desktop/src/components/SimpleSetup.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen } from "@testing-library/react"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; import { expect, it, vi } from "vitest"; import { SimpleSetup } from "./SimpleSetup"; import { renderWithProviders } from "../test/renderWithProviders"; @@ -12,3 +12,18 @@ it("explains a subscription failure without exposing its private URL", async () expect(alert).toHaveTextContent("Не удалось скачать подписку"); expect(alert).not.toHaveTextContent("private-token"); }); + +it("allows another import after the last subscription is removed", async () => { + const subscribe = vi.fn().mockResolvedValue(undefined); + const view = renderWithProviders(); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "https://example.invalid/first" } }); + fireEvent.click(screen.getByRole("button", { name: "Import" })); + await waitFor(() => expect(screen.getByRole("textbox")).toHaveValue("")); + view.rerender(); + expect(screen.queryByRole("textbox")).toBeNull(); + view.rerender(); + expect(screen.getByRole("textbox")).toBeEnabled(); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "https://example.invalid/second" } }); + fireEvent.click(screen.getByRole("button", { name: "Import" })); + await waitFor(() => expect(subscribe).toHaveBeenCalledTimes(2)); +}); diff --git a/ui-desktop/src/components/SimpleSetup.tsx b/ui-desktop/src/components/SimpleSetup.tsx index 7af58f6b..378d7cec 100644 --- a/ui-desktop/src/components/SimpleSetup.tsx +++ b/ui-desktop/src/components/SimpleSetup.tsx @@ -1,5 +1,5 @@ import { importErrorMessage } from "../lib/importError"; -import { useState } from "react"; +import { useId, useRef, useState } from "react"; import { useI18n } from "../i18n/I18nContext"; @@ -20,73 +20,79 @@ interface SimpleSetupProps { * already does. Keeping it as a folded-away "optional" step was no better: it * still put a decision in front of someone who has none to make. * - * Everything here disappears once it is done. A setup step that stays visible - * after it is satisfied is clutter, and clutter is what this screen exists to - * avoid — the finished state is a status word and one control. + * Import stays local to this form; an error never displays a private URL. */ export function SimpleSetup({ hasProfile, onSubscribe }: SimpleSetupProps) { const { t } = useI18n(); const [url, setUrl] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const pending = useRef(false); + const fieldId = useId(); // The link is the whole of the setup. A missing bundle is not a missing step: // the first connect installs one. if (hasProfile) return null; const run = async (fn: () => Promise) => { - if (busy) return; + if (pending.current) return; + pending.current = true; setBusy(true); setError(null); try { await fn(); + setUrl(""); } catch (e) { setError(importErrorMessage(e, t)); } finally { setBusy(false); + pending.current = false; } }; return ( -
-
-
- {t.simple.setupLink} +
+
+

{t.simple.welcome}

+

{t.simple.welcomeHint}

+
+
{ + event.preventDefault(); + if (url.trim()) void run(() => onSubscribe(url.trim())); + }} aria-busy={busy}> +
setUrl(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && url.trim()) { - e.preventDefault(); - void run(() => onSubscribe(url.trim())); - } - }} />
-
-
+

{t.simple.linkHelp}

+ {error && ( -

+

)} -
+ ); } diff --git a/ui-desktop/src/components/SimpleView.test.tsx b/ui-desktop/src/components/SimpleView.test.tsx index ea45880b..5ce36999 100644 --- a/ui-desktop/src/components/SimpleView.test.tsx +++ b/ui-desktop/src/components/SimpleView.test.tsx @@ -141,7 +141,49 @@ describe("SimpleView", () => { it("disables the button while a primary action is in flight", () => { setup({ busy: true }); + expect(screen.getByRole("button", { name: "Preparing connection…" })).toBeDisabled(); + }); + + it("names the server check and locks selections while it runs", () => { + setup({ checkingServers: true, profiles: [makeProfile({ id: "p1", nodes }), makeProfile({ id: "p2", nodes })] }); + expect(screen.getByRole("heading", { name: "Checking servers…" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Checking servers…" })).toBeDisabled(); + for (const picker of screen.getAllByRole("combobox")) expect(picker).toBeDisabled(); + }); + + it("keeps reconnecting selections locked even between API responses", () => { + setup({ phase: "health_reconnecting", busy: false }); + expect(screen.getByRole("combobox", { name: "Server" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "ABORT" })).toBeEnabled(); + }); + + it("gives an empty subscription a recovery path instead of a dead Connect", () => { + const manage = vi.fn(); + setup({ nodes: [], onManageProfiles: manage }); + expect(screen.getByText(/This subscription has no servers/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Connect" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Manage subscriptions" })); + expect(manage).toHaveBeenCalledOnce(); + }); + + it("does not ask for a new subscription while the service is still loading", () => { + setup({ ready: false, profiles: [], nodes: [] }); + expect(screen.getByRole("heading", { name: "Starting Tenebra…" })).toBeInTheDocument(); + expect(screen.queryByRole("textbox")).toBeNull(); + }); + + it("suppresses stale connected reassurance after losing the service", () => { + setup({ phase: "connected", coreUnreachable: true, nodeName: "AMS-01" }); + expect(screen.queryByText("Tunnel connected · AMS-01")).toBeNull(); + expect(screen.getByRole("heading", { name: "Service unavailable" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Disconnect" })).toBeEnabled(); + }); + + it("shows a confirmed traffic block instead of an ordinary idle state", () => { + setup({ protectionBlocked: true }); + expect(screen.getByRole("heading", { name: "Internet traffic blocked" })).toBeInTheDocument(); + expect(screen.queryByText("You're not connected")).toBeNull(); + expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled(); }); it("pins a node when one is picked", () => { @@ -165,7 +207,7 @@ describe("SimpleView", () => { it("omits the profile picker for a single subscription", () => { setup(); expect( - screen.queryByRole("combobox", { name: /profile/i }), + screen.queryByRole("combobox", { name: /subscription/i }), ).not.toBeInTheDocument(); }); @@ -176,7 +218,7 @@ describe("SimpleView", () => { makeProfile({ id: "p2", name: "Globex", nodes }), ], }); - const picker = screen.getByRole("combobox", { name: /profile/i }); + const picker = screen.getByRole("combobox", { name: /subscription/i }); fireEvent.change(picker, { target: { value: "p2" } }); expect(props.onSelectProfile).toHaveBeenCalledWith("p2"); }); diff --git a/ui-desktop/src/components/SimpleView.tsx b/ui-desktop/src/components/SimpleView.tsx index a83f3c8c..73ec7b8b 100644 --- a/ui-desktop/src/components/SimpleView.tsx +++ b/ui-desktop/src/components/SimpleView.tsx @@ -1,255 +1,181 @@ import type { ReactNode } from "react"; -import type { - ConnectionState, - Node, - Profile, - ServiceCheck as ServiceCheckResult, -} from "../api"; +import type { ConnectionState, Node, Profile, ServiceCheck } from "../api"; import { useI18n } from "../i18n/I18nContext"; +import { formatExpiry, formatTrafficUsage } from "../lib/format"; import { ServiceChecks } from "./ServiceChecks"; import { SimpleSetup } from "./SimpleSetup"; interface SimpleViewProps { phase: ConnectionState; - /** True while a primary action is mid-flight; disables the button. */ busy: boolean; - /** Connect / disconnect / abort, decided by phase — the same handler the shell uses. */ + checkingServers?: boolean; + ready?: boolean; + protectionBlocked?: boolean; onPrimary: () => void; - /** Display name of the active (connected) or target node; "" when none. */ nodeName: string; profiles: Profile[]; selectedProfileId: string | null; onSelectProfile: (id: string) => void; - /** Nodes of the selected profile. */ nodes: Node[]; - /** Pinned node id, or "" for automatic (let the core pick the fastest). */ selectedNodeId: string; onSelectNode: (id: string) => void; onSelectAuto: () => void; - /** True when the core reports a bundle on disk (or a filter already running). */ bypassInstalled: boolean; - /** True when the core reports the packet filter as carrying traffic. */ bypassOn: boolean; - /** The strategy the core is running; "" when it does not name one. */ bypassStrategy: string; - /** - * True while the core cannot be reached at all. Nothing else on this screen is - * backed by anything then, and saying so beats a calm "disconnected" over an - * app that has no core behind it. - */ coreUnreachable: boolean; - /** Import a subscription from a pasted link. */ onSubscribe: (url: string) => Promise; - /** What the post-connect checks measured; empty before one has run. */ - serviceChecks: ServiceCheckResult[]; - /** True while those checks are in flight. */ + serviceChecks: ServiceCheck[]; serviceChecking: boolean; - /** Open the report-a-problem flow. */ onReportProblem: () => void; - /** - * The app's own offer to report, when the checks below have twice said video - * isn't getting through. Built by the shell so both views raise the same one. - */ + onManageProfiles?: () => void; + onSettings?: () => void; reportNudge?: ReactNode; } -/** - * The stripped-down connection screen for people who want one button, not a - * control panel. A single large connect / disconnect control, the current status - * in plain words, and a minimal server picker — everything advanced (routing, - * kill switch, diagnostics, the node table, logs) is left to the full shell. - * Reads the same connection state and calls the same actions as the shell, so the - * two views never disagree. - */ +/** A daily connection screen using the shell's real state and shared actions. */ export function SimpleView({ - phase, - busy, - onPrimary, - nodeName, - profiles, - selectedProfileId, - onSelectProfile, - nodes, - selectedNodeId, - onSelectNode, - onSelectAuto, - bypassInstalled, - bypassOn, - bypassStrategy, - coreUnreachable, - onSubscribe, - serviceChecks, - serviceChecking, - onReportProblem, - reportNudge = null, + phase, busy, checkingServers = false, ready = true, protectionBlocked = false, onPrimary, nodeName, + profiles, selectedProfileId, onSelectProfile, nodes, selectedNodeId, + onSelectNode, onSelectAuto, bypassInstalled, bypassOn, bypassStrategy, + coreUnreachable, onSubscribe, serviceChecks, serviceChecking, + onReportProblem, onManageProfiles, onSettings, reportNudge = null, }: SimpleViewProps) { - const { t } = useI18n(); + const { t, lang } = useI18n(); + const connected = phase === "connected"; + const pending = phase === "connecting" || phase === "health_reconnecting"; + const hasProfile = profiles.length > 0; + const showConnection = hasProfile || connected || pending; + const unavailable = !ready || coreUnreachable; + const selectionLocked = unavailable || busy || pending || checkingServers; + const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId); + const usage = selectedProfile + ? formatTrafficUsage(selectedProfile.trafficUsed, selectedProfile.trafficTotal) + : null; + const expiry = selectedProfile + ? formatExpiry(selectedProfile.expiresAt, lang, { + in: t.profiles.expiresIn, today: t.profiles.expiresToday, + tomorrow: t.profiles.expiresTomorrow, expired: t.profiles.expired, + }) : null; - // The only way out of simple mode from here: it hides Settings (and its own - // toggle), so a stranded user would otherwise be stuck. Flip the shared - // `tenebra.simpleMode` flag off and nudge the app shell exactly the way the - // Settings toggle does — a `storage` event — plus the same-document custom - // event App also listens for. App re-reads the flag and restores the full - // shell. The key and "false" encoding are the contract with App/Settings; - // keep them verbatim. function exitSimpleMode() { localStorage.setItem("tenebra.simpleMode", "false"); - window.dispatchEvent( - new StorageEvent("storage", { - key: "tenebra.simpleMode", - newValue: "false", - }), - ); + window.dispatchEvent(new StorageEvent("storage", { + key: "tenebra.simpleMode", newValue: "false", + })); window.dispatchEvent(new CustomEvent("tenebra:simple-mode")); } - const connected = phase === "connected"; - const pending = phase === "connecting" || phase === "health_reconnecting"; - const hasProfile = profiles.length > 0; - - const buttonLabel = connected - ? t.home.disconnect - : pending - ? t.conn.abort - : t.home.connect; - - // Calm, plain-language status line. Connecting / reconnecting say nothing extra — - // the status word already carries it. - const reassurance = - phase === "connected" - ? nodeName - ? `${t.simple.statusOn} · ${nodeName}` - : t.simple.statusOn - : phase === "idle" || phase === "error" - ? t.simple.statusOff - : ""; + const statusLabel = coreUnreachable ? t.simple.serviceUnavailable + : !ready ? t.simple.serviceStarting + : protectionBlocked && !pending && !checkingServers ? t.simple.trafficBlocked + : checkingServers ? t.simple.checkingServers + : busy && !connected && !pending ? t.simple.preparing : t.state[phase]; + const buttonLabel = connected ? t.home.disconnect + : pending ? t.conn.abort + : checkingServers ? t.simple.checkingServers + : busy ? t.simple.preparing : t.home.connect; + const reassurance = connected && !unavailable + ? nodeName ? `${t.simple.statusOn} · ${nodeName}` : t.simple.statusOn + : coreUnreachable ? t.simple.serviceHelp + : protectionBlocked && ready ? t.simple.blockedHint + : phase === "idle" && !busy && ready ? t.simple.statusOff : ""; return (
- - - {/* A core that never answered leaves this screen drawn over nothing: no - profiles, no real status, every button doomed. The calm one-word status - is exactly what must not be shown on its own here. */} - {coreUnreachable && ( -

- ⚠ {t.daemon.unreachable} -

- )} - -
-
-
- -
- {hasProfile ? ( - <> - {profiles.length > 1 && ( - + ) :

{selectedProfile?.name ?? profiles[0]?.name}

} + {(usage || expiry) &&
+ {usage && {usage}}{expiry && {expiry}} +
} + + - )} - - - - ) : null} -
- - - - {/* This screen has no Settings and no log console, so before this there - was nothing here to complain with at all — a user whose video stopped - loading could only close the app. */} -
- - -
+

+ {nodes.length === 0 ? t.simple.noNodes : connected ? t.simple.changeHint : t.simple.autoHint} +

+ + {!unavailable && bypassInstalled &&
+ {t.simple.details} +

+

+ {bypassOn && bypassStrategy &&

{bypassStrategy}

} +
} + +
+ ) : unavailable ? ( +
+

{statusLabel}

+

{t.simple.serviceHelp}

+
+ ) : } + + +
+ {onSettings && } + +
); } diff --git a/ui-desktop/src/components/TopBar.tsx b/ui-desktop/src/components/TopBar.tsx index c2d16e5c..cb0c5128 100644 --- a/ui-desktop/src/components/TopBar.tsx +++ b/ui-desktop/src/components/TopBar.tsx @@ -26,6 +26,7 @@ interface TopBarProps { activeProfile: Profile | null; /** Fired when the wordmark is tapped {@link ECLIPSE_TAPS} times — plays the eclipse. */ onEclipse: () => void; + onSimpleMode?: () => void; } /** @@ -34,7 +35,7 @@ interface TopBarProps { * user-info (Tenebra has no account/plan/device model, so the meta reflects the * subscription itself). Falls back to a quiet "no subscription". */ -export function TopBar({ activeProfile, onEclipse }: TopBarProps) { +export function TopBar({ activeProfile, onEclipse, onSimpleMode }: TopBarProps) { const { t, lang } = useI18n(); // Two hidden tap counters, one per egg. Count and reset timer ride refs (no @@ -123,6 +124,7 @@ export function TopBar({ activeProfile, onEclipse }: TopBarProps) {
+ {onSimpleMode && } {activeProfile ? ( <> {activeProfile.name} diff --git a/ui-desktop/src/i18n/strings.ts b/ui-desktop/src/i18n/strings.ts index 486b3935..db6bcc29 100644 --- a/ui-desktop/src/i18n/strings.ts +++ b/ui-desktop/src/i18n/strings.ts @@ -379,6 +379,26 @@ export interface Strings { * words are reused from `home`/`state`. */ simple: { + mode: string; + welcome: string; + welcomeHint: string; + linkHelp: string; + importing: string; + refreshFailed: string; + subscription: string; + manage: string; + autoHint: string; + changeHint: string; + noNodes: string; + checkingServers: string; + preparing: string; + serviceStarting: string; + serviceUnavailable: string; + serviceHelp: string; + trafficBlocked: string; + blockedHint: string; + details: string; + checksTitle: string; /** The only setup step: paste the subscription link. */ setupLink: string; setupLinkPlaceholder: string; @@ -1086,6 +1106,26 @@ const en: Strings = { sessionTraffic: "This session", }, simple: { + mode: "Simple view", + welcome: "Start with your subscription", + welcomeHint: "Add your subscription, then choose a server and connect.", + linkHelp: "Use the subscription link from your VPN provider. Tenebra does not issue subscriptions.", + importing: "Adding subscription…", + refreshFailed: "Your subscription was saved, but its server list could not be loaded. Try again to refresh the list.", + subscription: "Subscription", + manage: "Manage subscriptions", + autoHint: "Tenebra will check the servers when you connect. You can also choose one yourself.", + changeHint: "Choosing another server changes the current connection. A different subscription applies to your next connection.", + noNodes: "This subscription has no servers. Open subscriptions to refresh it or add another one.", + checkingServers: "Checking servers…", + preparing: "Preparing connection…", + serviceStarting: "Starting Tenebra…", + serviceUnavailable: "Service unavailable", + serviceHelp: "Wait for the service to reconnect. If this continues, open Settings or report the problem.", + trafficBlocked: "Internet traffic blocked", + blockedHint: "Connect to the VPN, or explicitly release the block using the action above.", + details: "Connection details", + checksTitle: "Connection checks", setupLink: "Paste your subscription link", setupLinkPlaceholder: "https://…", bypassOn: "bypass on", @@ -1093,7 +1133,7 @@ const en: Strings = { statusOn: "Tunnel connected", statusOff: "You're not connected", server: "Server", - auto: "Automatic — fastest", + auto: "Automatic selection", noProfile: "Import a subscription to get started.", advanced: "Advanced view", }, @@ -1669,15 +1709,35 @@ const ru: Strings = { sessionTraffic: "За сессию", }, simple: { + mode: "Простой режим", + welcome: "Начнём с подписки", + welcomeHint: "Добавьте подписку, затем выберите сервер и подключитесь.", + linkHelp: "Ссылка находится у вашего VPN-провайдера. Tenebra не выдаёт подписки.", + importing: "Добавляем подписку…", + refreshFailed: "Подписка сохранена, но список серверов не загрузился. Повторите действие, чтобы обновить список.", + subscription: "Подписка", + manage: "Управление подписками", + autoHint: "Tenebra проверит серверы при подключении. При желании можно выбрать сервер вручную.", + changeHint: "Другой сервер применяется к текущему соединению. Другая подписка — при следующем подключении.", + noNodes: "В подписке нет серверов. Откройте подписки, чтобы обновить её или добавить другую.", + checkingServers: "Проверяем серверы…", + preparing: "Готовим подключение…", + serviceStarting: "Запускаем Tenebra…", + serviceUnavailable: "Служба недоступна", + serviceHelp: "Дождитесь восстановления связи со службой. Если это не помогает, откройте настройки или сообщите о проблеме.", + trafficBlocked: "Интернет заблокирован", + blockedHint: "Подключитесь к VPN или снимите блокировку кнопкой выше.", + details: "Сведения о подключении", + checksTitle: "Проверка подключения", /** The only setup step: paste the subscription link. */ - setupLink: "Вставь ссылку на подписку", + setupLink: "Ссылка на подписку", setupLinkPlaceholder: "https://…", bypassOn: "обход включён", bypassOff: "обход выключен", statusOn: "Туннель подключён", statusOff: "Вы не подключены", server: "Сервер", - auto: "Автоматически — быстрее всего", + auto: "Автоматический выбор", noProfile: "Импортируйте подписку, чтобы начать.", advanced: "Расширенный режим", }, diff --git a/ui-desktop/src/lib/importError.ts b/ui-desktop/src/lib/importError.ts index 6a757c8d..e629ae9d 100644 --- a/ui-desktop/src/lib/importError.ts +++ b/ui-desktop/src/lib/importError.ts @@ -12,6 +12,7 @@ export function importErrorMessage(err: unknown, t: Strings): string { ? (err as { message: unknown }).message : err; const msg = String(raw ?? "").toLowerCase(); + if (msg === "subscription_refresh_pending") return t.simple.refreshFailed; // Couldn't reach the host at all — DNS, refused, timeout, TLS. This is the // common case behind a subscription domain that a provider is blocking. diff --git a/ui-desktop/src/styles/shell.css b/ui-desktop/src/styles/shell.css index 349b83d2..0b009d36 100644 --- a/ui-desktop/src/styles/shell.css +++ b/ui-desktop/src/styles/shell.css @@ -390,20 +390,13 @@ a modal blocking it. It removes itself once the subscription is in, so a returning user never sees it. */ .app > .setup { - width: auto; - margin: 0 var(--sp-5) var(--sp-3); - padding: var(--sp-3) var(--sp-4); - background: var(--surface-2); - border: var(--bw) solid var(--line); - border-radius: var(--radius); + width: min(560px, calc(100% - 48px)); + margin: auto; + padding: 32px 0; } /* Keep first launch on one import task; the footer still exposes help. */ .app:not(.app--simple) > .setup { - flex: 1; - display: grid; - align-content: center; - width: min(720px, 100%); - margin-inline: auto; + flex: none; } .connection-error { flex: none; diff --git a/ui-desktop/src/styles/simple.css b/ui-desktop/src/styles/simple.css index 3114bc16..9d636c17 100644 --- a/ui-desktop/src/styles/simple.css +++ b/ui-desktop/src/styles/simple.css @@ -1,434 +1,153 @@ -/* ── simple mode ── - The one-button screen that replaces the full shell for non-technical users. A - centred column with more air than the shell: a quiet wordmark, the big status - word (reusing the shell's live-dot vocabulary and its blink-pending / breathe - keyframes), one large primary control, and a minimal server picker. Same tokens, - same accent discipline — just far less on screen. */ - +/* Daily-use view: a connection control beside its destination. The wordmark, + orange action and measured status retain Tenebra's identity; readable native + lettering replaces the technical shell's tracked mono labels. */ .simple { flex: 1; min-height: 0; display: flex; flex-direction: column; - align-items: center; - justify-content: center; - gap: var(--sp-8); - padding: var(--sp-8) var(--sp-6); - overflow-y: auto; -} - -.simple-brand { - font-family: var(--display); - font-weight: 700; - font-size: var(--fs-brand); - letter-spacing: 0.01em; - display: flex; - align-items: center; - gap: 7px; - color: var(--text); -} -.simple-brand .bracket { - color: var(--text-dim); - font-weight: 500; -} - -.simple-core { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--sp-5); - text-align: center; -} - -.simple-word { - font-family: var(--display); - font-size: var(--fs-word); - font-weight: 700; - letter-spacing: var(--track-word); - line-height: 1; - text-transform: uppercase; - display: flex; - align-items: center; - gap: 14px; - color: var(--word-off); - /* Same hero step as the shell's status word: one product, one pace. */ - transition: color var(--d-hero) var(--e-move); + font-family: var(--ui); + font-size: 15px; + line-height: 1.5; } -.simple-word .simple-ind { - width: 16px; - height: 16px; +.app--simple::before { display: none; } +.simple-header { flex: none; - display: inline-block; - transition: - background-color var(--d-hero) var(--e-move), - border-color var(--d-hero) var(--e-move); -} - -.simple-word.idle { - color: var(--word-off); -} -.simple-word.idle .simple-ind { - border: var(--bw-emphasis) solid var(--text-dim-2); -} - -.simple-word.connecting, -.simple-word.health_reconnecting { - color: var(--word-pending); -} -.simple-word.connecting .simple-ind, -.simple-word.health_reconnecting .simple-ind { - background: var(--word-pending); - animation: blink-pending 1s steps(2) infinite; -} - -.simple-word.connected { - color: var(--word-on); -} -.simple-word.connected .simple-ind { - background: var(--word-on); - animation: breathe 2.2s ease-in-out infinite; -} - -.simple-word.error { - color: var(--signal); -} -.simple-word.error .simple-ind { - background: var(--signal); -} - -.simple-sub { - font-family: var(--mono); - font-size: var(--fs-body); - letter-spacing: var(--track-body); - color: var(--text-dim); -} - -/* ── primary control ── */ -.simple-btn { - font-family: var(--mono); - font-size: var(--fs-sku); /* 22px — larger than the shell's connect CTA */ - letter-spacing: 0.14em; - text-transform: uppercase; - font-weight: 600; - padding: 20px 56px; - min-width: 300px; - max-width: 100%; - border: var(--bw-emphasis) solid var(--signal); - color: var(--signal); - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--sp-2); - transition: - background-color var(--d-base) var(--e-out), - color var(--d-base) var(--e-out), - border-color var(--d-base) var(--e-out), - opacity var(--d-base) var(--e-out), - transform var(--d-micro) var(--e-out); -} -/* Idle / error: fill on hover. Scoped away from the on/pending variants. */ -.simple-btn:not(.on):not(.pending):hover { - background: var(--signal); - color: var(--on-signal); -} -.simple-btn:active:not(:disabled) { - transform: scale(0.96); -} -.simple-btn:disabled { - opacity: 0.5; - cursor: default; -} -.simple-btn.on { - border-color: var(--text-dim-2); - color: var(--text-dim); -} -.simple-btn.on:hover { - border-color: var(--signal); - color: var(--signal); -} -.simple-btn.pending { - border-color: var(--warn-soft); - color: var(--warn-soft); -} -.simple-btn.pending:hover { - border-color: var(--signal); - color: var(--signal); -} - -/* ── server picker ── */ -.simple-pick { display: flex; - flex-direction: column; - gap: var(--sp-4); - width: min(360px, 100%); -} -.simple-field { + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 18px 28px; + border-bottom: 1px solid var(--line); +} +.simple-brand { display: flex; align-items: center; gap: 7px; font: 700 21px var(--display); } +.simple-brand .bracket { color: var(--signal); font-weight: 400; } +.simple-mode { margin-left: 12px; font: 13px var(--ui); color: var(--text-dim); } +.simple-content { + flex: 1; + min-height: 0; display: flex; flex-direction: column; - gap: var(--sp-2); -} -.simple-field-lab { - font-size: var(--fs-label); - letter-spacing: var(--track-cap); - text-transform: uppercase; - color: var(--text-dim); - text-align: center; -} -.simple-select { - font-family: var(--mono); - font-size: var(--fs-body); - letter-spacing: var(--track-body); - color: var(--text); - background: var(--bg); - border: var(--bw) solid var(--line-2); - padding: 12px 14px; - text-align: center; - text-align-last: center; - cursor: pointer; - transition: - border-color var(--t-fast), - color var(--t-fast); -} -.simple-select:hover { - border-color: var(--text-dim); -} -.simple-select:disabled { - color: var(--text-dim); - cursor: default; -} -.simple-empty { - font-family: var(--mono); - font-size: var(--fs-body); - letter-spacing: var(--track-body); - color: var(--text-dim); - text-align: center; -} - -/* ── escape hatch ── - A quiet way back to the full shell. Simple mode hides Settings, so this is the - only exit; kept deliberately understated so it never competes with the primary - control, yet stays reachable — and focus-visible for keyboard users. */ -.simple-advanced { - font-family: var(--mono); - font-size: var(--fs-label); - letter-spacing: var(--track-cap); - text-transform: uppercase; - color: var(--text-dim-2); - padding: var(--sp-2) var(--sp-3); - transition: - color var(--t-fast), - transform var(--d-micro) var(--e-out); -} -.simple-advanced:active { - transform: scale(0.96); -} -.simple-advanced:hover { - color: var(--text); -} - -/* No core behind the window. It sits above the status word on purpose: the word - below it says "disconnected", and read on its own that is a calm lie. */ -.simple-core-down { - max-width: min(460px, 100%); - margin: 0 0 var(--sp-3); - color: var(--signal); - font-family: var(--mono); - font-size: var(--fs-meta); - text-align: center; -} - -/* ── first-run setup ───────────────────────────────────────────────────── */ - -/* - * The one thing a new user supplies, on the same screen as the button. It - * disappears the moment it is satisfied — a finished step left on screen is - * clutter, and clutter is exactly what this view exists to avoid. - */ - -.setup { - display: grid; - gap: var(--sp-4); - width: min(460px, 100%); - margin-top: var(--sp-5); - animation: ui-rise var(--d-slow) var(--e-out) backwards; -} - -.setup .setup-step { - animation: ui-rise var(--d-slow) var(--e-out) backwards; -} - -.setup-step { - display: grid; - gap: var(--sp-3); - align-items: start; -} - -.setup-body { - display: grid; - gap: var(--sp-2); - min-width: 0; -} - -.setup-title { - color: var(--text); - font-family: var(--mono); - font-size: var(--fs-meta); - letter-spacing: var(--track-label); -} - -.setup-row { - display: flex; - gap: var(--sp-2); -} - -.setup-input { - flex: 1; - min-width: 0; - padding: var(--sp-2) var(--sp-3); - color: var(--text); - background: var(--surface-2); - border: var(--bw) solid var(--line-2); - border-radius: var(--radius); - font-family: var(--mono); - font-size: var(--fs-meta); - transition: border-color var(--t-base); -} - -.setup-input:focus { - border-color: var(--signal); - outline: none; -} - -.setup-go { - padding: 0 var(--sp-4); - color: var(--on-signal); - background: var(--signal); - border: 0; - border-radius: var(--radius); - font-family: var(--mono); - cursor: pointer; - transition: - opacity var(--t-base), - transform var(--d-micro) var(--e-out); -} - -.setup-go:active:not(:disabled) { - transform: scale(0.96); -} - -.setup-go:disabled { - opacity: 0.35; - cursor: default; + gap: 22px; + padding: 38px 32px; + overflow-y: auto; } - -/* The post-connect checks. Three lines, each with the latency it measured: for - voice and games the difference between "works" and "works at 240ms" is the - entire reason the routing is split, so a bare tick would drop the fact worth - showing. */ -.svc-checks { +.simple-layout { display: grid; - gap: var(--sp-1); - margin: var(--sp-4) 0 0; - padding: 0; - list-style: none; - font-family: var(--mono); - font-size: var(--fs-meta); -} - -.svc-check { + grid-template-columns: minmax(0, 1fr) minmax(0, 360px); + align-items: center; + width: min(900px, 100%); + margin: auto; + gap: 48px; +} +.simple-core { display: flex; flex-direction: column; align-items: center; gap: 12px; text-align: center; min-width: 0; } +.simple-word { font: 600 clamp(28px, 3.5vw, 42px)/1.15 var(--ui); letter-spacing: -0.035em; overflow-wrap: anywhere; } +.simple-word.connected { color: var(--word-on); } +.simple-word.error, .simple-word.unavailable { color: var(--signal); } +.simple-word.connecting, .simple-word.health_reconnecting { color: var(--word-pending); } +.simple-sub { color: var(--text-dim); max-width: 38ch; overflow-wrap: anywhere; } + +/* One button, including its visible verb. No perpetual idle animation. */ +.simple-btn { display: flex; flex-direction: column; align-items: center; gap: 25px; margin: 30px 0 14px; padding: 10px 26px; border-radius: 14px; max-width: 100%; } +.simple-power { + position: relative; display: grid; - grid-template-columns: 1.2em 1fr auto; - gap: var(--sp-2); - align-items: baseline; - color: var(--text-dim); - /* Three answers to the question the user actually pressed connect for. They - arrive together, so they cascade — on the chunk beat, because each is meant - to be read, not skimmed as a row of a table. */ - animation: ui-rise var(--d-slow) var(--e-out) backwards; -} -.svc-check:nth-child(2) { - animation-delay: var(--stagger-chunk); -} -.svc-check:nth-child(3) { - animation-delay: calc(var(--stagger-chunk) * 2); -} - -.svc-check.is-ok .svc-mark { - color: var(--signal); -} - -.svc-check.is-bad { - color: var(--text); -} - -.svc-check.is-bad .svc-mark, -.svc-check.is-bad .svc-rtt { - color: var(--danger, #e5484d); -} - -.svc-rtt { - font-variant-numeric: tabular-nums; -} - -.setup-error { - margin: 0; + place-items: center; + width: 132px; + height: 132px; + border-radius: 50%; + background: var(--surface); + border: 1px solid var(--signal); color: var(--signal); - font-family: var(--mono); - font-size: var(--fs-meta); + box-shadow: inset 0 0 0 8px var(--bg); + transition: background-color var(--t-base), border-color var(--t-base), transform var(--t-base); +} +.simple-power::before { content: ""; position: absolute; inset: -12px; border-radius: 50%; border: 1px solid var(--line-2); } +.simple-button-label { color: var(--signal); font-size: 18px; font-weight: 600; line-height: 1.4; } +.simple-btn:hover:not(:disabled) .simple-power { background: var(--signal-faint); transform: translateY(-2px); } +.simple-btn:active:not(:disabled) .simple-power { transform: scale(0.97); } +.simple-btn.on .simple-power { color: var(--good); border-color: var(--good); } +.simple-btn.on .simple-button-label { color: var(--text-dim); } +.simple-btn.on:hover:not(:disabled) .simple-power { background: var(--surface-2); } +.simple-btn.pending .simple-power { color: var(--warn-soft); border-color: var(--warn-soft); } +.simple-btn.pending .simple-power::before { border-top-color: var(--warn-soft); animation: simple-working 1.8s linear infinite; } +.simple-btn:disabled { cursor: default; } +.simple-btn:disabled:not(.pending) .simple-power { color: var(--text-dim); border-color: var(--line-2); } +.simple-btn:disabled .simple-button-label { color: var(--text-dim); } +@keyframes simple-working { to { transform: rotate(360deg); } } + +.simple-pick { min-width: 0; border-left: 1px solid var(--line-2); padding: 8px 0 8px 36px; } +.simple-section-head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 4px 16px; margin-bottom: 14px; } +.simple-section-head h2, .simple-checks h2 { font-size: 14px; font-weight: 500; color: var(--text-dim); } +.simple-profile-name { font-size: 23px; font-weight: 600; line-height: 1.3; overflow-wrap: anywhere; } +.simple-subscription-meta { display: flex; flex-direction: column; gap: 3px; margin-top: 10px; color: var(--text-dim); font-size: 13px; } +.simple-field { display: flex; flex-direction: column; gap: 8px; } +.simple-server-field { margin-top: 28px; } +.simple-field-lab { font-size: 14px; font-weight: 500; color: var(--text); } +.simple-select { width: 100%; min-width: 0; min-height: 46px; padding: 10px 12px; border: 1px solid var(--line-2); border-radius: 8px; background: var(--surface); color: var(--text); cursor: pointer; font: 15px var(--ui); text-overflow: ellipsis; } +.simple-select:hover:not(:disabled) { border-color: var(--text-dim); } +.simple-select:disabled { color: var(--text-dim); cursor: default; } +.simple-hint { margin-top: 12px; font-size: 14px; line-height: 1.6; color: var(--text-dim); } +.simple-hint.is-empty { color: var(--text); padding-left: 12px; border-left: 2px solid var(--signal); } +.simple-details { margin-top: 24px; border-top: 1px solid var(--line); padding-top: 14px; color: var(--text-dim); font-size: 13px; } +.simple-details summary { cursor: pointer; padding-block: 5px; } +.simple-bypass { display: flex; align-items: center; gap: 8px; margin-top: 12px; color: var(--good); } +.simple-bypass.is-off { color: var(--text-dim); } +.simple-bypass-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: none; } +.simple-strategy { margin-top: 6px; font: 12px/1.6 var(--mono); overflow-wrap: anywhere; user-select: text; } +.simple-checks { width: min(300px, 100%); text-align: left; border-top: 1px solid var(--line); padding-top: 16px; } +.simple-foot { flex: none; display: flex; justify-content: center; flex-wrap: wrap; gap: 8px 28px; padding: 12px 24px; border-top: 1px solid var(--line); } +.simple-link, .topbar-mode { display: inline-flex; align-items: center; min-height: 36px; color: var(--text-dim); font: 14px/1.4 var(--ui); border-radius: 5px; } +.simple-link:hover, .topbar-mode:hover { color: var(--text); text-decoration: underline; text-underline-offset: 4px; } +.simple-section-head .simple-link { font-size: 13px; } +.topbar-mode { padding: 0 12px; border: 1px solid var(--line-2); } +.simple-core-down { width: min(900px, 100%); margin: 0 auto; padding: 14px 18px; border: 1px solid var(--signal); border-radius: 8px; color: var(--signal); background: var(--signal-faint); font-size: 14px; } +.simple-wait { width: min(520px, 100%); margin: auto; } +.simple-wait h1 { font-size: 30px; font-weight: 600; margin-bottom: 16px; } +.simple-wait p { color: var(--text-dim); line-height: 1.7; } +.app--simple > .protection-banner, .app--simple > .connection-error { font-family: var(--ui); font-size: 14px; line-height: 1.5; } +.app--simple > .protection-banner .prof-ghost { padding: 6px 10px; min-height: 36px; border: 1px solid currentColor; border-radius: 6px; font: 14px/1.5 var(--ui); letter-spacing: normal; text-transform: none; } + +/* The same first-run form is used in both views. */ +.setup { display: grid; gap: 28px; width: min(540px, 100%); font-family: var(--ui); } +.simple-content > .setup { margin: auto; } +.setup-intro h1 { font: 600 clamp(26px, 3.5vw, 36px)/1.2 var(--ui); letter-spacing: -0.03em; margin-bottom: 14px; } +.setup-intro > p { color: var(--text-dim); font-size: 16px; line-height: 1.65; max-width: 48ch; } +.setup-body { display: grid; gap: 10px; min-width: 0; } +.setup-title { color: var(--text); font-size: 14px; font-weight: 600; } +.setup-row { display: flex; align-items: stretch; gap: 10px; } +.setup-input { flex: 1; min-width: 0; min-height: 48px; padding: 12px 14px; color: var(--text); background: var(--surface); border: 1px solid var(--line-2); border-radius: 8px; font: 15px var(--ui); } +.setup-input:focus { border-color: var(--signal); } +.setup-input[aria-invalid="true"] { border-color: var(--signal); } +.setup-go { display: inline-flex; align-items: center; justify-content: center; padding: 12px 20px; min-height: 48px; border-radius: 8px; color: var(--on-signal); background: var(--signal); font: 600 15px var(--ui); text-align: center; } +.setup-go:disabled { background: var(--surface-2); color: var(--text-dim); cursor: default; } +.setup-help { color: var(--text-dim); font-size: 13px; line-height: 1.6; } +.setup-error { margin: 0; color: var(--signal); font-size: 14px; line-height: 1.6; } + +/* Shared measured service results; failures never get a success-coloured RTT. */ +.svc-checks { display: grid; gap: 8px; margin: 12px 0 0; padding: 0; list-style: none; font-family: var(--mono); font-size: var(--fs-meta); } +.svc-check { display: grid; grid-template-columns: 1.2em 1fr auto; gap: 8px; align-items: baseline; color: var(--text-dim); } +.svc-check.is-ok .svc-mark { color: var(--good); } +.svc-check.is-bad { color: var(--text); } +.svc-check.is-bad .svc-mark, .svc-check.is-bad .svc-rtt { color: var(--danger, #e5484d); } +.svc-rtt { font-variant-numeric: tabular-nums; } +.simple .svc-checks { font: 14px var(--ui); } + +@media (max-width: 760px) { + .simple-header { padding: 14px 20px; } + .simple-mode { display: none; } + .simple-content { padding: 28px 22px; } + .simple-layout { grid-template-columns: minmax(0, 1fr); gap: 28px; max-width: 460px; } + .simple-pick { padding: 24px 0 0; border-left: 0; border-top: 1px solid var(--line-2); } + .simple-btn { margin-top: 16px; } + .simple-power { width: 106px; height: 106px; } +} +@media (max-width: 460px) { + .setup-row { flex-direction: column; } + .simple-header { gap: 4px 12px; } + .simple-link { font-size: 13px; } } - -/* ── bypass status ─────────────────────────────────────────────────────── */ - -/* Named, not just flagged: which bypass is running is the useful fact, since - they behave differently and the app chose one by measurement. Dimmed, and with - an unlit dot, when a bundle is installed and nothing is running it. */ -.simple-bypass { - display: flex; - align-items: center; - gap: var(--sp-2); - margin: var(--sp-2) 0 0; - color: var(--good); - font-family: var(--mono); - font-size: var(--fs-meta); -} - -/* Installed, nothing running it. Muted rather than absent: "there is a bundle - and it is switched off" is a different fact from "there is no bundle", and the - screen has to be able to say both. */ -.simple-bypass.is-off { - color: var(--text-dim); -} - -.simple-bypass-dot { - width: 0.5em; - height: 0.5em; - background: currentColor; - border-radius: var(--radius-max); - animation: bypass-pulse 2.4s ease-in-out infinite; -} - -/* Nothing is live, so nothing pulses. */ -.simple-bypass.is-off .simple-bypass-dot { - animation: none; - opacity: 0.55; -} - -/* Slow and shallow: it says "live" without competing with the status word. */ -@keyframes bypass-pulse { - 0%, - 100% { - opacity: 0.45; - } - 50% { - opacity: 1; - } -} - @media (prefers-reduced-motion: reduce) { - .setup, - .setup .setup-step, - .svc-check, - .simple-bypass-dot { - animation: none; - } -} \ No newline at end of file + .simple-power, .simple-power::before { animation: none !important; transition: none; } +} diff --git a/ui-desktop/src/styles/tokens.css b/ui-desktop/src/styles/tokens.css index 3615ebee..b9ceb5f9 100644 --- a/ui-desktop/src/styles/tokens.css +++ b/ui-desktop/src/styles/tokens.css @@ -23,6 +23,9 @@ system-ui, sans-serif; --mono: "Departure Mono", "JetBrains Mono Variable", "JetBrains Mono", "IBM Plex Mono", ui-monospace, monospace; + /* Native UI lettering keeps long Russian labels readable; mono stays for data. */ + --ui: "Segoe UI Variable Text", "Segoe UI", -apple-system, BlinkMacSystemFont, + sans-serif; /* ---- TYPE · scale (px) ---- */ --fs-word: 52px; /* connection status word (display) */ From 3b4dacbdf98e2a7149b17610e7870645327aa4d8 Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:51:17 +0300 Subject: [PATCH 45/56] feat(desktop): redesign the main connection and server interface --- ui-desktop/src/App.audit.test.tsx | 43 +- ui-desktop/src/App.tsx | 27 +- ui-desktop/src/components/BottomBar.tsx | 6 +- .../src/components/ConnectionPanel.test.tsx | 24 + ui-desktop/src/components/ConnectionPanel.tsx | 62 +- ui-desktop/src/components/ServerList.test.tsx | 21 +- ui-desktop/src/components/ServerList.tsx | 25 +- ui-desktop/src/styles/connection.css | 721 ++---------------- ui-desktop/src/styles/global.css | 7 +- ui-desktop/src/styles/servers.css | 528 ++----------- ui-desktop/src/styles/shell.css | 474 ++---------- ui-desktop/src/styles/tokens.css | 254 ++---- 12 files changed, 422 insertions(+), 1770 deletions(-) diff --git a/ui-desktop/src/App.audit.test.tsx b/ui-desktop/src/App.audit.test.tsx index 7f57ceab..6480ed26 100644 --- a/ui-desktop/src/App.audit.test.tsx +++ b/ui-desktop/src/App.audit.test.tsx @@ -1,4 +1,4 @@ -import type { DeepLinkAction, PingResult } from "./api"; +import type { DeepLinkAction, PingResult, State } from "./api"; import { createElement } from 'react'; import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; @@ -6,6 +6,7 @@ import { App } from './App.tsx'; import { renderWithProviders } from './test/renderWithProviders.tsx'; const m = vi.hoisted(() => ({ + ready: true, coreError: null as string | null, checkNodes: vi.fn(), importSubscription: vi.fn(), refreshProfiles: vi.fn(), updateAvailable: null as string | null, updateConfirm: false, confirmUpdate: vi.fn(), connect: vi.fn(), disconnect: vi.fn(), onDeepLink: vi.fn(), deep: null as ((e: DeepLinkAction) => void) | null, pings: new Map(), @@ -15,7 +16,7 @@ const m = vi.hoisted(() => ({ ] })); vi.mock('./state/useTenebra.ts', () => ({ - useTenebra: () => ({ ready: true, state: {state:'idle',daemon_version:'0.5.11',crash_reports_asked:true}, profiles: m.profiles, + useTenebra: () => ({ ready: m.ready, coreError: m.coreError, state: {state:'idle',daemon_version:'0.5.11',crash_reports_asked:true} as State, profiles: m.profiles, traffic: {up:0,down:0,upRate:0,downRate:0},logs:[],attempts:null,pickProgress:null, connect:m.connect,disconnect:m.disconnect,refreshProfiles:m.refreshProfiles }), })); @@ -33,6 +34,7 @@ vi.mock('./lib/useUpdateCheck.ts', () => ({ })); beforeEach(() => { localStorage.clear(); + m.ready = true; m.coreError = null; m.deep = null; m.updateAvailable = null; m.updateConfirm = false; m.checkNodes.mockResolvedValue({best:"",results:[]}); @@ -53,6 +55,43 @@ it('keeps failed ping unknown and permits a deliberate manual selection', async expect(document.querySelector('.cur-rtt')).toBeNull(); expect(document.querySelectorAll('.cur-meta .ping-scale-bar.on.good')).toHaveLength(0); }); + +it.each([['0', false, null], ['0', true, 'service lost'], ['1', false, null], ['1', true, 'service lost']] as const)( + 'blocks keyboard Connect as well as the button in mode %s with ready=%s error=%s', async (mode, ready, error) => { + localStorage.setItem('tenebra.simpleMode', mode); + m.ready = ready; m.coreError = error; + renderWithProviders(createElement(App)); + await screen.findAllByText('Node A'); + expect(screen.getByRole('button', {name: /^(▶\s*)?Connect$/})).toBeDisabled(); + await act(async () => { fireEvent.keyDown(document.body, {key:' ',code:'Space'}); }); + expect(m.checkNodes).not.toHaveBeenCalled(); + expect(m.connect).not.toHaveBeenCalled(); + }, +); + +it.each(['0','1'])('blocks keyboard Connect for a saved subscription without nodes in mode %s', async (mode) => { + localStorage.setItem('tenebra.simpleMode',mode); + const saved = m.profiles; + m.profiles = saved.map(p => ({...p,nodes:[]})); + try { + renderWithProviders(createElement(App)); + await act(async () => {}); + expect(screen.getByRole('button',{name:/^(▶\s*)?Connect$/})).toBeDisabled(); + await act(async () => { fireEvent.keyDown(document.body,{key:' ',code:'Space'}); }); + expect(m.checkNodes).not.toHaveBeenCalled(); + expect(m.connect).not.toHaveBeenCalled(); + } finally { m.profiles = saved; } +}); + +it('waits for the service before asking a new full-mode user to import', async () => { + const saved = m.profiles; + m.profiles = []; m.ready = false; + try { + renderWithProviders(createElement(App)); + expect(screen.getByRole('heading',{name:'Starting Tenebra…'})).toBeInTheDocument(); + expect(screen.queryByRole('textbox',{name:/subscription link/i})).toBeNull(); + } finally { m.profiles = saved; } +}); afterEach(() => cleanup()); it.each(['0', '1'])('keeps first launch focused on a single subscription task in mode %s', async (mode) => { diff --git a/ui-desktop/src/App.tsx b/ui-desktop/src/App.tsx index 1d6031cf..94aabe2f 100644 --- a/ui-desktop/src/App.tsx +++ b/ui-desktop/src/App.tsx @@ -368,13 +368,14 @@ export function App() { // unreachable, which leaves the list empty) was swallowed in silence. // Disabling it is the smallest honest fix and matches SimpleView, which has // always gated its own button on having a profile. - const canPrimary = + const canPrimary = !busy && !nodeCheck.checking && ( connected || phase === "connecting" || phase === "health_reconnecting" || - selectedProfileId !== null; + (tenebra.ready && !tenebra.coreError && selectedProfileId !== null && nodes.length > 0)); // All entrances share validation, refusal reporting and the one override prompt. + const selectionLocked = !tenebra.ready || !!tenebra.coreError || busy || nodeCheck.checking || phase === "connecting" || phase === "health_reconnecting"; const connectSafely = useCallback(async (profileId: string, node?: string, auto?: boolean): Promise => { if (connectingRef.current) return null; connectingRef.current = true; @@ -403,7 +404,7 @@ export function App() { }, [profiles, tenebra, askTunOverride, t]); const handlePrimary = useCallback(() => { - if (busy) return; + if (!canPrimary) return; setBusy(true); setConnectError(null); void (async () => { @@ -430,10 +431,11 @@ export function App() { pushToast(describeCoreError(e, t)); } finally { setBusy(false); } })(); - }, [busy, connected, phase, tenebra, selectedProfileId, selectedNodeId, nodes, nodeCheck, connectSafely, t]); + }, [canPrimary, connected, phase, tenebra, selectedProfileId, selectedNodeId, nodes, nodeCheck, connectSafely, t]); const handleSelectNode = useCallback( (id: string) => { + if (selectionLocked) return; setSelectedNodeId(id); if (!connected || !selectedProfileId) return; // Change the exit on a live tunnel. The core steers the running sing-box @@ -457,7 +459,7 @@ export function App() { }) .catch(() => {}); }, - [connected, selectedProfileId, selectedProfile, connectSafely, t], + [selectionLocked, connected, selectedProfileId, selectedProfile, connectSafely, t], ); const handleSelectProfile = useCallback((id: string) => { @@ -469,12 +471,13 @@ export function App() { // already connected, re-handshake straight away onto the fastest node, the // node-click counterpart for auto. const handleSelectAuto = useCallback(() => { + if (selectionLocked) return; setSelectedNodeId(""); if (connected && selectedProfileId) { void connectSafely(selectedProfileId, undefined, getAutoFastest()) .catch(() => {}); } - }, [connected, selectedProfileId, connectSafely]); + }, [selectionLocked, connected, selectedProfileId, connectSafely]); const handleSetRouting = useCallback( (mode: RoutingMode) => { @@ -822,13 +825,20 @@ export function App() { somewhere else. The strip removes itself the moment it is done, so it costs a returning user nothing. */} 0} + hasProfile={profiles.length > 0 || !tenebra.ready || !!tenebra.coreError} onSubscribe={handleSimpleSubscribe} /> + {profiles.length === 0 && (!tenebra.ready || tenebra.coreError) &&
+

{tenebra.coreError ? t.simple.serviceUnavailable : t.simple.serviceStarting}

+

{t.simple.serviceHelp}

+
} {(profiles.length > 0 || connected || phase === "connecting" || phase === "health_reconnecting") &&
setOverlay("profiles")} pinging={pings.pinging} + disabled={selectionLocked} />
} @@ -880,7 +891,7 @@ export function App() { onLeakCheck={() => setOverlay("logs")} onSettings={() => setOverlay("settings")} onReportProblem={problem.open} - bypassInstalled={bypassInstalled} + bypassInstalled={tenebra.ready && !tenebra.coreError && bypassInstalled} bypassOn={bypassOn} bypassStrategy={bypassStrategy} /> diff --git a/ui-desktop/src/components/BottomBar.tsx b/ui-desktop/src/components/BottomBar.tsx index bcaa0d42..7f2fd611 100644 --- a/ui-desktop/src/components/BottomBar.tsx +++ b/ui-desktop/src/components/BottomBar.tsx @@ -102,13 +102,13 @@ export function BottomBar({ unless it had crashed *and* the user had opted into crash reports, which is not how most things break. */}
diff --git a/ui-desktop/src/components/ConnectionPanel.test.tsx b/ui-desktop/src/components/ConnectionPanel.test.tsx index 9dd0fad9..833cef7f 100644 --- a/ui-desktop/src/components/ConnectionPanel.test.tsx +++ b/ui-desktop/src/components/ConnectionPanel.test.tsx @@ -27,6 +27,21 @@ function baseProps(overrides: Partial[0]> = { } describe("ConnectionPanel", () => { + it("clears stale connected information when the service is unavailable but preserves Disconnect", () => { + const {container} = renderWithProviders(); + expect(screen.getByRole("heading",{name:"Service unavailable"})).toBeInTheDocument(); + expect(screen.queryByText("203.0.113.7")).toBeNull(); + expect(container.querySelector(".cur-rtt")).toBeNull(); + expect(screen.getByRole("button",{name:/Disconnect/})).toBeEnabled(); + }); + + it("keeps Abort available during service loss and shows a confirmed block as its own state", () => { + const view = renderWithProviders(); + expect(screen.getByRole("button",{name:/ABORT/})).toBeEnabled(); + view.rerender(); + expect(screen.getByRole("heading",{name:"Internet traffic blocked"})).toBeInTheDocument(); + expect(screen.queryByText("tunnel disconnected · select a node and connect")).toBeNull(); + }); describe("idle", () => { it("shows the disconnected status, a Connect label and dimmed stats", () => { renderWithProviders(); @@ -322,6 +337,15 @@ describe("ConnectionPanel", () => { ], }; + it("removes the successful fallback as soon as the service becomes unavailable", () => { + const ok: AttemptsEvent = {outcome:"ok",items:[{seq:1,protocol:"vless",node:"n1",status:"ok",last_good:false}]}; + const view = renderWithProviders(); + expect(screen.getByText("Protocol fallback")).toBeInTheDocument(); + view.rerender(); + expect(screen.queryByText("Protocol fallback")).toBeNull(); + expect(screen.getByRole("heading",{name:"Service unavailable"})).toBeInTheDocument(); + }); + it("replaces the node card with the fallback walk while connecting", () => { renderWithProviders( , diff --git a/ui-desktop/src/components/ConnectionPanel.tsx b/ui-desktop/src/components/ConnectionPanel.tsx index eb1f9d1f..ef8c02ed 100644 --- a/ui-desktop/src/components/ConnectionPanel.tsx +++ b/ui-desktop/src/components/ConnectionPanel.tsx @@ -3,7 +3,6 @@ import { useEffect, useState } from "react"; import type { AttemptsEvent, ConnectionState, RoutingMode } from "../api"; import { useI18n } from "../i18n/I18nContext"; import { formatBytes } from "../lib/format"; -import { useScrambledText } from "../lib/useScrambledText"; import type { TrafficHistory } from "../lib/useTrafficHistory"; import { FallbackPanel } from "./FallbackPanel"; import { PingScale } from "./PingScale"; @@ -11,6 +10,9 @@ import { TrafficChart } from "./TrafficChart"; interface ConnectionPanelProps { phase: ConnectionState; + ready?: boolean; + coreUnreachable?: boolean; + protectionBlocked?: boolean; /** Active/selected node display code (the node's own name). */ nodeCode: string; /** Derived location subtitle; "" hides the line. */ @@ -114,6 +116,9 @@ function useOkHold(attempts: AttemptsEvent | null | undefined): boolean { export function ConnectionPanel({ phase, + ready = true, + coreUnreachable = false, + protectionBlocked = false, nodeCode, nodeCity, exitServer, @@ -135,7 +140,8 @@ export function ConnectionPanel({ onChange, }: ConnectionPanelProps) { const { t } = useI18n(); - const connected = phase === "connected"; + const unavailable = !ready || coreUnreachable; + const connected = phase === "connected" && !unavailable; const pending = phase === "connecting"; // The automatic health-failover recovery: the core is reconnecting to a // healthy node on its own after the active one degraded. It runs the same @@ -152,18 +158,19 @@ export function ConnectionPanel({ // A pseudo-phase: it drives the status-word class and the rail exactly the way // a real phase does, so measuring is one more stop on the same road rather // than a separate widget bolted beside it. - const displayPhase = measuring ? "checking" : phase; + const displayPhase = unavailable ? "unavailable" : protectionBlocked && !inFlight && !measuring ? "blocked" : measuring ? "checking" : phase; const working = measuring || inFlight; - const word = measuring ? t.conn.wordChecking : t.state[phase]; - const displayWord = useScrambledText(word); - const buttonLabel = connected - ? `▢ ${t.home.disconnect}` + const word = coreUnreachable ? t.simple.serviceUnavailable : !ready ? t.simple.serviceStarting + : protectionBlocked && !inFlight && !measuring ? t.simple.trafficBlocked + : measuring ? t.conn.wordChecking : t.state[phase]; + const buttonLabel = phase === "connected" + ? t.home.disconnect : inFlight - ? `· · · ${t.conn.abort}` + ? t.conn.abort : measuring - ? `· · · ${t.conn.measuring}` - : `▶ ${t.home.connect}`; + ? t.conn.measuring + : t.home.connect; const routeName = routing === "global" @@ -174,10 +181,10 @@ export function ConnectionPanel({ // A bare integer ping feeds the strength meter; "—" (no probe) shows neither // the meter nor a value — honest over decorative. - const pingValue = /^\d+$/.test(ping) ? Number(ping) : null; + const pingValue = !unavailable && /^\d+$/.test(ping) ? Number(ping) : null; const okHold = useOkHold(attempts); - const showFallback = shouldShowFallback(attempts, phase, okHold); + const showFallback = !unavailable && shouldShowFallback(attempts, phase, okHold); // The "change" affordance broadcasts a focus-search intent the server-list pane // listens for, so the two panes stay decoupled; the onChange callback is kept @@ -187,7 +194,11 @@ export function ConnectionPanel({ onChange(); }; - const subLine = measuring ? ( + const subLine = unavailable ? ( + {t.simple.serviceHelp} + ) : protectionBlocked && !inFlight && !measuring ? ( + {t.simple.blockedHint} + ) : measuring ? ( // Say what the seconds are being spent on. "Connecting…" would be a lie — // nothing is being connected yet — and silence was what made the wait read // as a hang. @@ -227,12 +238,12 @@ export function ConnectionPanel({ )} -
+

+ {/* Always mounted: the track is a hairline rule, and only the runner comes and goes. Mounting the rail with the work shifted everything under it by 4px at the exact moment the status changed. */} @@ -256,9 +267,12 @@ export function ConnectionPanel({ type="button" className={`connect-btn${connected ? " on" : ""}${pending ? " pending" : ""}${reconnecting ? " reconnecting" : ""}${measuring ? " checking" : ""}`} onClick={onPrimary} - disabled={disabled} + disabled={disabled || (unavailable && phase !== "connected" && !inFlight)} aria-busy={working || undefined} > + {buttonLabel}