diff --git a/agent/internal/agentapp/main.go b/agent/internal/agentapp/main.go index 94dd99805d..e290275ac8 100644 --- a/agent/internal/agentapp/main.go +++ b/agent/internal/agentapp/main.go @@ -300,6 +300,7 @@ func init() { enrollCmd.Flags().BoolVar(&quietEnroll, "quiet", false, "Suppress stdout progress output (errors still go to stderr). Intended for unattended installs.") bootstrapCmd.Flags().StringVar(&bootstrapInstallData, "install-data", "", "Pipe-packed bootstrap inputs from the MSI BootstrapEnroll CA: ||") bootstrapCmd.Flags().BoolVar(&quietEnroll, "quiet", false, "Suppress stdout progress output (errors still go to stderr)") + supportCmd.Flags().StringVar(&supportCode, "code", "", "Quick Support code (overrides the code embedded in the filename)") userHelperCmd.Flags().StringVar(&helperRole, "role", string(ipc.HelperRoleUser), "Helper role: 'system' (desktop capture) or 'user' (script execution)") desktopHelperCmd.Flags().StringVar(&desktopContext, "context", ipc.DesktopContextUserSession, "Desktop context: 'user_session' or 'login_window'") @@ -309,6 +310,7 @@ func init() { rootCmd.AddCommand(bootstrapCmd) rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(statusCmd) + rootCmd.AddCommand(supportCmd) rootCmd.AddCommand(uninstallNotifyCmd) rootCmd.AddCommand(userHelperCmd) rootCmd.AddCommand(desktopHelperCmd) @@ -362,6 +364,21 @@ func Main(v string) { runDesktopHelper() return } + + // Quick Support clients are downloaded under a name that carries the + // one-time code (breeze-support--.exe) and are double-clicked, + // so there is no subcommand on the command line. Dispatch to `support` by + // basename. + // + // The second condition guards a future service copy launched with an + // explicit `support --service-run` argv; without it the dispatch would + // prepend a SECOND "support" and cobra would parse the duplicate as a + // positional arg. + if strings.HasPrefix(strings.ToLower(filepath.Base(os.Args[0])), "breeze-support") && + (len(os.Args) < 2 || os.Args[1] != "support") { + rootCmd.SetArgs(append([]string{"support"}, os.Args[1:]...)) + } + if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) @@ -380,7 +397,13 @@ func initLogging(cfg *config.Config) { logFileFallbackReason = describeLogFileError(err) fmt.Fprintf(os.Stderr, "Failed to open log file %s: %s (logging to stdout)\n", cfg.LogFile, logFileFallbackReason) logFileFallback = true - } else if !hasConsole() { + } else if !hasConsole() || cfg.SupportMode { + // Support mode is file-only for a different reason than the + // headless case below: the console IS the end user's status + // window ("Waiting for your technician…"), and structured slog + // lines interleaved with it look like an error to a + // non-technical user. The lines still land in the workspace log + // file, which is what the technician gets. // No console attached (Windows service, launchd daemon, or systemd // service). Use file-only logging — stdout may be invalid or already // redirected to a log destination by the init system. Using @@ -575,9 +598,21 @@ func startAgent(cfg *config.Config) (*agentComponents, error) { return nil, fmt.Errorf("startAgent called with unenrolled config — caller must waitForEnrollment first") } + // Quick Support clients are throwaway, unelevated, and live entirely in a + // temp workspace. They must never touch the machine-wide install: no + // self-update (a support session outlives nothing), and every ProgramData + // path below is skipped because a real permanently-installed agent may be + // running on this same machine and owns those files. See runSupportSession. + if cfg.SupportMode { + cfg.AutoUpdate = false + } + // Loosen config directory (0755) and agent.yaml (0644) so the Helper can read - // them. secrets.yaml stays root-only (0600). - config.FixConfigPermissions() + // them. secrets.yaml stays root-only (0600). Skipped in support mode: this + // operates on the REAL config dir, which a support client does not own. + if !cfg.SupportMode { + config.FixConfigPermissions() + } initLogging(cfg) @@ -591,14 +626,22 @@ func startAgent(cfg *config.Config) (*agentComponents, error) { // stays zero (watchdog treats zero as a startup grace period) exactly as // the running-state write does until the first heartbeat records it. // See #1029. + // + // NOT in support mode: agent.state lives in the machine-wide config dir + // and is read by the watchdog as the live agent's PID. A throwaway + // support client writing its own PID there would make the watchdog + // supervise (and eventually force-kill) the wrong process, and would + // report the real agent as gone the moment the support client exits. startupStatePath := state.PathInDir(config.ConfigDir()) - if err := state.Write(startupStatePath, &state.AgentState{ - Status: state.StatusStarting, - PID: os.Getpid(), - Version: version, - Timestamp: time.Now(), - }); err != nil { - log.Warn("failed to write startup state file", "error", err.Error()) + if !cfg.SupportMode { + if err := state.Write(startupStatePath, &state.AgentState{ + Status: state.StatusStarting, + PID: os.Getpid(), + Version: version, + Timestamp: time.Now(), + }); err != nil { + log.Warn("failed to write startup state file", "error", err.Error()) + } } // Auto-clear Safe Mode BCD flag on startup to prevent reboot loops. @@ -669,8 +712,11 @@ func startAgent(cfg *config.Config) (*agentComponents, error) { // if the MSI HardenProgramDataAcl action was skipped or blocked (#1481). // Runs here, after the shipper is up, so the drift warning actually reaches // agent_logs — same constraint as the reconcile reporter above. No-op off - // Windows and when the dirs are already hardened. - config.EnforceProgramDataTreePermissions() + // Windows and when the dirs are already hardened. Skipped in support mode: + // an unelevated throwaway client has no business re-ACLing ProgramData. + if !cfg.SupportMode { + config.EnforceProgramDataTreePermissions() + } // Load mTLS client certificate if configured var tlsCfg *tls.Config @@ -723,8 +769,16 @@ func startAgent(cfg *config.Config) (*agentComponents, error) { // Propagate service/headless flags. On Windows, desktop sessions route // through the IPC user helper. On macOS, the daemon handles desktop // directly but uses IPC for user-context operations (run_as_user, helper). - cfg.IsService = isWindowsService() - cfg.IsHeadless = isHeadless() + // + // Support mode pins BOTH to false: the client is a plain foreground + // process owning the interactive desktop, so desktop capture takes the + // in-process path and no SYSTEM/user helper has to be spawned or + // installed. Pinning here (rather than only in runSupportSession) means a + // probe misfiring — isHeadless() on a double-clicked .exe with no attached + // console is the realistic one — cannot silently reroute capture through + // IPC to a helper that does not exist. + cfg.IsService = isWindowsService() && !cfg.SupportMode + cfg.IsHeadless = isHeadless() && !cfg.SupportMode // Ensure SAS (Ctrl+Alt+Del) policy allows services to generate it. // Only relevant on Windows when running as a service. @@ -732,7 +786,9 @@ func startAgent(cfg *config.Config) (*agentComponents, error) { ensureSASPolicy() } - if cfg.PAMEnabled && runtime.GOOS == "windows" { + // Never in support mode: provisioning a dormant elevation account is a + // permanent machine change, and the client is unelevated anyway. + if cfg.PAMEnabled && runtime.GOOS == "windows" && !cfg.SupportMode { if err := elevaccount.New().EnsureProvisioned(); err != nil { log.Warn("failed to provision PAM dormant elevation account, continuing", "error", err.Error()) @@ -791,9 +847,12 @@ func startAgent(cfg *config.Config) (*agentComponents, error) { // on-site UniFi controller's local Network Integration API and uploads // per-device PoE/health + client telemetry. Runs for the agent process // lifetime and no-ops until the server assigns collectors to this device. + // + // Off in support mode: a client that exists to serve one screen-share + // session has no business polling the customer's network gear. var unifiCancel context.CancelFunc var unifiDone <-chan struct{} - if cfg.ServerURL != "" && cfg.AgentID != "" { + if cfg.ServerURL != "" && cfg.AgentID != "" && !cfg.SupportMode { var unifiCtx context.Context // Scope the loop to a cancellable context registered in agentComponents // so shutdownAgent stops it. context.Background() here would never cancel: @@ -817,7 +876,11 @@ func startAgent(cfg *config.Config) (*agentComponents, error) { var workspaceIndexCancel context.CancelFunc var workspaceIndexDone <-chan struct{} - if cfg.WorkspaceIndex.Enabled != nil && !*cfg.WorkspaceIndex.Enabled { + if cfg.SupportMode { + // Crawling and indexing the customer's filesystem is exactly the kind + // of thing an ad-hoc support client must never do. + log.Debug("workspace indexing disabled in Quick Support mode") + } else if cfg.WorkspaceIndex.Enabled != nil && !*cfg.WorkspaceIndex.Enabled { log.Debug("workspace indexing disabled by local configuration") } else { workspaceClient := workspaceindex.NewClient(workspaceindex.ClientConfig{ @@ -849,24 +912,38 @@ func startAgent(cfg *config.Config) (*agentComponents, error) { // context.Background() (the old call) never cancels — defer // sub.Stop() at etwlua.Start exit-path never fires and the real-time // ETW session leaks across agent restarts (PR #959 review, blocker 1). + // + // A real-time kernel ETW session is process-global and machine-wide (two + // callers conflict — see NewETWSubscriber), so a support client must never + // open one alongside the installed agent. etwCtx, etwCancel := context.WithCancel(context.Background()) - etwluaDone := startETWLua(etwCtx, hb) + var etwluaDone <-chan struct{} + if cfg.SupportMode { + closed := make(chan struct{}) + close(closed) + etwluaDone = closed + } else { + etwluaDone = startETWLua(etwCtx, hb) + } log.Info("agent is running") - // Write state file so the watchdog can detect a running agent. - statePath := state.PathInDir(config.ConfigDir()) - if err := state.Write(statePath, &state.AgentState{ - Status: state.StatusRunning, - PID: os.Getpid(), - Version: version, - Timestamp: time.Now(), - }); err != nil { - log.Warn("failed to write agent state file", "error", err.Error()) - } + // Write state file so the watchdog can detect a running agent. Support + // mode never writes or registers it — see the startup-state write above. + if !cfg.SupportMode { + statePath := state.PathInDir(config.ConfigDir()) + if err := state.Write(statePath, &state.AgentState{ + Status: state.StatusRunning, + PID: os.Getpid(), + Version: version, + Timestamp: time.Now(), + }); err != nil { + log.Warn("failed to write agent state file", "error", err.Error()) + } - // Tell the heartbeat where the state file is so it can update after each heartbeat. - hb.SetStatePath(statePath) + // Tell the heartbeat where the state file is so it can update after each heartbeat. + hb.SetStatePath(statePath) + } // Mutual supervision: on Windows, when running as the SCM service this // agent process supervises BreezeWatchdog the same way BreezeWatchdog @@ -875,9 +952,13 @@ func startAgent(cfg *config.Config) (*agentComponents, error) { // LaunchDaemons report cfg.IsService=true via service_unix.go:21-26, // startWatchdogSupervisor is a no-op stub on non-Windows builds, so // gating on cfg.IsService here is safe across platforms. + // + // Support mode is doubly excluded (it always runs with IsService=false): + // there is no watchdog to supervise, and installing one is precisely the + // "permanently installed" outcome Quick Support promises not to produce. var supervisorCancel context.CancelFunc var supervisorDone <-chan struct{} - if cfg.IsService { + if cfg.IsService && !cfg.SupportMode { supCtx, supCancel := context.WithCancel(context.Background()) supervisorCancel = supCancel supervisorDone = startWatchdogSupervisor(supCtx) @@ -1199,6 +1280,81 @@ func enrollDevice(enrollmentKey string) { "server", cfg.ServerURL) } + secret := enrollmentSecret + if secret == "" { + secret = os.Getenv("BREEZE_AGENT_ENROLLMENT_SECRET") + } + + if err := enrollWithConfig(cfg, cfgFile, enrollmentKey, secret); err != nil { + var failure *enrollFailure + if errors.As(err, &failure) { + enrollError(failure.cat, failure.friendly, failure.detail) + } else { + // Unreachable in production: enrollWithConfig only ever returns + // *enrollFailure. Kept so a future edit that returns a bare error + // still exits through the four-sink reporter instead of silently + // falling through to the "start the agent with" guidance below. + enrollError(catUnknown, err.Error(), nil) + } + return // enrollError does not return in production; belt-and-braces. + } + + if isSystemServiceRunning() { + if !quietEnroll { + fmt.Println("Agent is already running via system service.") + } + } else if runtime.GOOS == "darwin" || runtime.GOOS == "linux" { + if !quietEnroll { + fmt.Println("Start the agent with:") + fmt.Println(" sudo breeze-agent service start") + } + } else { + if !quietEnroll { + fmt.Println("Run 'breeze-agent start' to start the agent.") + } + } +} + +// enrollFailure carries an enrollment failure's category and user-facing +// message out of enrollWithConfig so the caller can report it through +// enrollError (four sinks + category-specific exit code) exactly as the +// inline code used to. It exists because enrollWithConfig is shared with +// Quick Support mode (support.go), which must NOT exit the process on a +// failure — it has its own console to talk to the end user through. +type enrollFailure struct { + cat enrollErrCategory + friendly string + detail error +} + +func (e *enrollFailure) Error() string { + if e.detail != nil { + return fmt.Sprintf("%s (%v)", e.friendly, e.detail) + } + return e.friendly +} + +func (e *enrollFailure) Unwrap() error { return e.detail } + +// enrollWithConfig is the core of enrollment: collect system + hardware +// identity, POST /agents/enroll, apply the response to cfg, and persist it to +// cfgFile (agent.yaml + the sibling root-only secrets.yaml). +// +// This is a verbatim extraction of enrollDevice's core so the `enroll` +// command and Quick Support mode enroll through exactly one code path. The +// only behavioural difference from the inline version is that failures are +// RETURNED (as *enrollFailure) instead of calling enrollError inline; +// enrollDevice immediately forwards them to enrollError, so the CLI command's +// messages, sinks and exit codes are unchanged. +// +// The enrollment secret is a parameter rather than being read from the +// enrollmentSecret flag / BREEZE_AGENT_ENROLLMENT_SECRET here, because +// support mode presents a PER-KEY secret unique to its session. Everything +// else still reads the package-level command flags (quietEnroll, +// enrollDeviceRole, backupServerURL) — only one command runs per process. +func enrollWithConfig(cfg *config.Config, cfgFile, enrollmentKey, secret string) error { + enrollLog := logging.L("enroll") + enrollLog.Info("starting enrollment", "server", cfg.ServerURL) if !quietEnroll { fmt.Printf("Enrolling with server: %s\n", cfg.ServerURL) @@ -1252,11 +1408,9 @@ func enrollDevice(enrollmentKey string) { // issue #439 — one prod device ended up with its UUID in the hostname // column, which is worse than a loud failure because it looks legit. if err := assertHostnameNonEmpty(systemInfo); err != nil { - enrollError(catConfig, - "hostname resolution failed on this machine — tried "+ - collectors.HostnameSourcesDescription()+ - "; all returned empty. Refusing to enroll with an empty hostname.", - err) + return &enrollFailure{cat: catConfig, friendly: "hostname resolution failed on this machine — tried " + + collectors.HostnameSourcesDescription() + + "; all returned empty. Refusing to enroll with an empty hostname.", detail: err} } // Carry any existing device token into the enroll client. On a fresh @@ -1267,11 +1421,6 @@ func enrollDevice(enrollmentKey string) { // active row (e.g. after a rename/re-image). See #1028. client := api.NewClient(cfg.ServerURL, cfg.AuthToken, cfg.AgentID) - secret := enrollmentSecret - if secret == "" { - secret = os.Getenv("BREEZE_AGENT_ENROLLMENT_SECRET") - } - deviceRole := enrollDeviceRole if deviceRole == "" { deviceRole = collectors.ClassifyDeviceRole(systemInfo, hardwareInfo) @@ -1328,7 +1477,7 @@ func enrollDevice(enrollmentKey string) { enrollResp, err := client.Enroll(enrollReq) if err != nil { cat, friendly := classifyEnrollError(err, cfg.ServerURL) - enrollError(cat, friendly, err) + return &enrollFailure{cat: cat, friendly: friendly, detail: err} } applyEnrollResponseIdentity(cfg, enrollResp) @@ -1398,11 +1547,9 @@ func enrollDevice(enrollmentKey string) { } if err := config.SaveTo(cfg, cfgFile); err != nil { - enrollError(catConfig, - fmt.Sprintf( - "enrollment succeeded but could not save config to %s — check that the directory exists and SYSTEM has write access (agentID=%s)", - cfgFile, cfg.AgentID), - err) + return &enrollFailure{cat: catConfig, friendly: fmt.Sprintf( + "enrollment succeeded but could not save config to %s — check that the directory exists and SYSTEM has write access (agentID=%s)", + cfgFile, cfg.AgentID), detail: err} } enrollLog.Info("enrollment successful", @@ -1415,20 +1562,7 @@ func enrollDevice(enrollmentKey string) { fmt.Println("Configuration saved.") } - if isSystemServiceRunning() { - if !quietEnroll { - fmt.Println("Agent is already running via system service.") - } - } else if runtime.GOOS == "darwin" || runtime.GOOS == "linux" { - if !quietEnroll { - fmt.Println("Start the agent with:") - fmt.Println(" sudo breeze-agent service start") - } - } else { - if !quietEnroll { - fmt.Println("Run 'breeze-agent start' to start the agent.") - } - } + return nil } // initEnrollLogging configures the agent logging package for the enroll diff --git a/agent/internal/agentapp/support.go b/agent/internal/agentapp/support.go new file mode 100644 index 0000000000..51f96eb7fa --- /dev/null +++ b/agent/internal/agentapp/support.go @@ -0,0 +1,487 @@ +package agentapp + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "os/signal" + "path/filepath" + "regexp" + "runtime" + "strings" + "syscall" + "time" + + "github.com/breeze-rmm/agent/internal/collectors" + "github.com/breeze-rmm/agent/internal/config" + "github.com/breeze-rmm/agent/internal/logging" + "github.com/breeze-rmm/agent/pkg/api" + "github.com/spf13/cobra" +) + +// defaultSupportServer is the control-plane URL a Quick Support client falls +// back to when neither the filename nor --server supplies one. Injected at +// build time per region: +// +// -ldflags "-X github.com/breeze-rmm/agent/internal/agentapp.defaultSupportServer=https://us.2breeze.app" +// +// Deliberately empty in the repo: no region hostname is ever committed (see +// the "no internal infrastructure details in public code" rule). Empty means +// the client asks the end user for the server, which is the correct +// self-hosted behaviour anyway. +var defaultSupportServer = "" + +// supportCode is the --code flag. --server reuses the root command's +// persistent serverURL flag rather than declaring a shadowing local one. +var supportCode string + +var supportCmd = &cobra.Command{ + Use: "support", + Short: "Run a one-time Quick Support session (nothing is installed)", + Long: `Runs this binary as an ephemeral Breeze Quick Support client. + +The client redeems a one-time support code, enrolls a temporary device into a +directory under the system temp folder, serves a single remote-support session, +and then removes itself. Nothing is permanently installed and no existing +Breeze agent on this machine is touched. + +The code and server are normally embedded in the downloaded filename +(breeze-support--.exe); --code / --server override them, and if +neither is available the client prompts.`, + Run: func(cmd *cobra.Command, args []string) { + runSupportSession() + }, +} + +// supportCodeAlphabet is the server's code alphabet: A-Z minus I/L/O/U, plus +// 2-9. The excluded characters are the ones users mis-hear or mis-read over +// the phone (I/1/L, O/0), which is the whole point of a spoken support code. +const supportCodeAlphabet = "ABCDEFGHJKMNPQRSTVWXYZ23456789" + +var supportCodeRe = regexp.MustCompile(`^[` + supportCodeAlphabet + `]{9}$`) + +// supportFilenameRe parses the download filename +// breeze-support--.exe. +// +// The trailing `(?:\s?\(\d+\))?` is the browser duplicate-download marker: +// Chrome and Edge insert a space before it ("... (1).exe"), Firefox does not +// ("...(1).exe"). Both must parse, or a user who downloads twice gets an +// interactive prompt for a code they were told is already "in the file". +// +// The host group is non-greedy so the dedup marker is never folded into it. +var supportFilenameRe = regexp.MustCompile(`(?i)^breeze-support-([a-z2-9]{9})-(.+?)(?:\s?\(\d+\))?\.exe$`) + +// supportHostPortRe decodes the `host_PORT` suffix the download route emits +// for a nonstandard port. `:` is illegal in a Windows filename and Chromium +// silently rewrites it to `_` at save time — that is exactly how #2341 +// shipped silently-unenrolled installs — so the server encodes the colon and +// the client decodes it back. +// +// Mirrors internal/agentapp/installer_filename.go's `(?:_([0-9]{1,5}))?` +// terminator, with one deliberate difference: that decoder's host charset +// (`[a-zA-Z0-9.\-]+`) excludes `_` entirely, so it cannot express "the last +// underscore group". Here the host group is `(.+?)`, so the greedy `.*` below +// anchors on the LAST underscore and the 1-5 digit bound keeps a hostname +// that legitimately contains an underscore (host_evil, host_123456) intact. +// For every filename the server can actually emit, the two agree. +var supportHostPortRe = regexp.MustCompile(`^(.*)_([0-9]{1,5})$`) + +// errNoSupportCode means no code was supplied by flag OR embedded in the +// filename — the caller falls back to an interactive prompt. Distinct from a +// malformed code, which is reported with an explanation. +var errNoSupportCode = errors.New("no support code supplied and none embedded in the filename") + +// normalizeSupportCode strips the display formatting a technician reads out +// loud (XXX-XXX-XXX, possibly with spaces) and upper-cases the result. +func normalizeSupportCode(s string) string { + var b strings.Builder + for _, r := range s { + if r == '-' || r == ' ' || r == '\t' { + continue + } + b.WriteRune(r) + } + return strings.ToUpper(strings.TrimSpace(b.String())) +} + +// decodeSupportHost turns the filename's `host_PORT` encoding back into +// `host:port`. Returns host unchanged when there is no numeric port suffix. +func decodeSupportHost(host string) string { + if m := supportHostPortRe.FindStringSubmatch(host); m != nil { + return m[1] + ":" + m[2] + } + return host +} + +// resolveSupportInput determines the support code and server URL for this +// run. Explicit flags always win over the filename; whatever the filename +// supplies fills the gaps. Returns an error — errNoSupportCode when nothing +// was supplied at all — so the caller can fall back to an interactive prompt. +// +// The server is returned even on error (a filename may carry a usable host +// with an unusable code) so the prompt can pre-fill it. +func resolveSupportInput(argv0, codeFlag, serverFlag string) (code, server string, err error) { + code = normalizeSupportCode(codeFlag) + server = strings.TrimSpace(serverFlag) + + if fileCode, fileHost, ok := parseSupportFilename(supportBase(argv0)); ok { + if code == "" { + code = fileCode + } + if server == "" { + server = "https://" + fileHost + } + } + + if code == "" { + return "", server, errNoSupportCode + } + if !supportCodeRe.MatchString(code) { + return "", server, fmt.Errorf("%q is not a valid support code (9 characters, letters and digits, no I/L/O/U/0/1)", code) + } + return code, server, nil +} + +// supportBase is filepath.Base that understands BOTH separators regardless of +// the OS it is compiled for. argv[0] of a Quick Support client is always a +// Windows path (`C:\Users\me\Downloads\...`), but the parser is unit-tested on +// Linux CI, where filepath.Base would return the whole string and silently +// make every path-shaped table case vacuous. +func supportBase(path string) string { + if i := strings.LastIndexAny(path, `/\`); i >= 0 { + return path[i+1:] + } + return filepath.Base(path) +} + +// parseSupportFilename extracts the code and API host embedded in a Quick +// Support download filename. The code is upper-cased (the filesystem, and a +// user renaming the file, do not preserve case) and the host's `_PORT` +// encoding is decoded back to `:port`. +func parseSupportFilename(base string) (code, host string, ok bool) { + m := supportFilenameRe.FindStringSubmatch(base) + if m == nil { + return "", "", false + } + return strings.ToUpper(m[1]), decodeSupportHost(m[2]), true +} + +// supportWorkDir is the throwaway workspace for this support client: config, +// secrets and log file all live here and the whole tree is removed on +// teardown. Keyed by PID so two concurrent support clients (a user who runs +// the download twice) cannot fight over one directory. +// +// It is NEVER config.ConfigDir(). A support client writing into +// C:\ProgramData\Breeze would overwrite the config, secrets and agent.state +// of a real permanently-installed agent on the same machine — the single most +// destructive failure mode this feature has. +func supportWorkDir() string { + return filepath.Join(os.TempDir(), fmt.Sprintf("breeze-support-%d", os.Getpid())) +} + +// configDirForSupportGuard exposes the real agent config dir to the guard +// test that pins supportWorkDir away from it. +func configDirForSupportGuard() string { return config.ConfigDir() } + +const ( + // supportDisconnectGrace is how long the WebSocket may report + // disconnected before the dead-man switch tears the session down. This is + // the backstop for a support_end command that never arrived (server + // unreachable, session revoked while the client was offline): without it a + // client whose control plane vanished would sit on the user's desktop + // indefinitely. + supportDisconnectGrace = 10 * time.Minute + + // supportWatchdogInterval is how often the dead-man switch samples + // connectivity and the clock. + supportWatchdogInterval = 15 * time.Second +) + +// supportWatchdogDecision returns the notice to print before self-destructing, +// or "" to keep running. disconnectedSince is the zero time while the +// WebSocket is connected; hardExpiresAt is the zero time when the server did +// not supply one (or it could not be parsed). +func supportWatchdogDecision(now, disconnectedSince, hardExpiresAt time.Time) string { + if !hardExpiresAt.IsZero() && now.After(hardExpiresAt) { + return "This support session has expired. Closing." + } + if !disconnectedSince.IsZero() && now.Sub(disconnectedSince) >= supportDisconnectGrace { + return fmt.Sprintf("Lost contact with the Breeze server for %s. Closing.", supportDisconnectGrace) + } + return "" +} + +// supportBanner is the v1 "status window": this client has no GUI, so the +// console it was launched from IS the UI the end user sees. +const supportBanner = ` +Breeze Quick Support +───────────────────────────────────── +Connected. Waiting for your technician… +Nothing is permanently installed. Close this window +or press Ctrl+C at any time to stop sharing. +` + +// promptSupportInput asks the end user for the values the filename and flags +// did not supply. Only reached when the download filename was renamed or the +// binary was invoked directly. +func promptSupportInput(prefillServer string) (code, server string, err error) { + reader := bufio.NewReader(os.Stdin) + + server = strings.TrimSpace(prefillServer) + if server == "" { + server = strings.TrimSpace(defaultSupportServer) + } + if server == "" { + fmt.Print("Breeze server URL (e.g. https://rmm.example.com): ") + line, readErr := reader.ReadString('\n') + if readErr != nil && strings.TrimSpace(line) == "" { + return "", "", fmt.Errorf("could not read the server URL: %w", readErr) + } + server = strings.TrimSpace(line) + } + if server == "" { + return "", "", errors.New("a Breeze server URL is required") + } + if !strings.Contains(server, "://") { + server = "https://" + server + } + + for attempt := 0; attempt < 3; attempt++ { + fmt.Print("Enter the support code your technician gave you: ") + line, readErr := reader.ReadString('\n') + candidate := normalizeSupportCode(line) + if candidate != "" && supportCodeRe.MatchString(candidate) { + return candidate, server, nil + } + if readErr != nil { + return "", server, fmt.Errorf("could not read the support code: %w", readErr) + } + fmt.Println("That doesn't look like a support code — it's 9 characters, like ABC-123-XYZ.") + } + return "", server, errors.New("no valid support code entered") +} + +// supportFail prints a user-facing failure and exits nonzero. The end user is +// typically a non-technical person on the phone with a technician, so the +// message names the next action rather than the internals. +func supportFail(msg string, err error) { + if err != nil { + fmt.Fprintf(os.Stderr, "\n%s\n(%v)\n", msg, err) + } else { + fmt.Fprintf(os.Stderr, "\n%s\n", msg) + } + osExit(1) +} + +// runSupportSession is the `support` command: redeem a one-time code, enroll +// an ephemeral device into a temp workspace, serve one remote-support session +// in the foreground, then self-destruct. +// +// The whole flow deliberately avoids the machine-wide install: the config +// lives under os.TempDir(), the watchdog and updater are off, and startAgent +// skips every ProgramData path (see the cfg.SupportMode gates there). A real +// enrolled agent may be running on this same machine and must be untouched. +func runSupportSession() { + code, server, err := resolveSupportInput(os.Args[0], supportCode, serverURL) + if err != nil { + if !errors.Is(err, errNoSupportCode) { + fmt.Fprintf(os.Stderr, "%v\n", err) + } + code, server, err = promptSupportInput(server) + if err != nil { + supportFail("Quick Support could not start.", err) + return + } + } + if server == "" { + server = strings.TrimSpace(defaultSupportServer) + } + if server == "" { + supportFail("Quick Support could not start: no Breeze server URL. Re-download the client from your technician's link.", nil) + return + } + + fmt.Println("Breeze Quick Support") + fmt.Println("Connecting…") + + workDir := supportWorkDir() + if err := os.MkdirAll(workDir, 0o700); err != nil { + supportFail("Could not create a temporary working folder for this session.", err) + return + } + supportCfgFile := filepath.Join(workDir, "agent.yaml") + + cfg := config.Default() + cfg.ServerURL = server + cfg.LogFile = filepath.Join(workDir, "support.log") + // A disposable client neither installs nor is supervised by a watchdog. + cfg.Watchdog.Enabled = false + cfg.AutoUpdate = false + cfg.SupportMode = true + cfg.SupportWorkDir = workDir + // Foreground process owning the interactive desktop: capture runs + // in-process, so no SYSTEM helper has to be installed. startAgent pins + // both again from cfg.SupportMode. + cfg.IsService = false + cfg.IsHeadless = false + + // The enroll path's console chatter is for an admin reading an MSI log, + // not for the end user staring at this window. Only one command runs per + // process, so setting the package flag here is safe. + quietEnroll = true + + // Redirect structured logging into the workspace file before anything + // else runs. Until this happens the logging package's default sink is + // os.Stdout at info level, so collector and enrollment log lines would + // land in the middle of the end user's status window. quiet=true forces + // file-only; startAgent's initLogging keeps it file-only in support mode. + initEnrollLogging(cfg, true) + + hwCollector := collectors.NewHardwareCollector() + sysInfo, err := hwCollector.CollectSystemInfo() + if err != nil || sysInfo == nil { + sysInfo = &collectors.SystemInfo{} + } + hostname := strings.TrimSpace(sysInfo.Hostname) + if hostname == "" { + // The server stores this as the ephemeral device's name; a blank one + // is useless to the technician looking at the session. + if h, hErr := os.Hostname(); hErr == nil { + hostname = strings.TrimSpace(h) + } + } + osType := sysInfo.OSType + if osType == "" { + osType = runtime.GOOS + } + + resp, err := api.RedeemSupportCode(server, code, hostname, osType) + if err != nil { + _ = os.RemoveAll(workDir) + if errors.Is(err, api.ErrSupportCodeInvalid) { + // The person reading this is not technical and did not choose the + // code — name the remedy, not the status. + supportFail("That code is invalid or has expired — ask your technician for a new one.", nil) + return + } + supportFail("Could not reach the Breeze server. Check your internet connection and try again.", err) + return + } + + // The redeem response is authoritative for the control-plane URL: a + // self-hosted deployment can hand back a different (e.g. externally + // reachable) address than the one the download link used. + if strings.TrimSpace(resp.ServerURL) != "" { + cfg.ServerURL = strings.TrimSpace(resp.ServerURL) + } + cfg.SupportSessionID = resp.SessionID + + // Ctrl+C, and the SIGTERM Windows sends when the console X is clicked. + // The X gives roughly 5 seconds of grace, so every teardown path below + // must be local-only — no network I/O. + // + // Registered BEFORE enrollment, not just before the wait loop: from here + // on the workspace holds this session's device token, and the default + // SIGINT disposition (kill the process) would strand it on disk. Trading + // "Ctrl+C is instant" for "the workspace is always removed" is the right + // way round for a client whose whole promise is that it leaves nothing. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := enrollWithConfig(cfg, supportCfgFile, resp.EnrollmentKey, resp.EnrollmentSecret); err != nil { + _ = os.RemoveAll(workDir) + supportFail("Could not start the support session. Ask your technician for a new code.", err) + return + } + + if ctx.Err() != nil { + // Interrupted during enrollment — stop at the first point where doing + // so is clean, rather than bringing an agent up just to tear it down. + _ = os.RemoveAll(workDir) + fmt.Println("Cancelled. Nothing was left installed.") + return + } + + comps, err := startAgentFn(cfg) + if err != nil { + _ = os.RemoveAll(workDir) + supportFail("Could not start the support session on this computer.", err) + return + } + defer logging.StopShipper() + + // Console status lines on session start/stop. Chained (not replaced) onto + // the heartbeat's own desktop callbacks so the server still receives the + // peer-disconnect notification. + comps.hb.SetSupportSessionNotifier( + func(string) { fmt.Println("Technician connected.") }, + func(string) { fmt.Println("Technician disconnected.") }, + ) + + fmt.Print(supportBanner) + + hardExpiresAt := parseSupportHardExpiry(resp.HardExpiresAt) + notice := runSupportWatchdog(ctx, comps, hardExpiresAt) + if notice != "" { + fmt.Println() + fmt.Println(notice) + } else { + fmt.Println() + fmt.Println("Ending the support session…") + } + + // Teardown order: stop sharing and drop the connection first, then remove + // the workspace. RunSupportCleanup also schedules the self-delete of this + // executable on Windows. + shutdownAgent(comps) + comps.hb.RunSupportCleanup() + fmt.Println("Support session ended. Nothing was left installed.") +} + +// parseSupportHardExpiry parses the server's RFC3339 hard expiry. An absent or +// unparseable value yields the zero time, which the dead-man switch treats as +// "no hard expiry" — the disconnect grace is still in force, so a malformed +// timestamp degrades the backstop rather than removing it. +func parseSupportHardExpiry(raw string) time.Time { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{} + } + t, err := time.Parse(time.RFC3339, raw) + if err != nil { + log.Warn("could not parse support session hard expiry; relying on the disconnect grace alone", + "value", raw, "error", err.Error()) + return time.Time{} + } + return t +} + +// runSupportWatchdog blocks until the session must end, returning the notice +// to show the user ("" when the user themselves stopped it via ctx). +// +// It is the dead-man switch: a support client whose control plane went away +// must not linger on someone's desktop waiting for a support_end command that +// will never arrive. +func runSupportWatchdog(ctx context.Context, comps *agentComponents, hardExpiresAt time.Time) string { + ticker := time.NewTicker(supportWatchdogInterval) + defer ticker.Stop() + + var disconnectedSince time.Time + for { + select { + case <-ctx.Done(): + return "" + case now := <-ticker.C: + if comps.wsClient != nil && comps.wsClient.IsConnected() { + disconnectedSince = time.Time{} + } else if disconnectedSince.IsZero() { + disconnectedSince = now + } + if notice := supportWatchdogDecision(now, disconnectedSince, hardExpiresAt); notice != "" { + return notice + } + } + } +} diff --git a/agent/internal/agentapp/support_test.go b/agent/internal/agentapp/support_test.go new file mode 100644 index 0000000000..36f37cd155 --- /dev/null +++ b/agent/internal/agentapp/support_test.go @@ -0,0 +1,259 @@ +package agentapp + +import ( + "errors" + "testing" + "time" +) + +func TestResolveSupportInput(t *testing.T) { + cases := []struct { + name string + argv0 string + codeFlag string + serverFlag string + wantCode string + wantServer string + wantErr bool + }{ + // Explicit flags always win over whatever the filename carries — a + // technician re-running a downloaded client with --code must not be + // silently redirected to the embedded (already-consumed) code. + { + name: "flags win over filename", + argv0: `C:\Users\me\Downloads\breeze-support-KTM4H7P2X-us.2breeze.app.exe`, + codeFlag: "ABCDEFGHJ", + serverFlag: "https://eu.2breeze.app", + wantCode: "ABCDEFGHJ", + wantServer: "https://eu.2breeze.app", + }, + { + name: "filename parsed when no flags", + argv0: `C:\Users\me\Downloads\breeze-support-KTM4H7P2X-us.2breeze.app.exe`, + wantCode: "KTM4H7P2X", + wantServer: "https://us.2breeze.app", + }, + // Chrome/Edge insert a SPACE before the duplicate-download marker... + { + name: "chrome duplicate-download marker with space", + argv0: `C:\Users\me\Downloads\breeze-support-KTM4H7P2X-us.2breeze.app (1).exe`, + wantCode: "KTM4H7P2X", + wantServer: "https://us.2breeze.app", + }, + // ...Firefox does not. Both must parse or the client silently falls + // back to an interactive prompt for a code the user already "has". + { + name: "firefox duplicate-download marker without space", + argv0: `C:\Users\me\Downloads\breeze-support-KTM4H7P2X-us.2breeze.app(1).exe`, + wantCode: "KTM4H7P2X", + wantServer: "https://us.2breeze.app", + }, + { + name: "multi-digit duplicate marker", + argv0: "breeze-support-KTM4H7P2X-us.2breeze.app (12).exe", + wantCode: "KTM4H7P2X", + wantServer: "https://us.2breeze.app", + }, + { + name: "mixed-case filename normalizes the code to upper case", + argv0: "Breeze-Support-ktm4h7p2x-US.2Breeze.App.exe", + wantCode: "KTM4H7P2X", + wantServer: "https://US.2Breeze.App", + }, + // Nonstandard port: `:` is illegal in a Windows filename (Chromium + // rewrites it to `_` at save time — exactly how #2341 shipped + // silently-unenrolled installs), so the server encodes host:port as + // host_port. Without the decode the "https://" prepend produces a + // broken URL on every self-hosted/dev deployment. + { + name: "underscore port suffix decodes back to a colon", + argv0: "breeze-support-KTM4H7P2X-localhost_3000.exe", + wantCode: "KTM4H7P2X", + wantServer: "https://localhost:3000", + }, + { + name: "underscore port suffix with duplicate marker", + argv0: "breeze-support-KTM4H7P2X-rmm.acme.example_8443 (1).exe", + wantCode: "KTM4H7P2X", + wantServer: "https://rmm.acme.example:8443", + }, + // Only the LAST underscore group is a port, and only when it is + // all digits — mirrors installer_filename.go's `_([0-9]{1,5})$`. + { + name: "non-numeric underscore suffix is part of the host", + argv0: "breeze-support-KTM4H7P2X-host_evil.exe", + wantCode: "KTM4H7P2X", + wantServer: "https://host_evil", + }, + { + name: "port longer than five digits is not a port", + argv0: "breeze-support-KTM4H7P2X-host_123456.exe", + wantCode: "KTM4H7P2X", + wantServer: "https://host_123456", + }, + // A dashed display code (XXX-XXX-XXX) is what the technician reads + // out loud, so the flag must accept it verbatim. + { + name: "dashed display code from the flag is normalized", + argv0: "breeze-agent", + codeFlag: "ktm-4h7-p2x", + wantCode: "KTM4H7P2X", + }, + { + name: "server flag alone still takes the code from the filename", + argv0: "breeze-support-KTM4H7P2X-us.2breeze.app.exe", + serverFlag: "https://self.example", + wantCode: "KTM4H7P2X", + wantServer: "https://self.example", + }, + // Nothing embedded and no flags -> error so the caller prompts. + { + name: "plain agent binary with no flags errors", + argv0: "breeze-agent", + wantErr: true, + }, + { + name: "support-prefixed binary with no embedded code errors", + argv0: "breeze-support.exe", + wantErr: true, + }, + // Letters excluded from the alphabet (I/L/O/U) and digits 0/1 are + // rejected rather than redeemed as a typo'd code. + { + name: "code containing an excluded letter is rejected", + argv0: "breeze-agent", + codeFlag: "KTM4H7P2I", + wantErr: true, + }, + { + name: "code containing a zero is rejected", + argv0: "breeze-agent", + codeFlag: "KTM4H7P20", + wantErr: true, + }, + { + name: "short code is rejected", + argv0: "breeze-agent", + codeFlag: "KTM4H7P", + wantErr: true, + }, + { + name: "eight-char filename code does not match", + argv0: "breeze-support-KTM4H7P2-us.2breeze.app.exe", + wantErr: true, + }, + { + name: "non-exe extension does not match", + argv0: "breeze-support-KTM4H7P2X-us.2breeze.app.msi", + wantErr: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + code, server, err := resolveSupportInput(tc.argv0, tc.codeFlag, tc.serverFlag) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got code=%q server=%q", code, server) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if code != tc.wantCode { + t.Errorf("code: got %q, want %q", code, tc.wantCode) + } + if server != tc.wantServer { + t.Errorf("server: got %q, want %q", server, tc.wantServer) + } + }) + } +} + +// The "nothing supplied" case must be distinguishable from "supplied but +// malformed" only insofar as both send the caller to the interactive prompt; +// the sentinel exists so the prompt path can stay silent instead of printing +// a validation complaint about input the user never gave. +func TestResolveSupportInputMissingSentinel(t *testing.T) { + _, _, err := resolveSupportInput("breeze-agent", "", "") + if !errors.Is(err, errNoSupportCode) { + t.Fatalf("expected errNoSupportCode, got %v", err) + } + + _, _, err = resolveSupportInput("breeze-agent", "KTM4H7P20", "") + if errors.Is(err, errNoSupportCode) { + t.Fatal("a malformed code must not report as a missing code") + } +} + +func TestSupportWatchdogDecision(t *testing.T) { + now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) + + cases := []struct { + name string + disconnectedSince time.Time + hardExpiresAt time.Time + wantEnd bool + }{ + { + name: "connected and unexpired keeps the session alive", + wantEnd: false, + }, + { + name: "brief disconnect is tolerated", + disconnectedSince: now.Add(-2 * time.Minute), + wantEnd: false, + }, + { + name: "disconnected for the full grace ends the session", + disconnectedSince: now.Add(-supportDisconnectGrace), + wantEnd: true, + }, + { + name: "disconnected well past the grace ends the session", + disconnectedSince: now.Add(-30 * time.Minute), + wantEnd: true, + }, + { + name: "hard expiry in the future keeps the session alive", + hardExpiresAt: now.Add(time.Minute), + wantEnd: false, + }, + { + // The backstop for a lost support_end: the server's hard expiry + // ends the session even while the WebSocket is perfectly healthy. + name: "hard expiry in the past ends the session while connected", + hardExpiresAt: now.Add(-time.Second), + wantEnd: true, + }, + { + name: "zero hard expiry is never treated as expired", + hardExpiresAt: time.Time{}, + wantEnd: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + notice := supportWatchdogDecision(now, tc.disconnectedSince, tc.hardExpiresAt) + if got := notice != ""; got != tc.wantEnd { + t.Fatalf("end=%v (notice %q), want end=%v", got, notice, tc.wantEnd) + } + }) + } +} + +func TestSupportWorkDirIsNotTheRealConfigDir(t *testing.T) { + // The single most dangerous failure mode for this feature: a throwaway + // support client writing into C:\ProgramData\Breeze would clobber the + // config, secrets and agent.state of a real permanently-installed agent + // on the same machine. + dir := supportWorkDir() + if dir == "" { + t.Fatal("support work dir must not be empty") + } + if dir == configDirForSupportGuard() { + t.Fatalf("support work dir %q must never be the real agent config dir", dir) + } +} diff --git a/agent/internal/config/config.go b/agent/internal/config/config.go index d880d69356..61cca7f498 100644 --- a/agent/internal/config/config.go +++ b/agent/internal/config/config.go @@ -250,6 +250,25 @@ type Config struct { // IsHeadless is a runtime flag set when no console/TTY is attached (launchd // daemon, systemd service, etc.). Desktop commands route through IPC when set. IsHeadless bool `mapstructure:"-"` + + // SupportMode marks this process as an ephemeral Quick Support client: + // enrolled into a throwaway temp workspace, serving one remote-desktop + // session, then self-destructing. It gates off everything a disposable + // client must not do (watchdog, updater, background collector loops) and + // — critically — is the guard that lets a support_end command destroy + // this process while refusing to touch a real, permanently-installed + // agent. Runtime-only: `mapstructure:"-"` keeps it out of any config + // round-trip, so it can never be set by a file on disk. + SupportMode bool `mapstructure:"-"` + + // SupportSessionID is the server-side support session this client was + // redeemed for. Runtime-only, same reasoning as SupportMode. + SupportSessionID string `mapstructure:"-"` + + // SupportWorkDir is the temp directory holding this support client's + // config, secrets and log file. It is what the self-destruct removes, so + // it must NEVER be the real agent config dir. Runtime-only. + SupportWorkDir string `mapstructure:"-"` } // IsEnrolled reports whether cfg represents a complete enrollment — both diff --git a/agent/internal/heartbeat/handlers_support.go b/agent/internal/heartbeat/handlers_support.go new file mode 100644 index 0000000000..9b25483a13 --- /dev/null +++ b/agent/internal/heartbeat/handlers_support.go @@ -0,0 +1,173 @@ +package heartbeat + +import ( + "errors" + "fmt" + "os" + "time" + + "github.com/breeze-rmm/agent/internal/remote/tools" +) + +func init() { + handlerRegistry[tools.CmdSupportEnd] = handleSupportEnd +} + +// supportEndFlushDelay is how long the async teardown waits before removing +// the workspace and exiting, so the command result submitted by the caller +// has time to reach the server over the WebSocket. Short: the technician has +// already ended the session and the user is watching a window that should +// close. +const supportEndFlushDelay = 500 * time.Millisecond + +// Seams so the handler's contract — refuse when not in support mode, and +// never touch the filesystem or the process in that case — is unit-testable +// without deleting directories or exiting the test binary. +var ( + supportCleanupFn = supportCleanup + supportExitFn = os.Exit + // Stubbed in tests for an obvious reason: the real implementation deletes + // the running executable, which under `go test` is the test binary. + supportSelfDeleteFn = scheduleSupportSelfDelete +) + +// handleSupportEnd tears down an ephemeral Quick Support client: the +// technician ended the session (or the server revoked it), so this process +// stops sharing, deletes its temp workspace, schedules the deletion of its +// own executable, and exits. +// +// THE GUARD: a heartbeat that is not in support mode refuses outright. This +// command is a self-destruct, and support_end is delivered over the same +// command channel as everything else — a forged command, a server-side +// mis-routing to the wrong device, or a stale session id must never be able +// to wipe a real, permanently-installed agent. Support mode is a runtime-only +// config field (`mapstructure:"-"`, see config.Config.SupportMode) that is set +// exactly once, by runSupportSession, so it cannot be turned on by anything +// that arrives over the network or lands on disk. +func handleSupportEnd(h *Heartbeat, cmd Command) tools.CommandResult { + start := time.Now() + + sessionID := tools.GetPayloadString(cmd.Payload, "sessionId", "") + + if !h.supportMode { + log.Warn("REFUSED support_end: this agent is not a Quick Support client", + "sessionId", sessionID, + "commandId", cmd.ID, + ) + return tools.NewErrorResult( + errors.New("support_end refused: this agent is a permanently-installed Breeze agent, not an ephemeral Quick Support client; nothing was removed"), + time.Since(start).Milliseconds(), + ) + } + + log.Info("support_end received — ending Quick Support session and self-destructing", + "sessionId", sessionID, + "workDir", h.supportWorkDir, + ) + + go func() { + defer func() { + if r := recover(); r != nil { + log.Error("panic during Quick Support teardown", "panic", fmt.Sprint(r)) + } + }() + // Let the success result below reach the wire before the process dies. + time.Sleep(supportEndFlushDelay) + supportCleanupFn(h) + supportExitFn(0) + }() + + return tools.NewSuccessResult(map[string]string{ + "message": "support session ended; client is self-destructing", + "sessionId": sessionID, + }, time.Since(start).Milliseconds()) +} + +// supportCleanup performs the local teardown of a Quick Support client: stop +// sharing the screen, remove the temp workspace (config + secrets + log), and +// schedule the deletion of the executable itself. +// +// Everything here is local — no network I/O. The signal path in +// runSupportSession runs this same function, and a console X-close on Windows +// gives roughly 5 seconds of grace, so a blocking HTTP call here would mean +// the workspace (which holds this session's device token) survives. +// +// Never called on a permanently-installed agent: the only two callers are +// handleSupportEnd (guarded on h.supportMode) and RunSupportCleanup. +func supportCleanup(h *Heartbeat) { + if h == nil { + return + } + + if h.desktopMgr != nil { + h.desktopMgr.StopAllSessions() + } + if h.wsDesktopMgr != nil { + h.wsDesktopMgr.StopAll() + } + + // Belt-and-braces against ever removing a real install's config dir: the + // workspace is only ever the temp directory runSupportSession created. + if h.supportWorkDir != "" { + if err := os.RemoveAll(h.supportWorkDir); err != nil { + log.Warn("could not remove Quick Support workspace", "path", h.supportWorkDir, "error", err.Error()) + } + } + + supportSelfDeleteFn() +} + +// RunSupportCleanup runs the Quick Support teardown from outside this package. +// The support-mode foreground runner (internal/agentapp) calls it on Ctrl+C / +// SIGTERM so a user-initiated close destroys exactly as much as a +// server-initiated support_end does. +func (h *Heartbeat) RunSupportCleanup() { + if h == nil || !h.supportMode { + return + } + supportCleanupFn(h) +} + +// SetSupportSessionNotifier wires console callbacks fired when a remote +// desktop session connects/disconnects. The stop callback is CHAINED onto +// whatever the heartbeat already registered (the peer-disconnect notification +// to the API) rather than replacing it. +// +// Must be called right after startAgent returns and before any session can +// start; the desktop manager's hooks are plain fields set at construction. +func (h *Heartbeat) SetSupportSessionNotifier(onStart, onStop func(sessionID string)) { + if h == nil || h.desktopMgr == nil { + return + } + previousStop := h.desktopMgr.OnSessionStopped + h.desktopMgr.OnSessionStarted = onStart + h.desktopMgr.OnSessionStopped = func(sessionID string) { + if previousStop != nil { + previousStop(sessionID) + } + if onStop != nil { + onStop(sessionID) + } + } +} + +// buildSupportSelfDeleteCmdLine renders the Windows trampoline command line. +// Extracted (like buildWindowsUninstallScript) so the exact text is +// unit-testable on any host without spawning cmd.exe. +func buildSupportSelfDeleteCmdLine(exePath string) string { + return fmt.Sprintf(`cmd /C ping 127.0.0.1 -n 3 >NUL & del /f "%s"`, exePath) +} + +// scheduleSupportSelfDelete removes this executable after the process exits. +// Best-effort by nature: if it fails, the user is left with a downloaded file +// they can delete, not with anything installed or running. +func scheduleSupportSelfDelete() { + exePath, err := os.Executable() + if err != nil || exePath == "" { + log.Warn("could not resolve own executable path; skipping Quick Support self-delete", "error", fmt.Sprint(err)) + return + } + if err := startSupportSelfDelete(exePath); err != nil { + log.Warn("could not schedule Quick Support self-delete", "path", exePath, "error", err.Error()) + } +} diff --git a/agent/internal/heartbeat/handlers_support_test.go b/agent/internal/heartbeat/handlers_support_test.go new file mode 100644 index 0000000000..734f6aa9e3 --- /dev/null +++ b/agent/internal/heartbeat/handlers_support_test.go @@ -0,0 +1,284 @@ +package heartbeat + +import ( + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// withSupportSeams swaps the cleanup/exit seams for recording stubs and +// restores them afterwards. Returns accessors for what the async teardown +// goroutine did. +func withSupportSeams(t *testing.T) (cleanupCalls func() int, exitCalls func() []int) { + t.Helper() + + origCleanup, origExit, origDelete := supportCleanupFn, supportExitFn, supportSelfDeleteFn + t.Cleanup(func() { + supportCleanupFn = origCleanup + supportExitFn = origExit + supportSelfDeleteFn = origDelete + }) + supportSelfDeleteFn = func() {} + + var mu sync.Mutex + cleanups := 0 + exits := []int{} + + supportCleanupFn = func(*Heartbeat) { + mu.Lock() + defer mu.Unlock() + cleanups++ + } + supportExitFn = func(code int) { + mu.Lock() + defer mu.Unlock() + exits = append(exits, code) + } + + return func() int { + mu.Lock() + defer mu.Unlock() + return cleanups + }, func() []int { + mu.Lock() + defer mu.Unlock() + return append([]int(nil), exits...) + } +} + +func TestHandleSupportEnd(t *testing.T) { + cases := []struct { + name string + supportMode bool + payload map[string]any + wantStatus string + wantCleanup bool + wantErrPart string + }{ + { + // THE GUARD. support_end is a self-destruct delivered over the + // same command channel as everything else; a forged or misrouted + // one must never be able to wipe a real installed agent. + name: "refuses on a permanently-installed agent and destroys nothing", + supportMode: false, + payload: map[string]any{"sessionId": "11111111-1111-1111-1111-111111111111"}, + wantStatus: "failed", + wantCleanup: false, + wantErrPart: "permanently-installed", + }, + { + name: "refuses even with no payload at all", + supportMode: false, + payload: nil, + wantStatus: "failed", + wantCleanup: false, + wantErrPart: "refused", + }, + { + name: "ends the session on an ephemeral support client", + supportMode: true, + payload: map[string]any{"sessionId": "22222222-2222-2222-2222-222222222222"}, + wantStatus: "completed", + wantCleanup: true, + }, + { + name: "ends the session even when the payload omits sessionId", + supportMode: true, + payload: map[string]any{}, + wantStatus: "completed", + wantCleanup: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cleanupCalls, exitCalls := withSupportSeams(t) + + h := &Heartbeat{supportMode: tc.supportMode, supportWorkDir: t.TempDir()} + result := handleSupportEnd(h, Command{ID: "cmd-1", Type: "support_end", Payload: tc.payload}) + + if result.Status != tc.wantStatus { + t.Fatalf("status: got %q, want %q (error=%q)", result.Status, tc.wantStatus, result.Error) + } + if tc.wantErrPart != "" && !strings.Contains(result.Error, tc.wantErrPart) { + t.Errorf("error %q does not mention %q", result.Error, tc.wantErrPart) + } + if tc.wantStatus == "failed" && result.ExitCode == 0 { + // exit_code 0 must always mean "ran and exited cleanly" (#2474). + t.Error("a failed result must carry a nonzero exit code") + } + + // The teardown is asynchronous so the result can flush first. Poll + // past supportEndFlushDelay either way: the refusal cases must + // still be given a real chance to (wrongly) fire before we + // conclude they didn't. + deadline := time.Now().Add(supportEndFlushDelay + 500*time.Millisecond) + for time.Now().Before(deadline) { + if cleanupCalls() > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + + if got := cleanupCalls() > 0; got != tc.wantCleanup { + t.Fatalf("cleanup invoked=%v, want %v", got, tc.wantCleanup) + } + if got := len(exitCalls()) > 0; got != tc.wantCleanup { + t.Fatalf("process exit scheduled=%v, want %v", got, tc.wantCleanup) + } + if tc.wantCleanup { + if codes := exitCalls(); codes[0] != 0 { + t.Errorf("exit code: got %d, want 0", codes[0]) + } + } + }) + } +} + +// A refused support_end must leave the workspace on disk untouched — the +// result-status assertion above would still pass if the async goroutine ran +// and deleted things, so pin the filesystem effect directly. +func TestHandleSupportEndRefusalLeavesFilesystemUntouched(t *testing.T) { + origCleanup, origExit, origDelete := supportCleanupFn, supportExitFn, supportSelfDeleteFn + t.Cleanup(func() { + supportCleanupFn = origCleanup + supportExitFn = origExit + supportSelfDeleteFn = origDelete + }) + supportSelfDeleteFn = func() {} + supportExitFn = func(int) { t.Error("os.Exit must not be scheduled when support_end is refused") } + // Deliberately the REAL cleanup: if the guard ever regresses, this test + // fails by deleting the sentinel rather than by a stubbed counter. + supportCleanupFn = supportCleanup + + dir := t.TempDir() + sentinel := filepath.Join(dir, "agent.yaml") + if err := os.WriteFile(sentinel, []byte("agent_id: real-agent\n"), 0o600); err != nil { + t.Fatalf("seed sentinel: %v", err) + } + + h := &Heartbeat{supportMode: false, supportWorkDir: dir} + result := handleSupportEnd(h, Command{ID: "cmd-forged", Type: "support_end", Payload: map[string]any{"sessionId": "x"}}) + if result.Status != "failed" { + t.Fatalf("expected refusal, got status %q", result.Status) + } + + time.Sleep(supportEndFlushDelay + 300*time.Millisecond) + + if _, err := os.Stat(sentinel); err != nil { + t.Fatalf("refused support_end deleted a file it must never touch: %v", err) + } +} + +// RunSupportCleanup is the signal-path entry point (Ctrl+C / console close). +// It carries the same guard as the command handler so it can never be reached +// on a normal agent through some future call site. +func TestRunSupportCleanupHonoursTheSupportModeGuard(t *testing.T) { + cases := []struct { + name string + heartbeat *Heartbeat + wantCleanup bool + }{ + {"nil heartbeat is a no-op", nil, false}, + {"installed agent is refused", &Heartbeat{supportMode: false}, false}, + {"support client cleans up", &Heartbeat{supportMode: true}, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cleanupCalls, _ := withSupportSeams(t) + tc.heartbeat.RunSupportCleanup() + if got := cleanupCalls() > 0; got != tc.wantCleanup { + t.Fatalf("cleanup invoked=%v, want %v", got, tc.wantCleanup) + } + }) + } +} + +// stubSelfDelete keeps the real cleanup from deleting the test binary. +func stubSelfDelete(t *testing.T) { + t.Helper() + orig := supportSelfDeleteFn + t.Cleanup(func() { supportSelfDeleteFn = orig }) + supportSelfDeleteFn = func() {} +} + +func TestSupportCleanupRemovesOnlyItsOwnWorkspace(t *testing.T) { + stubSelfDelete(t) + root := t.TempDir() + workDir := filepath.Join(root, "breeze-support-4242") + if err := os.MkdirAll(workDir, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(workDir, "secrets.yaml"), []byte("auth_token: t\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + neighbour := filepath.Join(root, "unrelated.yaml") + if err := os.WriteFile(neighbour, []byte("x\n"), 0o600); err != nil { + t.Fatalf("seed neighbour: %v", err) + } + + supportCleanup(&Heartbeat{supportMode: true, supportWorkDir: workDir}) + + if _, err := os.Stat(workDir); !os.IsNotExist(err) { + t.Fatalf("workspace should be gone, stat err = %v", err) + } + if _, err := os.Stat(neighbour); err != nil { + t.Fatalf("cleanup removed a sibling it does not own: %v", err) + } +} + +// An empty workDir must not turn os.RemoveAll into a no-op on "" that some +// future refactor could widen into the process CWD. +func TestSupportCleanupWithEmptyWorkDirIsSafe(t *testing.T) { + stubSelfDelete(t) + supportCleanup(&Heartbeat{supportMode: true, supportWorkDir: ""}) + supportCleanup(nil) +} + +// The trampoline is passed to CreateProcess verbatim via +// SysProcAttr.CmdLine (see support_selfdelete_windows.go), so the quoting +// here is load-bearing: a path containing a space must stay one argument to +// del, and there must be no backslash-escaped quotes for cmd.exe to choke on. +func TestBuildSupportSelfDeleteCmdLine(t *testing.T) { + cases := []struct { + name string + exePath string + want string + }{ + { + name: "plain path", + exePath: `C:\Users\me\Downloads\breeze-support-KTM4H7P2X-us.2breeze.app.exe`, + want: `cmd /C ping 127.0.0.1 -n 3 >NUL & del /f "C:\Users\me\Downloads\breeze-support-KTM4H7P2X-us.2breeze.app.exe"`, + }, + { + name: "user profile containing a space stays quoted as one argument", + exePath: `C:\Users\John Smith\Downloads\breeze-support-KTM4H7P2X-us.2breeze.app.exe`, + want: `cmd /C ping 127.0.0.1 -n 3 >NUL & del /f "C:\Users\John Smith\Downloads\breeze-support-KTM4H7P2X-us.2breeze.app.exe"`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := buildSupportSelfDeleteCmdLine(tc.exePath) + if got != tc.want { + t.Fatalf("got %s\nwant %s", got, tc.want) + } + if strings.Contains(got, `\"`) { + t.Errorf("command line contains a backslash-escaped quote, which cmd.exe does not understand: %s", got) + } + if strings.Count(got, `"`) != 2 { + t.Errorf("cmd /C only strips outer quotes when the line has exactly two quote characters; got %d in %s", strings.Count(got, `"`), got) + } + }) + } +} + +func TestSupportEndIsRegistered(t *testing.T) { + if _, ok := handlerRegistry["support_end"]; !ok { + t.Fatal("support_end is not registered in handlerRegistry") + } +} diff --git a/agent/internal/heartbeat/handlers_test.go b/agent/internal/heartbeat/handlers_test.go index a5c7c5d24e..ecc1e29d15 100644 --- a/agent/internal/heartbeat/handlers_test.go +++ b/agent/internal/heartbeat/handlers_test.go @@ -114,6 +114,9 @@ var allCommandTypes = []string{ // handlers_uninstall.go init() tools.CmdSelfUninstall, + // handlers_support.go init() + tools.CmdSupportEnd, + // handlers_incident_response.go init() tools.CmdCollectEvidence, tools.CmdExecuteContainment, diff --git a/agent/internal/heartbeat/heartbeat.go b/agent/internal/heartbeat/heartbeat.go index 1cae952b53..844b0845d3 100644 --- a/agent/internal/heartbeat/heartbeat.go +++ b/agent/internal/heartbeat/heartbeat.go @@ -276,6 +276,15 @@ type Heartbeat struct { shutdownTimeout time.Duration isService bool isHeadless bool + // supportMode marks this heartbeat as belonging to an ephemeral Quick + // Support client. It is the guard on the support_end command: without it + // a forged or misrouted support_end would self-destruct a real, + // permanently-installed agent. supportWorkDir is the temp workspace that + // self-destruct removes — never the machine-wide config dir. Both are + // copied from cfg at construction and never mutated afterwards, exactly + // like isService/isHeadless. + supportMode bool + supportWorkDir string // headlessCachedAt memoizes the Linux resolver-backed headless probe used by // currentHeadless() for the outgoing heartbeat payload. Stores a // headlessCache; an atomic.Value so the heartbeat and command-handler @@ -618,6 +627,8 @@ func NewWithVersion(cfg *config.Config, version string, token *secmem.SecureStri h.accepting.Store(true) h.isService = cfg.IsService h.isHeadless = cfg.IsHeadless + h.supportMode = cfg.SupportMode + h.supportWorkDir = cfg.SupportWorkDir // Classify device role once at startup and cache system info. // CollectHardware spawns WMIC processes on Windows which can take up to diff --git a/agent/internal/heartbeat/support_selfdelete_other.go b/agent/internal/heartbeat/support_selfdelete_other.go new file mode 100644 index 0000000000..c0ecaa62ca --- /dev/null +++ b/agent/internal/heartbeat/support_selfdelete_other.go @@ -0,0 +1,15 @@ +//go:build !windows + +package heartbeat + +import "os" + +// startSupportSelfDelete removes the Quick Support executable. Unix unlinks by +// name and the running process keeps its open inode, so no trampoline is +// needed — the counterpart of the Windows implementation's cmd /C dance. +func startSupportSelfDelete(exePath string) error { + if err := os.Remove(exePath); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/agent/internal/heartbeat/support_selfdelete_windows.go b/agent/internal/heartbeat/support_selfdelete_windows.go new file mode 100644 index 0000000000..b9f75fbd48 --- /dev/null +++ b/agent/internal/heartbeat/support_selfdelete_windows.go @@ -0,0 +1,46 @@ +//go:build windows + +package heartbeat + +import ( + "os/exec" + "syscall" +) + +// createNoWindow suppresses the console window of the detached trampoline. +// The last thing a Quick Support session should do is flash a black cmd box +// on the end user's screen. Defined locally rather than pulling in +// x/sys/windows for one constant (same value as windows.CREATE_NO_WINDOW). +const createNoWindow = 0x08000000 + +// startSupportSelfDelete launches the detached self-delete trampoline: +// +// cmd /C ping 127.0.0.1 -n 3 >NUL & del /f "" +// +// The ping is a dependency-free sleep (no PowerShell, no execution policy) — +// a running .exe cannot delete itself, so the trampoline has to outlive this +// process by a couple of seconds. +// +// SysProcAttr.CmdLine is set explicitly instead of passing the script as an +// argument to exec.Command. os/exec would run the script through +// syscall.EscapeArg, which wraps it in quotes and backslash-escapes the inner +// quotes around the path (`\"C:\...\x.exe\"`). cmd.exe does not understand +// backslash-escaped quotes, and its /C "strip the outer quotes" rule only +// applies when the line contains exactly two quote characters — with four it +// tries to execute the whole quoted script as a program name and fails. Any +// path containing a space (C:\Users\John Smith\Downloads\...) needs those +// inner quotes, so the escaped form is not an option: build the command line +// verbatim. +func startSupportSelfDelete(exePath string) error { + cmd := exec.Command("cmd") + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | createNoWindow, + HideWindow: true, + CmdLine: buildSupportSelfDeleteCmdLine(exePath), + } + if err := cmd.Start(); err != nil { + return err + } + _ = cmd.Process.Release() + return nil +} diff --git a/agent/internal/remote/desktop/session.go b/agent/internal/remote/desktop/session.go index 6f2cb5b31f..e1d5d78999 100644 --- a/agent/internal/remote/desktop/session.go +++ b/agent/internal/remote/desktop/session.go @@ -183,6 +183,12 @@ type SessionManager struct { // disconnected and allow reconnection. OnSessionStopped func(sessionID string) + // OnSessionStarted is the symmetric hook: called when a WebRTC peer + // connection reaches Connected, i.e. the viewer is actually watching. + // Quick Support uses it to tell the end user "Technician connected." + // Invoked on its own goroutine, like OnSessionStopped. + OnSessionStarted func(sessionID string) + // lastDesktopState caches the most recently broadcast desktop state so // late-connecting viewers can receive an initial state when their control // channel opens. Protected by mu. diff --git a/agent/internal/remote/desktop/session_webrtc.go b/agent/internal/remote/desktop/session_webrtc.go index 38bc5c3987..661cae46b9 100644 --- a/agent/internal/remote/desktop/session_webrtc.go +++ b/agent/internal/remote/desktop/session_webrtc.go @@ -567,6 +567,9 @@ func (m *SessionManager) StartSession(sessionID string, offer string, iceServers case webrtc.PeerConnectionStateConnected: logSelectedPair("connected") session.startStreaming() + if m.OnSessionStarted != nil { + go m.OnSessionStarted(sessionID) + } case webrtc.PeerConnectionStateDisconnected: // Transient: a brief network blip enters this state. We deliberately diff --git a/agent/internal/remote/tools/types.go b/agent/internal/remote/tools/types.go index 761a7c244e..ec6b3dee8e 100644 --- a/agent/internal/remote/tools/types.go +++ b/agent/internal/remote/tools/types.go @@ -202,6 +202,12 @@ const ( // Self-uninstall (remote wipe) CmdSelfUninstall = "self_uninstall" + // Quick Support session teardown. Only an ephemeral support-mode client + // acts on this; a permanently-installed agent refuses it (see + // handleSupportEnd) so a forged or misrouted command cannot destroy a + // real install. + CmdSupportEnd = "support_end" + // Hyper-V VM backup management CmdHypervDiscover = "hyperv_discover" CmdHypervBackup = "hyperv_backup" diff --git a/agent/internal/websocket/client.go b/agent/internal/websocket/client.go index ff45c6e46d..b3400f8da7 100644 --- a/agent/internal/websocket/client.go +++ b/agent/internal/websocket/client.go @@ -138,9 +138,9 @@ type Client struct { // read pump waiting for lane space. Overridable in tests; defaults to // defaultOrderedEnqueueTimeout. orderedEnqueueTimeout time.Duration - stopOnce sync.Once - isRunning bool - runningMu sync.RWMutex + stopOnce sync.Once + isRunning bool + runningMu sync.RWMutex // OnConnected, if set, is invoked synchronously from the read pump once // the server's "connected" welcome frame has been parsed — i.e. after a @@ -260,6 +260,16 @@ func (c *Client) UpdateTLSConfig(tlsCfg *tls.Config) { c.tlsConfigMu.Unlock() } +// IsConnected reports whether a live WebSocket connection is currently held. +// conn is set on a successful dial and cleared by closeCurrentConn, so this is +// "connected right now", not "has ever connected". Used by the Quick Support +// dead-man switch to detect a control plane that has gone away for good. +func (c *Client) IsConnected() bool { + c.connMu.RLock() + defer c.connMu.RUnlock() + return c.conn != nil +} + // ForceReconnect closes the active connection so the reconnect loop re-dials. func (c *Client) ForceReconnect() { c.closeCurrentConn(false) diff --git a/agent/pkg/api/client.go b/agent/pkg/api/client.go index 624fc8dfa5..906a66dc6d 100644 --- a/agent/pkg/api/client.go +++ b/agent/pkg/api/client.go @@ -422,6 +422,85 @@ func CancelBootstrap(serverURL, childEnrollmentKey string) (*CancelBootstrapResp return &result, nil } +// SupportRedeemRequest is the body of POST /api/v1/support/redeem — the +// Quick Support (ad-hoc remote support) code redemption. Unauthenticated: +// the one-time code IS the credential, exactly like bootstrap-token +// redemption (see CancelBootstrap above). +type SupportRedeemRequest struct { + Code string `json:"code"` + Hostname string `json:"hostname"` + OSType string `json:"osType"` +} + +// SupportRedeemResponse mirrors the server's 200 response. EnrollmentSecret +// is a PER-KEY secret unique to this support session — not the org-shared +// AGENT_ENROLLMENT_SECRET — and is presented to /agents/enroll through the +// ordinary EnrollRequest.EnrollmentSecret body field. +type SupportRedeemResponse struct { + ServerURL string `json:"serverUrl"` + EnrollmentKey string `json:"enrollmentKey"` + EnrollmentSecret string `json:"enrollmentSecret"` + SessionID string `json:"sessionId"` + // RFC3339. The client treats this as a hard stop even if the server's + // support_end command never arrives. + HardExpiresAt string `json:"hardExpiresAt"` +} + +// ErrSupportCodeInvalid is returned for the server's 404 — an unknown, +// expired, or already-redeemed code (the endpoint deliberately does not +// distinguish them). +// +// Terse and lowercase per Go convention; the end-user wording lives at the +// display site in agentapp, which is where the audience is actually known. +var ErrSupportCodeInvalid = errors.New("support code invalid or expired") + +// RedeemSupportCode exchanges a one-time Quick Support code for an ephemeral +// enrollment. Package-level (not a *Client method) because at this point the +// process holds no device token and no agent ID — the code is the only +// credential, the same trust level as bootstrap redemption. +// +// The 404 case is mapped to ErrSupportCodeInvalid so the caller can print a +// human sentence to an end user who mistyped a code; every other non-200 is +// surfaced as *ErrHTTPStatus for diagnostics. +func RedeemSupportCode(server, code, hostname, osType string) (*SupportRedeemResponse, error) { + url := strings.TrimRight(server, "/") + "/api/v1/support/redeem" + body, err := json.Marshal(&SupportRedeemRequest{Code: code, Hostname: hostname, OSType: osType}) + if err != nil { + return nil, fmt.Errorf("failed to marshal support redeem request: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create support redeem request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second, CheckRedirect: refuseUntrustedRedirect} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send support redeem request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if err != nil { + return nil, fmt.Errorf("failed to read support redeem response body: %w", err) + } + + if resp.StatusCode == http.StatusNotFound { + return nil, ErrSupportCodeInvalid + } + if resp.StatusCode != http.StatusOK { + return nil, &ErrHTTPStatus{StatusCode: resp.StatusCode, Body: string(bodyBytes)} + } + + var result SupportRedeemResponse + if err := json.Unmarshal(bodyBytes, &result); err != nil { + return nil, fmt.Errorf("failed to decode support redeem response: %w", err) + } + return &result, nil +} + // UninstallIntentResponse mirrors the server's POST // /agents/:id/uninstall-intent 200 response body. type UninstallIntentResponse struct { diff --git a/apps/api/migrations/2026-08-13-a-quick-support-sessions.sql b/apps/api/migrations/2026-08-13-a-quick-support-sessions.sql new file mode 100644 index 0000000000..463cfead98 --- /dev/null +++ b/apps/api/migrations/2026-08-13-a-quick-support-sessions.sql @@ -0,0 +1,110 @@ +-- Quick Support — one-time code ad-hoc remote sessions. +-- Spec: docs/superpowers/specs/2026-07-06-one-off-support-session-design.md +-- Plan: docs/superpowers/plans/2026-07-06-quick-support-phase1.md +-- +-- A tech generates a short one-time code; the end user runs a downloaded client +-- (the Go agent in `support` mode) that enrolls an EPHEMERAL device into a +-- hidden per-partner 'quick_support' org. The existing remote-desktop stack +-- (remote_sessions, WebRTC broker, consent, viewer, audit) is then reused +-- unchanged. Everything self-destructs at session end. +-- +-- support_sessions is RLS Shape 1 (direct org_id) — auto-discovered by +-- rls-coverage.integration.test.ts, so it needs no allowlist entry. It DOES +-- need registering in the cascade/export lists (done in the same PR): +-- - CORE_ORG_CASCADE_DELETE_ORDER (services/tenantCascade.ts) +-- - DEVICE_DETACH_DEVICE_ID_TABLES (routes/devices/core.ts — device_id is +-- ON DELETE SET NULL, so the row survives device deletion like tickets) +-- - CORE_TENANT_EXPORT_POLICY (services/tenantExportPolicyRegistry.ts) +-- +-- Idempotent: ADD COLUMN / CREATE TABLE / CREATE INDEX IF NOT EXISTS, guarded +-- type creation, DROP POLICY IF EXISTS then CREATE. Re-applying is a no-op. +-- No inner BEGIN/COMMIT — autoMigrate wraps each file in one transaction. + +-- ============================================ +-- Step 1: new org type for the hidden per-partner Quick Support org +-- ============================================ +-- PG12+ allows ADD VALUE inside a transaction, but the new value cannot be +-- USED in the same transaction (55P04 "unsafe use of new value") — and +-- autoMigrate wraps each file in ONE transaction. That is why the partial +-- index on `type = 'quick_support'` lives in the -b- file. Nothing in THIS +-- file may reference the new value. +ALTER TYPE org_type ADD VALUE IF NOT EXISTS 'quick_support'; + +-- ============================================ +-- Step 2: ephemeral device marker +-- ============================================ +-- Ephemeral devices are excluded from partner license counts, device +-- listings, billing rollups and alert evaluation, and are purged by the +-- reaper 6h after their session ends. +ALTER TABLE devices + ADD COLUMN IF NOT EXISTS is_ephemeral BOOLEAN NOT NULL DEFAULT FALSE; + +-- ============================================ +-- Step 3: support_sessions +-- ============================================ +DO $$ +BEGIN + CREATE TYPE support_session_status AS ENUM ('pending','claimed','ready','ended','expired'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +CREATE TABLE IF NOT EXISTS support_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- the partner's hidden Quick Support org (never a real customer org) + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + created_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- SHA-256 hex of the one-time code; plaintext is shown once at creation + -- and never stored. + code_hash VARCHAR(64) NOT NULL UNIQUE, + code_expires_at TIMESTAMPTZ NOT NULL, + status support_session_status NOT NULL DEFAULT 'pending', + -- hard cap so no session can outlive the day even if nothing else fires + hard_expires_at TIMESTAMPTZ NOT NULL, + -- SET NULL (not CASCADE): the session row is the audit trail and must + -- survive the ephemeral device being purged. + device_id UUID REFERENCES devices(id) ON DELETE SET NULL, + -- reporting only — carries no tenancy effect whatsoever + attributed_org_id UUID REFERENCES organizations(id) ON DELETE SET NULL, + attribution_label TEXT, + claimed_at TIMESTAMPTZ, + claimed_from_ip TEXT, + ended_at TIMESTAMPTZ, + ended_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_support_sessions_reaper + ON support_sessions(status, hard_expires_at); +CREATE INDEX IF NOT EXISTS idx_support_sessions_device + ON support_sessions(device_id); + +-- ============================================ +-- Step 4: link the redeemed child enrollment key back to its session +-- ============================================ +ALTER TABLE enrollment_keys + ADD COLUMN IF NOT EXISTS support_session_id UUID + REFERENCES support_sessions(id) ON DELETE CASCADE; + +-- ============================================ +-- Step 5: RLS — Shape 1 (direct org_id) +-- ============================================ +-- breeze_has_org_access() already short-circuits to TRUE under system scope +-- (0008-tenant-rls.sql), which is what lets the public redeem path and the +-- reaper write through withSystemDbAccessContext. +ALTER TABLE support_sessions ENABLE ROW LEVEL SECURITY; +ALTER TABLE support_sessions FORCE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS breeze_org_isolation_select ON support_sessions; +DROP POLICY IF EXISTS breeze_org_isolation_insert ON support_sessions; +DROP POLICY IF EXISTS breeze_org_isolation_update ON support_sessions; +DROP POLICY IF EXISTS breeze_org_isolation_delete ON support_sessions; + +CREATE POLICY breeze_org_isolation_select ON support_sessions + FOR SELECT USING (public.breeze_has_org_access(org_id)); +CREATE POLICY breeze_org_isolation_insert ON support_sessions + FOR INSERT WITH CHECK (public.breeze_has_org_access(org_id)); +CREATE POLICY breeze_org_isolation_update ON support_sessions + FOR UPDATE USING (public.breeze_has_org_access(org_id)) + WITH CHECK (public.breeze_has_org_access(org_id)); +CREATE POLICY breeze_org_isolation_delete ON support_sessions + FOR DELETE USING (public.breeze_has_org_access(org_id)); diff --git a/apps/api/migrations/2026-08-13-b-quick-support-org-index.sql b/apps/api/migrations/2026-08-13-b-quick-support-org-index.sql new file mode 100644 index 0000000000..373c9a7fc7 --- /dev/null +++ b/apps/api/migrations/2026-08-13-b-quick-support-org-index.sql @@ -0,0 +1,15 @@ +-- Quick Support — partial unique index on the new org_type enum value. +-- +-- MUST be a separate file from -a-: Postgres forbids USING an enum value that +-- was added in the current transaction (55P04 "unsafe use of new value"), and +-- autoMigrate wraps each migration file in ONE transaction. File -a- commits +-- the 'quick_support' value; this file is then free to reference it. +-- +-- Enforces exactly one hidden Quick Support org per partner, which is what +-- makes getOrCreateQuickSupportOrg()'s onConflictDoNothing + re-select safe +-- against a concurrent-create race. +-- +-- Idempotent. No inner BEGIN/COMMIT. + +CREATE UNIQUE INDEX IF NOT EXISTS organizations_partner_quick_support_uniq + ON organizations(partner_id) WHERE type = 'quick_support'; diff --git a/apps/api/src/__tests__/integration/quickSupportChain.integration.test.ts b/apps/api/src/__tests__/integration/quickSupportChain.integration.test.ts new file mode 100644 index 0000000000..c579f57f54 --- /dev/null +++ b/apps/api/src/__tests__/integration/quickSupportChain.integration.test.ts @@ -0,0 +1,580 @@ +/** + * Quick Support — the whole chain against REAL Postgres. + * + * provision hidden org -> mint session -> redeem code -> enroll ephemeral + * device -> licence accounting -> end session -> reap. + * + * Every mocked unit suite in this feature (`quickSupportOrg.test.ts`, + * `supportPublic.test.ts`, `enrollment.test.ts`, `quickSupportEnd.test.ts`, + * `quickSupportReaper.test.ts`) mocks `../db` wholesale, so none of them can + * prove any of the properties that actually matter here: + * + * - Idempotent org provisioning depends on a PARTIAL UNIQUE INDEX + * (`organizations_partner_quick_support_uniq`) plus `onConflictDoNothing`. + * A mock has no index. + * - "A code is strictly single-use" is an atomic `UPDATE ... WHERE + * status = 'pending'` — its whole meaning is the row-level guard, which a + * chainable mock resolves unconditionally. + * - The reaper's purge relies on `support_sessions.device_id` being + * `ON DELETE SET NULL`, i.e. on the real FK action. A mock cannot have one. + * - Licence counting is a real `count(*)` over a real `IN (subquery)` with a + * real `is_ephemeral = false` predicate. + * + * Where a real HTTP handler exists (POST /support/redeem, POST /agents/enroll) + * this suite drives THE ROUTE, not a re-implementation, following the + * mount-a-real-Hono-app pattern from `enrollmentCollision.integration.test.ts`. + */ +import './setup'; +import { afterEach, describe, expect, it } from 'vitest'; +import { Hono } from 'hono'; +import { and, eq } from 'drizzle-orm'; +import { + db, + runOutsideDbContext, + withSystemDbAccessContext, +} from '../../db'; +import { + devices, + enrollmentKeys, + organizations, + partners, + sites, + supportSessions, +} from '../../db/schema'; +import { createPartner, createOrganization, createSite, createUser } from './db-utils'; +import { getOrCreateQuickSupportOrg } from '../../services/quickSupportOrg'; +import { + SUPPORT_CODE_TTL_MINUTES, + SUPPORT_SESSION_HARD_CAP_HOURS, + generateSupportCode, + hashSupportCode, +} from '../../services/quickSupportCode'; +import { endSupportSession } from '../../services/quickSupportEnd'; +import { reapOnce } from '../../jobs/quickSupportReaper'; +import { countContractDevices } from '../../services/contractQuantities'; +import { hashEnrollmentKey } from '../../services/enrollmentKeySecurity'; +import { deleteDeviceCascade, type DeviceDeletionTx } from '../../services/deviceDeletion'; +import { supportPublicRoutes } from '../../routes/supportPublic'; +import { enrollmentRoutes } from '../../routes/agents/enrollment'; + +// ============================================ +// Fixtures / cleanup bookkeeping +// ============================================ + +const createdDevices: string[] = []; +const createdOrgs: string[] = []; +const createdPartners: string[] = []; + +/** Everything here runs OUTSIDE a request, so escalate the same way jobs do. */ +function asSystem(fn: () => Promise): Promise { + return runOutsideDbContext(() => withSystemDbAccessContext(fn)); +} + +/** + * Explicitly drop every feature-owned row this file creates. + * + * The tenant ROOTS (organizations / partners / users / audit_logs) are left to + * setup.ts's global `beforeEach` TRUNCATE ... CASCADE, which already lists all + * four. Deleting them here would abort anyway: `audit_logs.org_id` is an FK + * with no ON DELETE action, and the redeem + enroll routes under test both + * write audit rows, so an org DELETE raises 23503 while audit_logs is + * append-only (REVOKE DELETE) and cannot be cleared first. + */ +afterEach(async () => { + if (createdPartners.length === 0 && createdOrgs.length === 0 && createdDevices.length === 0) return; + await asSystem(async () => { + // Devices first: deleteDeviceCascade also NULLs support_sessions.device_id. + for (const deviceId of createdDevices) { + await db.transaction(async (tx) => { + await deleteDeviceCascade(tx as unknown as DeviceDeletionTx, deviceId); + }); + } + for (const orgId of createdOrgs) { + await db.delete(enrollmentKeys).where(eq(enrollmentKeys.orgId, orgId)); + await db.delete(supportSessions).where(eq(supportSessions.orgId, orgId)); + await db.delete(devices).where(eq(devices.orgId, orgId)); + await db.delete(sites).where(eq(sites.orgId, orgId)); + } + }); + createdDevices.length = 0; + createdOrgs.length = 0; + createdPartners.length = 0; +}); + +function redeemApp(): Hono { + const app = new Hono(); + app.route('/support', supportPublicRoutes); + return app; +} + +function enrollApp(): Hono { + const app = new Hono(); + app.route('/agents', enrollmentRoutes); + return app; +} + +async function postJson(app: Hono, path: string, body: unknown): Promise { + return app.request(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +function uniqueEmail(): string { + return `qs-chain-${Date.now()}-${Math.random().toString(36).slice(2, 10)}@example.com`; +} + +/** + * A partner with its hidden Quick Support org provisioned and a technician + * user to own the sessions. + */ +async function seedSupportTenant() { + const partner = await createPartner(); + createdPartners.push(partner.id); + const { orgId, siteId } = await getOrCreateQuickSupportOrg(partner.id); + createdOrgs.push(orgId); + // users.email is UNIQUE and db-utils' default is `test-${Date.now()}`, which + // collides when two tenants land in the same millisecond. + const tech = await createUser({ partnerId: partner.id, email: uniqueEmail() }); + return { partnerId: partner.id, orgId, siteId, techId: tech.id }; +} + +/** Mirrors POST /remote/support-sessions' insert (routes/remote/supportSessions.ts). */ +async function mintSession(orgId: string, techId: string): Promise<{ id: string; code: string }> { + const code = generateSupportCode(); + const now = Date.now(); + const [row] = await asSystem(() => + db + .insert(supportSessions) + .values({ + orgId, + createdByUserId: techId, + codeHash: hashSupportCode(code), + codeExpiresAt: new Date(now + SUPPORT_CODE_TTL_MINUTES * 60_000), + hardExpiresAt: new Date(now + SUPPORT_SESSION_HARD_CAP_HOURS * 3_600_000), + }) + .returning(), + ); + if (!row) throw new Error('mintSession: no row'); + return { id: row.id, code }; +} + +function redeemBody(code: string, hostname: string) { + return { code, hostname, osType: 'windows' as const }; +} + +function enrollBody(rawKey: string, rawSecret: string | null, hostname: string) { + return { + enrollmentKey: rawKey, + ...(rawSecret ? { enrollmentSecret: rawSecret } : {}), + hostname, + osType: 'windows' as const, + osVersion: '11', + architecture: 'x86_64', + agentVersion: '1.0.0-test', + }; +} + +async function readSession(id: string) { + return asSystem(async () => { + const [row] = await db.select().from(supportSessions).where(eq(supportSessions.id, id)); + return row ?? null; + }); +} + +async function readDevice(id: string) { + return asSystem(async () => { + const [row] = await db.select().from(devices).where(eq(devices.id, id)); + return row ?? null; + }); +} + +/** Full redeem -> enroll, returning the ephemeral device id. */ +async function redeemAndEnroll(sessionCode: string, hostname: string): Promise<{ deviceId: string; redeem: Record }> { + const redeemRes = await postJson(redeemApp(), '/support/redeem', redeemBody(sessionCode, hostname)); + expect(redeemRes.status).toBe(200); + const redeem = (await redeemRes.json()) as Record; + + const enrollRes = await postJson( + enrollApp(), + '/agents/enroll', + enrollBody(redeem.enrollmentKey!, redeem.enrollmentSecret!, hostname), + ); + expect(enrollRes.status).toBe(201); + const enrolled = (await enrollRes.json()) as { deviceId: string }; + createdDevices.push(enrolled.deviceId); + return { deviceId: enrolled.deviceId, redeem }; +} + +// ============================================ +// 1. Hidden-org provisioning +// ============================================ + +describe('getOrCreateQuickSupportOrg — hidden per-partner org', () => { + it('is idempotent: two calls for the same partner return the same org and site, and only one row exists', async () => { + const partner = await createPartner(); + createdPartners.push(partner.id); + + const first = await getOrCreateQuickSupportOrg(partner.id); + createdOrgs.push(first.orgId); + const second = await getOrCreateQuickSupportOrg(partner.id); + + expect(second.orgId).toBe(first.orgId); + expect(second.siteId).toBe(first.siteId); + + const rows = await asSystem(() => + db + .select({ id: organizations.id, type: organizations.type }) + .from(organizations) + .where(and(eq(organizations.partnerId, partner.id), eq(organizations.type, 'quick_support'))), + ); + expect(rows).toHaveLength(1); + expect(rows[0]?.id).toBe(first.orgId); + + const siteRows = await asSystem(() => + db.select({ id: sites.id }).from(sites).where(eq(sites.orgId, first.orgId)), + ); + expect(siteRows).toHaveLength(1); + }); + + it('gives DIFFERENT partners different hidden orgs', async () => { + const a = await createPartner(); + const b = await createPartner(); + createdPartners.push(a.id, b.id); + + const orgA = await getOrCreateQuickSupportOrg(a.id); + const orgB = await getOrCreateQuickSupportOrg(b.id); + createdOrgs.push(orgA.orgId, orgB.orgId); + + expect(orgA.orgId).not.toBe(orgB.orgId); + }); +}); + +// ============================================ +// 2 + 3. Redemption and single-use +// ============================================ + +describe('POST /support/redeem — claim + child key minting', () => { + it('flips pending -> claimed and mints a single-use child key with its own secret', async () => { + const tenant = await seedSupportTenant(); + const session = await mintSession(tenant.orgId, tenant.techId); + + const res = await postJson(redeemApp(), '/support/redeem', redeemBody(session.code, 'qs-host-1')); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.enrollmentKey).toMatch(/^[0-9a-f]{64}$/); + expect(body.enrollmentSecret).toMatch(/^[0-9a-f]{64}$/); + expect(body.sessionId).toBe(session.id); + + const claimed = await readSession(session.id); + expect(claimed?.status).toBe('claimed'); + expect(claimed?.claimedAt).toBeInstanceOf(Date); + + const keys = await asSystem(() => + db.select().from(enrollmentKeys).where(eq(enrollmentKeys.supportSessionId, session.id)), + ); + expect(keys).toHaveLength(1); + const key = keys[0]!; + expect(key.orgId).toBe(tenant.orgId); + expect(key.siteId).toBe(tenant.siteId); + expect(key.maxUsage).toBe(1); + expect(key.usageCount).toBe(0); + // Its OWN secret, not the global AGENT_ENROLLMENT_SECRET. + expect(key.keySecretHash).toMatch(/^[0-9a-f]{64}$/); + // The stored key is a hash of the raw key handed to the client, never the + // raw value itself. + expect(key.key).toBe(hashEnrollmentKey(body.enrollmentKey!)); + expect(key.key).not.toBe(body.enrollmentKey); + expect(key.expiresAt).toBeInstanceOf(Date); + }); + + it('a SECOND redemption of the same code fails and mints NO second key (strictly single-use)', async () => { + const tenant = await seedSupportTenant(); + const session = await mintSession(tenant.orgId, tenant.techId); + + const first = await postJson(redeemApp(), '/support/redeem', redeemBody(session.code, 'qs-host-1')); + expect(first.status).toBe(200); + + const second = await postJson(redeemApp(), '/support/redeem', redeemBody(session.code, 'qs-host-2')); + expect(second.status).toBe(404); + // Indistinguishable from an unknown code — no confirmation the code existed. + expect(await second.json()).toEqual({ error: 'invalid or expired code' }); + + // The load-bearing assertion: exactly ONE credential ever existed for + // this code. A second key here is a second live agent on a stranger's + // machine. + const keys = await asSystem(() => + db.select({ id: enrollmentKeys.id }).from(enrollmentKeys).where(eq(enrollmentKeys.supportSessionId, session.id)), + ); + expect(keys).toHaveLength(1); + + const row = await readSession(session.id); + expect(row?.status).toBe('claimed'); + }); + + it('CONCURRENT redemptions of the same code: exactly one wins', async () => { + const tenant = await seedSupportTenant(); + const session = await mintSession(tenant.orgId, tenant.techId); + + // Genuinely overlapping requests through the real pool — the atomic + // `WHERE status='pending'` guard is the only thing separating them. + const results = await Promise.all([ + postJson(redeemApp(), '/support/redeem', redeemBody(session.code, 'qs-race-a')), + postJson(redeemApp(), '/support/redeem', redeemBody(session.code, 'qs-race-b')), + ]); + const statuses = results.map((r) => r.status).sort(); + expect(statuses).toEqual([200, 404]); + + const keys = await asSystem(() => + db.select({ id: enrollmentKeys.id }).from(enrollmentKeys).where(eq(enrollmentKeys.supportSessionId, session.id)), + ); + expect(keys).toHaveLength(1); + }); +}); + +// ============================================ +// 4. Ephemeral enrollment +// ============================================ + +describe('POST /agents/enroll with a support child key', () => { + it('mints an EPHEMERAL device and links it back to the session', async () => { + const tenant = await seedSupportTenant(); + const session = await mintSession(tenant.orgId, tenant.techId); + + const { deviceId } = await redeemAndEnroll(session.code, 'qs-enroll-host'); + + const device = await readDevice(deviceId); + expect(device?.isEphemeral).toBe(true); + expect(device?.orgId).toBe(tenant.orgId); + expect(device?.siteId).toBe(tenant.siteId); + + const linked = await readSession(session.id); + expect(linked?.deviceId).toBe(deviceId); + + // The single-use child key is now spent. + const [key] = await asSystem(() => + db.select().from(enrollmentKeys).where(eq(enrollmentKeys.supportSessionId, session.id)), + ); + expect(key?.usageCount).toBe(1); + }); +}); + +// ============================================ +// 5. Licence accounting +// ============================================ + +describe('licence counting — ephemeral devices are not endpoints', () => { + it('a partner AT its maxDevices cap can still take a support session, but a normal enrollment is refused', async () => { + const partner = await createPartner(); + createdPartners.push(partner.id); + const custOrg = await createOrganization({ partnerId: partner.id }); + createdOrgs.push(custOrg.id); + const custSite = await createSite({ orgId: custOrg.id }); + + // Ordinary (non-support) key for the customer org. + const rawKey = `cap-key-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + await asSystem(() => + db.insert(enrollmentKeys).values({ + orgId: custOrg.id, + siteId: custSite.id, + name: 'Cap Test Key', + key: hashEnrollmentKey(rawKey), + keySecretHash: null, + usageCount: 0, + maxUsage: null, + expiresAt: null, + }), + ); + + // One real endpoint, then the cap set to exactly that. + // /agents/enroll answers 201 Created, not 200 — matching the route and its + // unit suite. + const firstRes = await postJson(enrollApp(), '/agents/enroll', enrollBody(rawKey, null, 'cap-host-1')); + expect(firstRes.status).toBe(201); + const first = (await firstRes.json()) as { deviceId: string }; + createdDevices.push(first.deviceId); + + await asSystem(() => db.update(partners).set({ maxDevices: 1 }).where(eq(partners.id, partner.id))); + + // Quick Support tenant for the SAME partner. + const { orgId: qsOrgId } = await getOrCreateQuickSupportOrg(partner.id); + createdOrgs.push(qsOrgId); + const tech = await createUser({ partnerId: partner.id, email: uniqueEmail() }); + const session = await mintSession(qsOrgId, tech.id); + + // (a) Support enrollment succeeds at the cap — a tech must always be able + // to help a caller. + const { deviceId: ephemeralId } = await redeemAndEnroll(session.code, 'cap-support-host'); + const ephemeral = await readDevice(ephemeralId); + expect(ephemeral?.isEphemeral).toBe(true); + + // (b) A NORMAL enrollment at the same cap is refused — proving (a) was an + // exemption for support, not a broken/absent limit check. + const deniedRes = await postJson(enrollApp(), '/agents/enroll', enrollBody(rawKey, null, 'cap-host-2')); + expect(deniedRes.status).toBe(403); + const denied = (await deniedRes.json()) as { code?: string; currentDevices?: number; maxDevices?: number }; + expect(denied.code).toBe('DEVICE_LIMIT_REACHED'); + // The ephemeral device did NOT inflate the count. + expect(denied.currentDevices).toBe(1); + expect(denied.maxDevices).toBe(1); + + // (c) countContractDevices feeds contract AND invoice line quantities — + // an ephemeral device reaching it would bill a customer for a machine + // that existed for twenty minutes and was never theirs. + await asSystem(async () => { + expect(await countContractDevices(qsOrgId, null)).toBe(0); + expect(await countContractDevices(custOrg.id, null)).toBe(1); + }); + }); +}); + +// ============================================ +// 6. Teardown +// ============================================ + +describe('endSupportSession', () => { + it("revokes all three device token hashes, decommissions the device and marks the session 'ended'", async () => { + const tenant = await seedSupportTenant(); + const session = await mintSession(tenant.orgId, tenant.techId); + const { deviceId } = await redeemAndEnroll(session.code, 'qs-end-host'); + + const before = await readDevice(deviceId); + // Anti-vacuity: the hashes must actually be SET before we assert they + // were cleared. + expect(before?.agentTokenHash).toBeTruthy(); + expect(before?.watchdogTokenHash).toBeTruthy(); + expect(before?.helperTokenHash).toBeTruthy(); + expect(before?.status).toBe('online'); + + const result = await endSupportSession(session.id, 'tech'); + expect(result.ended).toBe(true); + + const after = await readDevice(deviceId); + expect(after?.agentTokenHash).toBeNull(); + expect(after?.watchdogTokenHash).toBeNull(); + expect(after?.helperTokenHash).toBeNull(); + expect(after?.status).toBe('decommissioned'); + + const ended = await readSession(session.id); + expect(ended?.status).toBe('ended'); + expect(ended?.endedReason).toBe('tech'); + expect(ended?.endedAt).toBeInstanceOf(Date); + // The audit trail still points at the device until the reaper purges it. + expect(ended?.deviceId).toBe(deviceId); + }); + + it('is idempotent: a second end on a terminal session reports ended:false', async () => { + const tenant = await seedSupportTenant(); + const session = await mintSession(tenant.orgId, tenant.techId); + const { deviceId } = await redeemAndEnroll(session.code, 'qs-end-twice'); + + expect((await endSupportSession(session.id, 'tech')).ended).toBe(true); + expect((await endSupportSession(session.id, 'tech')).ended).toBe(false); + + const row = await readSession(session.id); + expect(row?.endedReason).toBe('tech'); + expect(row?.deviceId).toBe(deviceId); + }); +}); + +// ============================================ +// 7 + 8. The reaper +// ============================================ + +describe('reapOnce — ephemeral device purge', () => { + it('purges the ephemeral device 6h after the session ended, while the session row SURVIVES with device_id NULL', async () => { + const tenant = await seedSupportTenant(); + const session = await mintSession(tenant.orgId, tenant.techId); + const { deviceId } = await redeemAndEnroll(session.code, 'qs-reap-host'); + + await endSupportSession(session.id, 'tech'); + + // Backdate past PURGE_AFTER_ENDED_MS (6h). + await asSystem(() => + db + .update(supportSessions) + .set({ endedAt: new Date(Date.now() - 7 * 3_600_000) }) + .where(eq(supportSessions.id, session.id)), + ); + + expect(await readDevice(deviceId)).not.toBeNull(); // control + + await reapOnce(); + + expect(await readDevice(deviceId)).toBeNull(); + + // The session row is the audit trail — ON DELETE SET NULL, never CASCADE. + const survivor = await readSession(session.id); + expect(survivor).not.toBeNull(); + expect(survivor?.deviceId).toBeNull(); + expect(survivor?.status).toBe('ended'); + expect(survivor?.endedReason).toBe('tech'); + }); + + it('REFUSES to purge a non-ephemeral device even when a session row points at it', async () => { + const tenant = await seedSupportTenant(); + + // A real, managed customer device — is_ephemeral = false. + const custOrg = await createOrganization({ partnerId: tenant.partnerId }); + createdOrgs.push(custOrg.id); + const custSite = await createSite({ orgId: custOrg.id }); + const [realDevice] = await asSystem(() => + db + .insert(devices) + .values({ + orgId: custOrg.id, + siteId: custSite.id, + agentId: `real-agent-${Date.now()}`, + hostname: 'not-ephemeral-host', + osType: 'windows', + osVersion: '11', + architecture: 'x86_64', + agentVersion: '1.0.0-test', + isEphemeral: false, + }) + .returning(), + ); + if (!realDevice) throw new Error('failed to seed non-ephemeral device'); + createdDevices.push(realDevice.id); + + // A corrupted/mis-linked session pointing at that real device, already + // long past its purge deadline. + const bad = await mintSession(tenant.orgId, tenant.techId); + await asSystem(() => + db + .update(supportSessions) + .set({ + status: 'ended', + deviceId: realDevice.id, + endedAt: new Date(Date.now() - 7 * 3_600_000), + endedReason: 'tech', + }) + .where(eq(supportSessions.id, bad.id)), + ); + + // A legitimate ephemeral session in the SAME pass, so a reaper that + // simply no-opped could not make this test pass. + const good = await mintSession(tenant.orgId, tenant.techId); + const { deviceId: ephemeralId } = await redeemAndEnroll(good.code, 'qs-reap-good'); + await endSupportSession(good.id, 'tech'); + await asSystem(() => + db + .update(supportSessions) + .set({ endedAt: new Date(Date.now() - 7 * 3_600_000) }) + .where(eq(supportSessions.id, good.id)), + ); + + await reapOnce(); + + // The real device survives, still attached to the bad session row. + const stillThere = await readDevice(realDevice.id); + expect(stillThere).not.toBeNull(); + expect(stillThere?.isEphemeral).toBe(false); + expect((await readSession(bad.id))?.deviceId).toBe(realDevice.id); + + // ...and the pass genuinely ran. + expect(await readDevice(ephemeralId)).toBeNull(); + expect((await readSession(good.id))?.deviceId).toBeNull(); + }); +}); diff --git a/apps/api/src/__tests__/integration/site-scope-coverage.integration.test.ts b/apps/api/src/__tests__/integration/site-scope-coverage.integration.test.ts index 251d5f2025..aec0f25678 100644 --- a/apps/api/src/__tests__/integration/site-scope-coverage.integration.test.ts +++ b/apps/api/src/__tests__/integration/site-scope-coverage.integration.test.ts @@ -190,6 +190,14 @@ const SITE_SCOPE_INPUT_EXEMPT: ReadonlySet = new Set([ // ---- Genuinely site-gated via the cross-file `getDeviceWithOrgCheck` // helper (routes/remote/helpers.ts), which the file-local scanner can't see. 'routes/remote/sessions.ts:POST /sessions', + // ---- Quick Support: no caller-supplied device input. Device ids are + // derived from support_sessions rows RLS already authorized, and the only + // device datum returned is the boolean `deviceOnline`. The devices are + // ephemeral rows in the hidden per-partner 'quick_support' org, reachable + // only at PARTNER scope; `allowedSiteIds` is an org-scope-only axis, so a + // site-restricted caller sees no sessions here in the first place. + 'routes/remote/supportSessions.ts:GET /support-sessions', + 'routes/remote/supportSessions.ts:GET /support-sessions/:id', // ---- Org-wide AGGREGATE reads: return only counts/summaries (no // per-device rows), so no cross-site device data is disclosed (returns // re-verified 2026-05-31). NB: totals still span the org incl. other @@ -231,6 +239,15 @@ const SITE_SCOPE_INPUT_EXEMPT_USER_SESSION_OK: ReadonlySet = new Set