Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
dc6ac14
docs: add Quick Support one-time code session design spec
Jul 6, 2026
b2ef0e0
docs: add public code-entry page (/quick) and soft-check endpoint to …
Jul 6, 2026
24c561b
docs: quick support phase 1 implementation plan + spec implementation…
Jul 6, 2026
45985be
docs(quick-support): 2026-07-17 review adjustments to phase 1 plan + …
Aug 4, 2026
28e2c25
feat(api): quick support schema — support_sessions, ephemeral devices…
Aug 4, 2026
948f26b
feat(shared): quick support code validators + generator
Aug 4, 2026
a6a0d3d
feat(api): quick support hidden org provisioning
Aug 4, 2026
6bc52ca
feat(api): quick support session create/list/get routes
Aug 4, 2026
b2b5605
feat(api): public quick support check/redeem endpoints
Aug 4, 2026
9428921
feat(api): mark quick support session ready on agent connect
Aug 4, 2026
186c91b
feat(api): ephemeral enrollment via quick support keys
Aug 4, 2026
6e265ae
feat(api): quick support client download with code-embedded filename
Aug 4, 2026
d0af6ef
fix(alerts): never alert on a quick support device going offline
Aug 4, 2026
d6789c8
feat(api): end quick support session with agent self-destruct + token…
Aug 4, 2026
ba57aff
refactor(api): extract deleteDeviceCascade so the reaper reuses one d…
Aug 4, 2026
96be5ad
feat(api): quick support reaper worker
Aug 5, 2026
980e4fb
feat(agent): quick support mode (tier 1) + support_end self-destruct
Aug 5, 2026
44f1a6e
feat(web): quick support technician page + public /quick landing page
Aug 5, 2026
abd6495
feat(api): grant the hidden quick support org to partner-scope callers
Aug 5, 2026
d6f1b05
fix(billing,vuln,abuse): keep quick support devices out of invoices, …
Aug 5, 2026
ba489b0
fix(jobs): keep quick support ephemeral devices out of every fleet wo…
Aug 5, 2026
bd0148b
fix(api): exclude quick support org + ephemeral devices from user-fac…
Aug 5, 2026
76261d2
test(api): quick support RLS + end-to-end chain integration tests
Aug 5, 2026
6135f3d
docs: add Quick Support section to the remote access page
Aug 5, 2026
abb1e01
docs: drop the test VM address from the quick support plan
Aug 5, 2026
30d5167
style(agent): gofmt websocket/client.go
Aug 5, 2026
d20e295
fix(agent): make ErrSupportCodeInvalid a conventional Go sentinel
Aug 5, 2026
e13f513
test(api): exempt the quick support reads from the site-scope gate
Aug 5, 2026
ccfcd04
Merge remote-tracking branch 'origin/main' into ToddHebebrand/quick-a…
Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
256 changes: 195 additions & 61 deletions agent/internal/agentapp/main.go

Large diffs are not rendered by default.

487 changes: 487 additions & 0 deletions agent/internal/agentapp/support.go

Large diffs are not rendered by default.

259 changes: 259 additions & 0 deletions agent/internal/agentapp/support_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
19 changes: 19 additions & 0 deletions agent/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading