diff --git a/.github/go-server-context.md b/.github/go-server-context.md index ee026066..6a9d5188 100644 --- a/.github/go-server-context.md +++ b/.github/go-server-context.md @@ -14,11 +14,14 @@ both `hbbs` (signal) and `hbbr` (relay). ### Legal Basis -- `.proto` files (`rendezvous.proto`, `message.proto`) have **no copyright headers** — they - define the wire protocol, not copyrightable expression. -- AGPL-3.0 covers the Rust **source code**, not the protocol itself. -- Clean-room = we implement from the **protocol specification** (protobuf messages, framing, - port layout), never copying Rust code. +- Do not infer the provenance or copyright status of `.proto` files from their + protocol role or file headers. Existing schemas are subject to the provenance + gate in `docs/important/support-agent-provenance.md`. +- Do not copy external source, generated artifacts, comments, or test fixtures. + Implement only from BetterDesk-owned specifications and independently + authored black-box test vectors. +- Do not make clean-room or relicensing claims in code, documentation, or + release notes until the provenance register has been reviewed. --- diff --git a/.github/workflows/support-agent-ci.yml b/.github/workflows/support-agent-ci.yml new file mode 100644 index 00000000..dea4b4b1 --- /dev/null +++ b/.github/workflows/support-agent-ci.yml @@ -0,0 +1,141 @@ +name: Support Agent CI + +on: + push: + branches: [main, dev] + paths: + - 'betterdesk-support-agent/**' + - 'betterdesk-agent/**' + - 'betterdesk-server/protos/**' + - 'web-nodejs/protos/**' + - 'docs/important/support-agent-provenance.md' + - 'docs/important/support-agent-conformance.md' + - 'THIRD_PARTY_NOTICES.md' + - 'docs/features/WEB_REMOTE_CLIENT_PLAN.md' + - '.github/go-server-context.md' + - '.github/workflows/support-agent-ci.yml' + - 'web-nodejs/scripts/check-cleanroom-provenance.js' + - 'web-nodejs/scripts/sync-protocol-schemas.js' + pull_request: + paths: + - 'betterdesk-support-agent/**' + - 'betterdesk-agent/**' + - 'betterdesk-server/protos/**' + - 'web-nodejs/protos/**' + - 'docs/important/support-agent-provenance.md' + - 'docs/important/support-agent-conformance.md' + - 'THIRD_PARTY_NOTICES.md' + - 'docs/features/WEB_REMOTE_CLIENT_PLAN.md' + - '.github/go-server-context.md' + - '.github/workflows/support-agent-ci.yml' + - 'web-nodejs/scripts/check-cleanroom-provenance.js' + - 'web-nodejs/scripts/sync-protocol-schemas.js' + +permissions: + contents: read + +jobs: + provenance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + - name: Verify compatibility provenance policy + run: node web-nodejs/scripts/check-cleanroom-provenance.js && node web-nodejs/scripts/sync-protocol-schemas.js + + support-agent: + name: Support Agent (Linux) + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: betterdesk-support-agent + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: betterdesk-support-agent/go.mod + cache-dependency-path: betterdesk-support-agent/go.sum + - name: Install Fyne build dependencies + run: sudo apt-get update && sudo apt-get install -y libgl1-mesa-dev xorg-dev libwayland-dev libdecor-0-dev + - name: go vet + run: go vet ./... + - name: go test with race detector + run: go test -race -count=1 -timeout=10m ./... + + shared-agent: + name: Shared Agent (Linux) + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: betterdesk-agent + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: betterdesk-agent/go.mod + cache-dependency-path: betterdesk-agent/go.sum + - name: go vet + run: go vet ./... + - name: go test with race detector + run: go test -race -count=1 -timeout=10m ./... + + framing-fuzz: + name: Framing fuzz smoke (${{ matrix.name }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - name: support-agent peer frames + module: betterdesk-support-agent + package: ./signalhost + target: '^FuzzPeerFrameFraming$' + - name: support-agent Annex-B frames + module: betterdesk-support-agent + package: ./signalhost + target: '^FuzzAnnexBFraming$' + - name: shared-agent stream frames + module: betterdesk-agent + package: ./agent + target: '^FuzzEncodedStreamFraming$' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: ${{ matrix.module }}/go.mod + cache-dependency-path: ${{ matrix.module }}/go.sum + - name: Fuzz parser and framing boundary + working-directory: ${{ matrix.module }} + run: go test -run '^$' -fuzz '${{ matrix.target }}' -fuzztime=15s -parallel=1 -timeout=2m ${{ matrix.package }} + + platform: + name: Native platform tests (${{ matrix.name }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - name: Windows + os: windows-latest + - name: macOS + os: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: betterdesk-support-agent/go.mod + cache-dependency-path: | + betterdesk-support-agent/go.sum + betterdesk-agent/go.sum + - name: Test shared-agent platform implementation + working-directory: betterdesk-agent + run: go test -count=1 -timeout=10m ./... + - name: Test support-agent platform implementation + working-directory: betterdesk-support-agent + run: go test -count=1 -timeout=10m ./... diff --git a/.github/workflows/web-nodejs-ci.yml b/.github/workflows/web-nodejs-ci.yml index 857f2cbb..720589cf 100644 --- a/.github/workflows/web-nodejs-ci.yml +++ b/.github/workflows/web-nodejs-ci.yml @@ -40,5 +40,8 @@ jobs: - name: Check browser JavaScript syntax run: npm run check:frontend + - name: Verify protocol schema artifacts + run: npm run protocols:check + - name: Run tests run: npm run test:ci diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..b0397edd --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,53 @@ +# BetterDesk third-party notices + +This file is the release index for third-party material distributed with +BetterDesk. It complements, but does not replace, the license text and notices +required by each dependency. + +## Source manifests + +The authoritative dependency manifests for a release are: + +- `betterdesk-server/go.mod` and `betterdesk-server/go.sum` +- `betterdesk-agent/go.mod` and `betterdesk-agent/go.sum` +- `betterdesk-support-agent/go.mod` and `betterdesk-support-agent/go.sum` +- `web-nodejs/package.json` and `web-nodejs/package-lock.json` +- `rdclient-desktop/package.json`, Cargo manifests, and + `rdclient-desktop/vendor/wry/LICENSE.spdx` + +The build/release process must archive resolved dependency lists from those +manifests with the matching source revision as an SBOM. + +## Support Agent distribution + +The Support Agent is built with Go and Fyne. Its code declares the dependency +licenses in [LICENSE](LICENSE); a release must also include the notices supplied +by Fyne and all resolved Go modules when they are distributed in a form that +requires them. + +External system tools such as FFmpeg, GStreamer, PipeWire, xdg-desktop-portal, +xdotool, and ydotool are not silently relicensed by BetterDesk. A package may +only bundle one of them after its license, redistributability, provenance, and +security-update path are recorded in the release SBOM. + +## Compatibility components + +Any desktop-client compatibility adapter is subject to +[support-agent-provenance.md](docs/important/support-agent-provenance.md). +Before distribution, its release record must include: + +1. the compatibility specification revision; +2. a source-provenance review result; +3. generated-schema hashes; +4. third-party notices for its direct and transitive dependencies; and +5. a signed artifact checksum. + +## Maintainer checklist + +- Do not remove vendor license files from release inputs. +- Do not declare a dependency's license from memory; use its resolved package + metadata and shipped notices. +- Do not add external source, generated protocol artifacts, or binary blobs + without an entry in the SBOM and provenance review. +- Update this index when a release begins bundling a new runtime component. + diff --git a/betterdesk-agent/agent/agent.go b/betterdesk-agent/agent/agent.go index f3e45781..ae129824 100644 --- a/betterdesk-agent/agent/agent.go +++ b/betterdesk-agent/agent/agent.go @@ -51,7 +51,10 @@ type Agent struct { fileHandlers sync.Map // session_id → context.CancelFunc desktopStreams sync.Map // session_id → *DesktopStreamer desktopFlags sync.Map // session_id → *desktopSessionFlags - audioStreams sync.Map // session_id → *AudioStreamer + // desktopControlMu keeps stream lifecycle and control flags coherent so a + // late control cannot outlive the desktop session it targeted. + desktopControlMu sync.Mutex + audioStreams sync.Map // session_id → *AudioStreamer // Consent system: when require_consent=true, handleDesktopStart prints // CONSENT_REQUEST to stdout and waits on a channel stored here. @@ -481,6 +484,12 @@ func (a *Agent) executeWidgetCommand(widgetID, action string, value any) (any, e return a.captureAndSendScreenshot() } case "sys_clipboard": + if !a.cfg.Clipboard { + return nil, fmt.Errorf("clipboard capability disabled") + } + if a.isClipboardOperationBlocked("") { + return nil, fmt.Errorf("clipboard is disabled for an active remote desktop session") + } if action == "query" { text := a.clipboard.Get() return map[string]string{"text": text}, nil @@ -730,7 +739,8 @@ func (a *Agent) handleClipboardSet(msg *Message) { if err := json.Unmarshal(msg.Payload, &p); err != nil { return } - if a.sessionFlags(p.SessionID).isClipboardDisabled() { + if a.isClipboardOperationBlocked(p.SessionID) { + log.Printf("[clipboard] Ignored clipboard_set while disabled for session %s", normalizeDesktopSessionID(p.SessionID)) return } if p.Format == "text" { @@ -744,11 +754,13 @@ func (a *Agent) handleClipboardSet(msg *Message) { func (a *Agent) handleClipboardGet(msg *Message) { var p struct { RequestID string `json:"request_id"` + SessionID string `json:"session_id"` } _ = json.Unmarshal(msg.Payload, &p) resp := map[string]any{ "request_id": p.RequestID, + "session_id": p.SessionID, "format": "text", } @@ -758,6 +770,12 @@ func (a *Agent) handleClipboardGet(msg *Message) { a.sendMessage("clipboard_data", resp) return } + if a.isClipboardOperationBlocked(p.SessionID) { + resp["error"] = "clipboard disabled for this remote desktop session" + resp["data"] = "" + a.sendMessage("clipboard_data", resp) + return + } resp["data"] = a.clipboard.Get() a.sendMessage("clipboard_data", resp) @@ -780,7 +798,6 @@ func (a *Agent) cleanupSessions() { }) a.desktopStreams.Range(func(key, value any) bool { value.(*DesktopStreamer).Stop() - a.desktopStreams.Delete(key) return true }) } diff --git a/betterdesk-agent/agent/audio.go b/betterdesk-agent/agent/audio.go index da968e85..4f5d2960 100644 --- a/betterdesk-agent/agent/audio.go +++ b/betterdesk-agent/agent/audio.go @@ -32,6 +32,15 @@ func (a *AudioStreamer) Stop() { <-a.done } +// audioCodecCapability reports the only audio codec the agent can safely +// advertise. The current platform capture commands are best-effort probes and +// emit muxed Ogg chunks, not the packetized Opus media contract CDAP expects. +// Until a backend produces that contract reliably and is declared in the +// manifest, audio remains unavailable rather than being falsely negotiated. +func (a *Agent) audioCodecCapability() string { + return CodecNone +} + func (a *Agent) handleAudioStart(msg *Message) { var p struct { SessionID string `json:"session_id"` @@ -40,6 +49,14 @@ func (a *Agent) handleAudioStart(msg *Message) { if p.SessionID == "" { p.SessionID = "default" } + if a.audioCodecCapability() == CodecNone { + log.Printf("[audio] audio_start rejected: audio is not supported by this agent build") + _ = a.sendMessage("audio_end", map[string]any{ + "session_id": p.SessionID, + "reason": "audio capability unavailable", + }) + return + } if old, loaded := a.audioStreams.LoadAndDelete(p.SessionID); loaded { old.(*AudioStreamer).Stop() diff --git a/betterdesk-agent/agent/codec.go b/betterdesk-agent/agent/codec.go index 0d443aec..f2677ff4 100644 --- a/betterdesk-agent/agent/codec.go +++ b/betterdesk-agent/agent/codec.go @@ -38,6 +38,7 @@ import ( // Codec identifiers used on the wire (desktop_meta.format) and in config. const ( + CodecNone = "none" CodecMJPEG = "mjpeg" CodecWebP = "webp" CodecH264 = "h264" @@ -50,11 +51,11 @@ const ( const ( HwAuto = "auto" HwNone = "none" - HwVAAPI = "vaapi" // Intel/AMD on Linux - HwNVENC = "nvenc" // NVIDIA, all OS - HwQSV = "qsv" // Intel QuickSync - HwAMF = "amf" // AMD on Windows - HwVideoToolbox = "videotoolbox" // Apple + HwVAAPI = "vaapi" // Intel/AMD on Linux + HwNVENC = "nvenc" // NVIDIA, all OS + HwQSV = "qsv" // Intel QuickSync + HwAMF = "amf" // AMD on Windows + HwVideoToolbox = "videotoolbox" // Apple ) // frameMode describes how the encoded output is delimited on the wire. @@ -91,10 +92,10 @@ type encoderPlan struct { // encoder names to try, hardware first. The first candidate that ffmpeg both // lists and (for hardware) survives a 1-frame validation encode is used. var encoderCandidates = map[string][]string{ - CodecH264: {"h264_nvenc", "h264_qsv", "h264_vaapi", "h264_amf", "h264_videotoolbox", "libx264"}, - CodecVP9: {"vp9_vaapi", "vp9_qsv", "libvpx-vp9"}, - CodecAV1: {"av1_nvenc", "av1_qsv", "av1_vaapi", "av1_amf", "libsvtav1", "libaom-av1"}, - CodecWebP: {"libwebp"}, + CodecH264: {"h264_nvenc", "h264_qsv", "h264_vaapi", "h264_amf", "h264_videotoolbox", "libx264"}, + CodecVP9: {"vp9_vaapi", "vp9_qsv", "libvpx-vp9"}, + CodecAV1: {"av1_nvenc", "av1_qsv", "av1_vaapi", "av1_amf", "libsvtav1", "libaom-av1"}, + CodecWebP: {"libwebp"}, CodecMJPEG: {"mjpeg"}, } diff --git a/betterdesk-agent/agent/codec_framing.go b/betterdesk-agent/agent/codec_framing.go index b57942a7..7f3ef212 100644 --- a/betterdesk-agent/agent/codec_framing.go +++ b/betterdesk-agent/agent/codec_framing.go @@ -348,6 +348,9 @@ func av1IsKeyframe(f []byte) bool { hasSize := (hdr >> 1) & 0x01 i++ if extFlag == 1 { + if i >= len(f) { + return false + } i++ // extension header byte } var size int diff --git a/betterdesk-agent/agent/codec_framing_fuzz_test.go b/betterdesk-agent/agent/codec_framing_fuzz_test.go new file mode 100644 index 00000000..2c59ef39 --- /dev/null +++ b/betterdesk-agent/agent/codec_framing_fuzz_test.go @@ -0,0 +1,56 @@ +package agent + +import ( + "bytes" + "context" + "testing" +) + +const maxEncodedFrameFuzzInput = 128 * 1024 + +func TestAV1KeyframeReaderRejectsTruncatedExtensionHeader(t *testing.T) { + if av1IsKeyframe([]byte{0x06}) { + t.Fatal("truncated AV1 extension header was treated as a keyframe") + } +} + +// FuzzEncodedStreamFraming covers the bounded image, Annex-B, and IVF splitters +// together with the VP9/AV1 keyframe header readers used by the CDAP stream. +func FuzzEncodedStreamFraming(f *testing.F) { + f.Add([]byte{}) + f.Add([]byte("RIFF\x04\x00\x00\x00WEBP")) + f.Add([]byte{ + 0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1f, + 0x00, 0x00, 0x01, 0x65, 0x88, 0x84, + 0x00, 0x00, 0x01, 0x61, 0x9a, + }) + f.Add(append([]byte("DKIF"), make([]byte, 28)...)) + f.Add([]byte{0x06}) // truncated AV1 OBU with extension and size flags + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > maxEncodedFrameFuzzInput { + t.Skip() + } + + assertFrame := func(frame []byte) { + if len(frame) > len(data) { + t.Fatalf("frame length = %d, input length = %d", len(frame), len(data)) + } + } + ctx := context.Background() + + readImageFrames(ctx, bytes.NewReader(data), CodecMJPEG, assertFrame) + readImageFrames(ctx, bytes.NewReader(data), CodecWebP, assertFrame) + readAnnexBFrames(ctx, bytes.NewReader(data), func(frame []byte, _ bool) { + assertFrame(frame) + }) + readIVFFrames(ctx, bytes.NewReader(data), CodecVP9, func(frame []byte, _ bool) { + assertFrame(frame) + }) + readIVFFrames(ctx, bytes.NewReader(data), CodecAV1, func(frame []byte, _ bool) { + assertFrame(frame) + }) + _ = vp9IsKeyframe(data) + _ = av1IsKeyframe(data) + }) +} diff --git a/betterdesk-agent/agent/config.go b/betterdesk-agent/agent/config.go index 1b31e576..026f92d9 100644 --- a/betterdesk-agent/agent/config.go +++ b/betterdesk-agent/agent/config.go @@ -50,10 +50,15 @@ type Config struct { HwAccel string `json:"hw_accel,omitempty"` // Lifecycle callbacks for embedded UIs (support agent, tests). - ConsentHandler func(sessionID, operator string) bool - SessionStartHandler func(sessionID, operator, mode string) - SessionEndHandler func(sessionID string) - ChatMessageHandler func(fromName, text string) + ConsentHandler func(sessionID, operator string) bool + // SessionAuthorizeHandler is called before consent and before any desktop + // stream is started. Embedded products use it to verify a short-lived, + // target-bound server grant without coupling this shared agent to a + // particular authorization implementation. + SessionAuthorizeHandler func(sessionID, operator, transport string, capabilities []string, grant string) error + SessionStartHandler func(sessionID, operator, mode string) + SessionEndHandler func(sessionID string) + ChatMessageHandler func(fromName, text string) // ── TLS hardening (Phase 4) ────────────────────────────────────── // EnforceTLS rejects plaintext ws:// for any non-local host (returns an diff --git a/betterdesk-agent/agent/desktop.go b/betterdesk-agent/agent/desktop.go index 1a22f5a9..952b4957 100644 --- a/betterdesk-agent/agent/desktop.go +++ b/betterdesk-agent/agent/desktop.go @@ -128,15 +128,20 @@ func (a *Agent) handleDesktopStart(msg *Message) { } var p struct { - SessionID string `json:"session_id"` - Quality int `json:"quality"` - FPS int `json:"fps"` - OperatorName string `json:"operator_name"` + SessionID string `json:"session_id"` + Quality int `json:"quality"` + FPS int `json:"fps"` + OperatorName string `json:"operator_name"` // VideoCodec is the operator's preferred codec ("", "auto" or concrete). VideoCodec string `json:"video_codec"` // Codecs is the list of codecs the operator can DECODE. A legacy operator // omits this, in which case only JPEG is assumed and used. Codecs []string `json:"codecs"` + // SessionGrant and Capabilities are injected by the BetterDesk CDAP + // gateway for passive Support Agent sessions. They are intentionally + // opaque to this shared package. + SessionGrant string `json:"session_grant,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` } if err := json.Unmarshal(msg.Payload, &p); err != nil { return @@ -154,6 +159,21 @@ func (a *Agent) handleDesktopStart(msg *Message) { if p.OperatorName == "" { p.OperatorName = "operator" } + if a.cfg.SessionAuthorizeHandler != nil { + if err := a.cfg.SessionAuthorizeHandler( + p.SessionID, + p.OperatorName, + "cdap", + p.Capabilities, + p.SessionGrant, + ); err != nil { + _ = a.sendMessage("desktop_authorization_denied", map[string]any{ + "session_id": p.SessionID, + }) + log.Printf("[desktop] authorization denied for session %s: %v", p.SessionID, err) + return + } + } // ── Consent gate ───────────────────────────────────────────────────────── if !a.requestRemoteConsent(p.SessionID, p.OperatorName, "desktop") { @@ -165,13 +185,22 @@ func (a *Agent) handleDesktopStart(msg *Message) { log.Printf("[desktop] Consent granted for session %s", p.SessionID) // Stop any existing session for this session ID. - if old, loaded := a.desktopStreams.LoadAndDelete(p.SessionID); loaded { + a.desktopControlMu.Lock() + old, loaded := a.desktopStreams.LoadAndDelete(p.SessionID) + a.desktopControlMu.Unlock() + if loaded { old.(*DesktopStreamer).Stop() } + // Desktop controls are scoped to one capture session. Never carry a + // previous operator's block/clipboard/lock choices into a new session + // that happens to reuse the same ID. + a.desktopControlMu.Lock() + a.resetSessionFlags(p.SessionID) ctx, cancel := context.WithCancel(a.ctx) streamer := newDesktopStreamer(p.SessionID, cancel) a.desktopStreams.Store(p.SessionID, streamer) + a.desktopControlMu.Unlock() // Notify embedded UI or Tauri wrapper about active session. modeLabel := sessionModeLabel(a.cfg.RequireConsent) @@ -183,11 +212,12 @@ func (a *Agent) handleDesktopStart(msg *Message) { p.SessionID, p.OperatorName, modeLabel) } - // Send the monitor list as soon as the session is accepted so the - // operator's toolbar can populate its dropdown before any frames - // arrive. Errors here are non-fatal — single-monitor placeholder is - // emitted by enumerateMonitors() on platforms without a backend. - monitors := enumerateMonitors() + // This stream has one stable capture source. Physical-monitor switching + // would require a coordinated capture-process restart and coordinate + // remapping; neither is safe to claim until every platform backend + // implements it. Advertise the actual single source rather than a list + // whose selected state cannot be enforced. + monitors := currentCaptureMonitorList() _ = a.sendMessage("monitor_list", map[string]any{ "session_id": p.SessionID, "monitors": monitors, @@ -208,7 +238,7 @@ func (a *Agent) handleDesktopStart(msg *Message) { go func() { defer close(streamer.done) - defer a.desktopStreams.Delete(p.SessionID) + defer a.finishDesktopStream(p.SessionID, streamer) // Always emit SESSION_END (matched to the SESSION_START above) when // the streamer goroutine exits, no matter the reason — stop request, // operator disconnect, or watchdog failure. The overlay state machine @@ -268,19 +298,34 @@ func (a *Agent) handleDesktopStop(msg *Message) { if err := json.Unmarshal(msg.Payload, &p); err != nil || p.SessionID == "" { p.SessionID = "default" } + p.SessionID = normalizeDesktopSessionID(p.SessionID) - if sess, loaded := a.desktopStreams.LoadAndDelete(p.SessionID); loaded { + a.desktopControlMu.Lock() + sess, loaded := a.desktopStreams.LoadAndDelete(p.SessionID) + a.desktopControlMu.Unlock() + if loaded { sess.(*DesktopStreamer).Stop() log.Printf("[desktop] Stopped session %s", p.SessionID) } } -// handleMonitorSelect updates the active monitor index for a streaming -// session and re-emits the monitor list so the operator's UI reflects the -// new selection. Region-aware capture switching (cropping ffmpeg's input -// to the chosen monitor) is wired in a follow-up — for now any selected -// monitor still streams the whole virtual desktop, but the toolbar's -// active state is correct so the dropdown is usable. +// currentCaptureMonitorList describes the one source this stream can +// truthfully expose. It intentionally does not reuse enumerateMonitors: +// listing physical displays implies that monitor_select can switch to them. +func currentCaptureMonitorList() []MonitorInfo { + return []MonitorInfo{{ + Index: 0, + Name: "Current capture", + Primary: true, + }} +} + +// handleMonitorSelect preserves the actual active state. Capture pipelines +// are long-lived processes; changing their crop/input at runtime would need a +// coordinated restart and input-coordinate remapping that is not implemented +// consistently across Windows, macOS, X11, and Wayland. Do not claim a +// requested physical monitor is active when the existing process still emits +// the original source. func (a *Agent) handleMonitorSelect(msg *Message) { var p struct { SessionID string `json:"session_id"` @@ -292,24 +337,19 @@ func (a *Agent) handleMonitorSelect(msg *Message) { if p.SessionID == "" { p.SessionID = "default" } + p.SessionID = normalizeDesktopSessionID(p.SessionID) if _, ok := a.desktopStreams.Load(p.SessionID); !ok { return } - monitors := enumerateMonitors() - active := p.Index - if active < 0 || active >= len(monitors) { - active = 0 - } - if sess, ok := a.desktopStreams.Load(p.SessionID); ok { - sess.(*DesktopStreamer).monitor.Store(int32(active)) - } + monitors := currentCaptureMonitorList() _ = a.sendMessage("monitor_list", map[string]any{ "session_id": p.SessionID, "monitors": monitors, - "active": active, + "active": 0, }) - log.Printf("[desktop] Active monitor for session %s set to %d (%s)", - p.SessionID, active, monitors[active].Name) + if p.Index != 0 { + log.Printf("[desktop] Ignored monitor_select=%d for session %s: capture switching is unavailable", p.Index, p.SessionID) + } } // monitorCropFilter returns an ffmpeg -vf crop filter for the given monitor index. @@ -344,7 +384,6 @@ func (a *Agent) captureAndSendScreenshot() (any, error) { // handleCodecOffer responds with the agent's actual encoding capabilities, // probed live (available ffmpeg encoders + validated hardware back-ends). The // operator picks one of these and the agent honours it at desktop_start time. -// Audio is never supported in os_agent mode. func (a *Agent) handleCodecOffer(msg *Message) { var p struct { SessionID string `json:"session_id"` @@ -352,17 +391,20 @@ func (a *Agent) handleCodecOffer(msg *Message) { _ = json.Unmarshal(msg.Payload, &p) caps := a.videoCapabilities() - primary := "" - if len(caps) > 0 { - primary = caps[0] - } + _ = a.sendMessage("codec_answer", codecAnswerPayload(p.SessionID, caps, a.audioCodecCapability())) +} - _ = a.sendMessage("codec_answer", map[string]any{ - "session_id": p.SessionID, +func codecAnswerPayload(sessionID string, videoCaps []string, audioCodec string) map[string]any { + primary := "" + if len(videoCaps) > 0 { + primary = videoCaps[0] + } + return map[string]any{ + "session_id": sessionID, "video_codec": primary, - "video_codecs": caps, - "audio_codec": "opus", - }) + "video_codecs": videoCaps, + "audio_codec": audioCodec, + } } // ── Streaming logic ────────────────────────────────────────────────────── diff --git a/betterdesk-agent/agent/desktop_control_policy.go b/betterdesk-agent/agent/desktop_control_policy.go new file mode 100644 index 00000000..7077da89 --- /dev/null +++ b/betterdesk-agent/agent/desktop_control_policy.go @@ -0,0 +1,32 @@ +package agent + +import "strings" + +// normalizeDesktopControl keeps control names stable before they reach an OS +// action. A remote peer must not bypass a deny rule through casing or padding. +func normalizeDesktopControl(control string) string { + return strings.ToLower(strings.TrimSpace(control)) +} + +// unsupportedDesktopControlReason returns a fixed, non-secret reason for +// controls the agent cannot truthfully enforce. The policy is intentionally +// local: a valid desktop session or remote peer must not turn a missing host +// implementation into a privileged action. +func unsupportedDesktopControlReason(control string) string { + switch normalizeDesktopControl(control) { + case "privacy_mode": + return "privacy mode is unavailable on this host" + case "block_input": + // The legacy session flag blocks remote injection, not the local user's + // keyboard and mouse. It must not be presented as local-input blocking. + return "local input blocking is unavailable on this host" + case "restart_device": + // Restart is not bound to the passive desktop grant and must remain + // unavailable until a dedicated, policy-authorized host path exists. + return "remote restart is unavailable on this host" + case "recording": + return "recording is unavailable on this host" + default: + return "" + } +} diff --git a/betterdesk-agent/agent/desktop_linux.go b/betterdesk-agent/agent/desktop_linux.go index 541ea83f..33208e1d 100644 --- a/betterdesk-agent/agent/desktop_linux.go +++ b/betterdesk-agent/agent/desktop_linux.go @@ -4,11 +4,9 @@ package agent import ( "context" - "encoding/json" "fmt" - "log" "os" - "os/exec" + "path/filepath" "strings" "time" @@ -29,42 +27,106 @@ func isWaylandSession() bool { // hasX11Display returns true when an X11 display is available. // This includes XWayland sessions running inside Wayland compositors. func hasX11Display() bool { - return os.Getenv("DISPLAY") != "" + return strings.TrimSpace(os.Getenv("DISPLAY")) != "" } -// x11Display returns the X11 DISPLAY value, defaulting to ":0". +// x11Display returns the X11 DISPLAY value. Callers must first confirm that +// an X11 display is present rather than guessing ":0" from a service context. func x11Display() string { - if v := os.Getenv("DISPLAY"); v != "" { - return v - } - return ":0" + return strings.TrimSpace(os.Getenv("DISPLAY")) } -// captureDevice returns the ffmpeg input format (used only by screenshot fallback). +const desktopPortalBusName = "org.freedesktop.portal.Desktop" + +type waylandPortalReadiness struct { + Portal bool + PipeWire bool +} + +// detectWaylandPortalReadiness probes only local session prerequisites. A +// ready portal/PipeWire session is not a capture capability by itself: this +// agent still needs an in-process OpenPipeWireRemote file-descriptor bridge +// before it can offer a live portal stream. +func detectWaylandPortalReadiness() waylandPortalReadiness { + return waylandPortalReadiness{ + Portal: desktopPortalServiceAvailable(), + PipeWire: pipeWireSocketAvailable(os.Getenv("XDG_RUNTIME_DIR")), + } +} + +func pipeWireSocketAvailable(runtimeDir string) bool { + if strings.TrimSpace(runtimeDir) == "" { + return false + } + info, err := os.Stat(filepath.Join(runtimeDir, "pipewire-0")) + return err == nil && info.Mode()&os.ModeSocket != 0 +} + +func desktopPortalServiceAvailable() bool { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + // SessionBus returns a package-managed shared connection; it must not be + // closed here. The bounded calls below do not activate a portal or prompt. + conn, err := dbus.SessionBus() + if err != nil { + return false + } + + var owned bool + call := conn.BusObject().CallWithContext( + ctx, + "org.freedesktop.DBus.NameHasOwner", + 0, + desktopPortalBusName, + ) + if call.Err == nil && call.Store(&owned) == nil && owned { + return true + } + + var activatable []string + call = conn.BusObject().CallWithContext( + ctx, + "org.freedesktop.DBus.ListActivatableNames", + 0, + ) + return call.Err == nil && call.Store(&activatable) == nil && portalServiceListed(activatable) +} + +func portalServiceListed(names []string) bool { + for _, name := range names { + if name == desktopPortalBusName { + return true + } + } + return false +} + +// captureDevice returns a verified direct ffmpeg input format. Pure Wayland +// has no such path in this agent: a PipeWire portal node cannot be consumed +// safely without passing OpenPipeWireRemote's file descriptor to the capture +// process. func captureDevice() string { - if isWaylandSession() && !hasX11Display() { - return "pipewire" + if !hasX11Display() { + return "" } return "x11grab" } -// captureInput returns the ffmpeg input source (used only by screenshot fallback). +// captureInput returns the direct ffmpeg input source, if one is available. func captureInput() string { - if isWaylandSession() && !hasX11Display() { - return "0" + if !hasX11Display() { + return "" } return x11Display() } -// captureFFmpegInputArgs is kept for backwards compatibility with code paths -// that want a single best-guess input. Streaming uses captureFFmpegStrategies. +// captureFFmpegInputArgs returns a verified direct ffmpeg input. Streaming +// uses captureFFmpegStrategies; pure Wayland deliberately returns nil rather +// than claiming a fictitious PipeWire node 0 is capturable. func captureFFmpegInputArgs(fps int) []string { - if isWaylandSession() && !hasX11Display() { - return []string{ - "-f", "pipewire", - "-i", "0", - "-vf", fmt.Sprintf("fps=%d", fps), - } + if !hasX11Display() { + return nil } return []string{ "-f", "x11grab", @@ -77,393 +139,23 @@ func captureFFmpegInputArgs(fps int) []string { // for the current Linux session. The streamer tries them in order until one // produces frames. // -// Order on Wayland (KDE / GNOME / sway): -// 1. xdg-desktop-portal ScreenCast → pipewire (NATIVE, captures everything) -// 2. kmsgrab (DRM, requires CAP_SYS_ADMIN or root) -// 3. x11grab on :0 (XWayland — usually shows only X11 windows or a blank -// root, but better than nothing as a last resort) -// -// Order on X11: -// 1. x11grab on $DISPLAY -// -// Order on bare TTY: -// 1. kmsgrab -func captureFFmpegStrategies(fps int, streamer *DesktopStreamer) []CaptureStrategy { - var out []CaptureStrategy - +// On pure Wayland, use streamFallback instead. It delegates to screenshot +// tools that can prove they captured a frame (grim/wayshot/portal-aware +// desktop tools) instead of assuming a PipeWire node or elevated DRM access. +func captureFFmpegStrategies(fps int, _ *DesktopStreamer) []CaptureStrategy { + if !hasX11Display() { + return nil + } + name := "x11grab" if isWaylandSession() { - // 1. Native Wayland via xdg-desktop-portal → PipeWire. - if node, _, portal, err := openScreenCastPortal(); err == nil { - if streamer != nil { - streamer.setPortalCleanup(portal.close) - } - // Prefer gst-launch-1.0 — its pipewiresrc plugin is shipped with - // every standard Wayland install (gstreamer1-plugins-good + - // gstreamer1-plugin-pipewire). ffmpeg's `-f pipewire` demuxer is - // rarely compiled in (Fedora/Nobara/Debian don't ship it) so we - // avoid that path entirely on Wayland. - if gst, err := exec.LookPath("gst-launch-1.0"); err == nil { - out = append(out, CaptureStrategy{ - Name: fmt.Sprintf("gst-pipewire(node=%d)", node), - FullCommand: []string{ - gst, "-q", - "pipewiresrc", fmt.Sprintf("path=%d", node), "do-timestamp=true", - "!", "videoconvert", - "!", "videorate", - "!", fmt.Sprintf("video/x-raw,framerate=%d/1", fps), - "!", "jpegenc", "quality=%QUALITY%", - "!", "fdsink", "fd=1", - }, - PortalBacked: true, - }) - } - // ffmpeg pipewire (only works if ffmpeg was built --enable-libpipewire). - out = append(out, CaptureStrategy{ - Name: fmt.Sprintf("ffmpeg-pipewire(node=%d)", node), - Args: []string{ - "-f", "pipewire", - "-i", fmt.Sprintf("%d", node), - "-vf", fmt.Sprintf("fps=%d", fps), - }, - PortalBacked: true, - }) - _ = portal // restore token persistence is a follow-up - } - - // 2. KMS direct capture (requires permissions; usually root). - if hasKMSAccess() { - out = append(out, CaptureStrategy{ - Name: "kmsgrab", - Args: []string{ - "-f", "kmsgrab", - "-framerate", fmt.Sprintf("%d", fps), - "-i", "-", - }, - }) - } - - // 3. XWayland fallback (rarely useful but cheap to try). - if hasX11Display() { - out = append(out, CaptureStrategy{ - Name: "x11grab(XWayland)", - Args: []string{ - "-f", "x11grab", - "-framerate", fmt.Sprintf("%d", fps), - "-i", x11Display(), - }, - }) - } - return out + name = "x11grab(XWayland; X11 windows only)" } - - // Pure X11 session. - if hasX11Display() { - out = append(out, CaptureStrategy{ - Name: "x11grab", - Args: []string{ - "-f", "x11grab", - "-framerate", fmt.Sprintf("%d", fps), - "-i", x11Display(), - }, - }) - } - - // Bare TTY / kiosk: kmsgrab is the only option. - if hasKMSAccess() { - out = append(out, CaptureStrategy{ - Name: "kmsgrab", - Args: []string{ - "-f", "kmsgrab", - "-framerate", fmt.Sprintf("%d", fps), - "-i", "-", - }, - }) - } - return out + return []CaptureStrategy{{ + Name: name, + Args: []string{ + "-f", "x11grab", + "-framerate", fmt.Sprintf("%d", fps), + "-i", x11Display(), + }, + }} } - -// hasKMSAccess returns true when ffmpeg's kmsgrab device is likely usable -// (i.e. /dev/dri/card0 exists and is readable). It does NOT verify CAP_SYS_ADMIN -// because requesting that capability requires running ffmpeg first. -func hasKMSAccess() bool { - for _, path := range []string{"/dev/dri/card0", "/dev/dri/card1"} { - if f, err := os.Open(path); err == nil { - _ = f.Close() - return true - } - } - return false -} - -// ── xdg-desktop-portal ScreenCast ──────────────────────────────────────── -// -// The portal flow is: -// 1. CreateSession → returns a session handle -// 2. SelectSources → declares we want a monitor and persistence mode -// 3. Start → user is prompted; on success returns PipeWire streams -// 4. OpenPipeWireRemote → returns an FD we can hand to ffmpeg -// -// Each portal call returns a request handle; the actual response arrives -// asynchronously on a Response signal. We block on a per-request channel -// with a short timeout so the streamer fails fast when the portal is missing -// or the user denies consent. - -// portalCallTimeout caps the total round-trip for a single portal call. -const portalCallTimeout = 12 * time.Second - -// portalScreenCastSession tracks an active xdg-desktop-portal ScreenCast -// session so it can be closed when the remote desktop stream ends. -type portalScreenCastSession struct { - sessionPath dbus.ObjectPath -} - -func (p *portalScreenCastSession) close() { - if p == nil || p.sessionPath == "" { - return - } - conn, err := dbus.SessionBus() - if err != nil { - log.Printf("[desktop] portal CloseSession: session bus: %v", err) - return - } - portal := conn.Object( - "org.freedesktop.portal.Desktop", - dbus.ObjectPath("/org/freedesktop/portal/desktop"), - ) - if err := portal.Call( - "org.freedesktop.portal.ScreenCast.CloseSession", 0, p.sessionPath, - ).Err; err != nil { - log.Printf("[desktop] portal CloseSession: %v", err) - return - } - log.Printf("[desktop] portal screencast session closed (%s)", p.sessionPath) -} - -// openScreenCastPortal opens an xdg-desktop-portal ScreenCast session, -// negotiates a single monitor stream, and returns the PipeWire node ID for -// ffmpeg's `-f pipewire -i ` input plus a handle for CloseSession. -// -// Returns an error if the portal is unreachable, the user denies the prompt, -// or any step in the handshake times out. The caller is expected to fall -// back to another capture strategy in that case. -func openScreenCastPortal() (uint32, string, *portalScreenCastSession, error) { - conn, err := dbus.SessionBus() - if err != nil { - return 0, "", nil, fmt.Errorf("dbus session: %w", err) - } - - portal := conn.Object( - "org.freedesktop.portal.Desktop", - dbus.ObjectPath("/org/freedesktop/portal/desktop"), - ) - - // 1. CreateSession ---------------------------------------------------- - sessionHandleToken := newPortalToken() - createReqToken := newPortalToken() - createReqPath := requestPath(conn, createReqToken) - - createCh := subscribePortalResponse(conn, createReqPath) - defer unsubscribePortalResponse(conn, createReqPath) - - createOpts := map[string]dbus.Variant{ - "handle_token": dbus.MakeVariant(createReqToken), - "session_handle_token": dbus.MakeVariant(sessionHandleToken), - } - - var createReply dbus.ObjectPath - if err := portal.Call( - "org.freedesktop.portal.ScreenCast.CreateSession", 0, createOpts, - ).Store(&createReply); err != nil { - return 0, "", nil, fmt.Errorf("CreateSession: %w", err) - } - - createResp, err := waitPortalResponse(createCh, portalCallTimeout) - if err != nil { - return 0, "", nil, fmt.Errorf("CreateSession response: %w", err) - } - sessionHandleVar, ok := createResp["session_handle"] - if !ok { - return 0, "", nil, fmt.Errorf("CreateSession: no session_handle") - } - sessionHandle, ok := sessionHandleVar.Value().(string) - if !ok || sessionHandle == "" { - return 0, "", nil, fmt.Errorf("CreateSession: bad session_handle type") - } - sessionPath := dbus.ObjectPath(sessionHandle) - portalHandle := &portalScreenCastSession{sessionPath: sessionPath} - - // 2. SelectSources ---------------------------------------------------- - selectReqToken := newPortalToken() - selectReqPath := requestPath(conn, selectReqToken) - - selectCh := subscribePortalResponse(conn, selectReqPath) - defer unsubscribePortalResponse(conn, selectReqPath) - - // types: 1 = MONITOR, 2 = WINDOW, 4 = VIRTUAL (bitmask) - // cursor_mode: 1 = HIDDEN, 2 = EMBEDDED, 4 = METADATA - // persist_mode: 0 = no, 1 = transient (until logout), 2 = permanent - selectOpts := map[string]dbus.Variant{ - "handle_token": dbus.MakeVariant(selectReqToken), - "types": dbus.MakeVariant(uint32(1)), - "multiple": dbus.MakeVariant(false), - "cursor_mode": dbus.MakeVariant(uint32(2)), - "persist_mode": dbus.MakeVariant(uint32(0)), - } - - if err := portal.Call( - "org.freedesktop.portal.ScreenCast.SelectSources", 0, - sessionPath, selectOpts, - ).Store(&createReply); err != nil { - return 0, "", nil, fmt.Errorf("SelectSources: %w", err) - } - if _, err := waitPortalResponse(selectCh, portalCallTimeout); err != nil { - return 0, "", nil, fmt.Errorf("SelectSources response: %w", err) - } - - // 3. Start ------------------------------------------------------------ - startReqToken := newPortalToken() - startReqPath := requestPath(conn, startReqToken) - - startCh := subscribePortalResponse(conn, startReqPath) - defer unsubscribePortalResponse(conn, startReqPath) - - startOpts := map[string]dbus.Variant{ - "handle_token": dbus.MakeVariant(startReqToken), - } - - if err := portal.Call( - "org.freedesktop.portal.ScreenCast.Start", 0, - sessionPath, "", startOpts, - ).Store(&createReply); err != nil { - return 0, "", nil, fmt.Errorf("Start: %w", err) - } - startResp, err := waitPortalResponse(startCh, portalCallTimeout) - if err != nil { - return 0, "", nil, fmt.Errorf("Start response: %w", err) - } - - streamsVar, ok := startResp["streams"] - if !ok { - return 0, "", nil, fmt.Errorf("Start: no streams in response") - } - streams, ok := streamsVar.Value().([][]interface{}) - if !ok { - // Some portal versions wrap streams as []interface{} of structs. - alt, _ := streamsVar.Value().([]interface{}) - for _, s := range alt { - if pair, ok := s.([]interface{}); ok && len(pair) >= 1 { - if node, ok := pair[0].(uint32); ok { - rt, _ := startResp["restore_token"].Value().(string) - return node, rt, portalHandle, nil - } - } - } - // Some bindings unmarshal as []struct{ uint32; map[string]variant } - if raw, err := json.Marshal(streamsVar.Value()); err == nil { - return 0, "", nil, fmt.Errorf("Start: unsupported streams shape (%s)", raw) - } - return 0, "", nil, fmt.Errorf("Start: unsupported streams shape") - } - if len(streams) == 0 { - return 0, "", nil, fmt.Errorf("Start: empty streams") - } - first := streams[0] - if len(first) < 1 { - return 0, "", nil, fmt.Errorf("Start: bad stream tuple") - } - node, ok := first[0].(uint32) - if !ok { - return 0, "", nil, fmt.Errorf("Start: bad node type") - } - - restoreToken := "" - if rt, ok := startResp["restore_token"]; ok { - if s, ok := rt.Value().(string); ok { - restoreToken = s - } - } - - return node, restoreToken, portalHandle, nil -} - -// newPortalToken returns a unique per-call handle token. -func newPortalToken() string { - return fmt.Sprintf("bd_%d", time.Now().UnixNano()) -} - -// requestPath computes the org.freedesktop.portal.Request object path -// the portal will emit a Response signal on for a given token. -func requestPath(conn *dbus.Conn, token string) dbus.ObjectPath { - // The bus name is sender-specific; portal mangles it according to the spec. - sender := strings.ReplaceAll(strings.TrimPrefix(conn.Names()[0], ":"), ".", "_") - return dbus.ObjectPath(fmt.Sprintf( - "/org/freedesktop/portal/desktop/request/%s/%s", sender, token, - )) -} - -// subscribePortalResponse adds a match rule and returns a channel that -// receives the Response signal payload for the given request path. -func subscribePortalResponse(conn *dbus.Conn, path dbus.ObjectPath) chan map[string]dbus.Variant { - ch := make(chan map[string]dbus.Variant, 1) - - rule := fmt.Sprintf( - "type='signal',interface='org.freedesktop.portal.Request',member='Response',path='%s'", - path, - ) - if call := conn.BusObject().Call( - "org.freedesktop.DBus.AddMatch", 0, rule, - ); call.Err != nil { - close(ch) - return ch - } - - sigCh := make(chan *dbus.Signal, 4) - conn.Signal(sigCh) - - go func() { - defer conn.RemoveSignal(sigCh) - for sig := range sigCh { - if sig.Path != path { - continue - } - if sig.Name != "org.freedesktop.portal.Request.Response" { - continue - } - if len(sig.Body) < 2 { - close(ch) - return - } - results, _ := sig.Body[1].(map[string]dbus.Variant) - ch <- results - close(ch) - return - } - }() - return ch -} - -// unsubscribePortalResponse removes the match rule installed by subscribePortalResponse. -func unsubscribePortalResponse(conn *dbus.Conn, path dbus.ObjectPath) { - rule := fmt.Sprintf( - "type='signal',interface='org.freedesktop.portal.Request',member='Response',path='%s'", - path, - ) - _ = conn.BusObject().Call("org.freedesktop.DBus.RemoveMatch", 0, rule).Err -} - -// waitPortalResponse blocks on ch up to timeout and returns the response map. -func waitPortalResponse(ch chan map[string]dbus.Variant, timeout time.Duration) (map[string]dbus.Variant, error) { - select { - case resp, ok := <-ch: - if !ok { - return nil, fmt.Errorf("portal channel closed") - } - return resp, nil - case <-time.After(timeout): - return nil, fmt.Errorf("timeout after %v", timeout) - } -} - -// (kept to avoid unused-import errors when the build tags evolve) -var _ = context.Background -var _ = exec.Command diff --git a/betterdesk-agent/agent/desktop_linux_test.go b/betterdesk-agent/agent/desktop_linux_test.go new file mode 100644 index 00000000..1dbbf8a4 --- /dev/null +++ b/betterdesk-agent/agent/desktop_linux_test.go @@ -0,0 +1,125 @@ +//go:build linux + +package agent + +import ( + "net" + "path/filepath" + "strings" + "testing" +) + +func TestPureWaylandDoesNotAdvertiseUnverifiedPipeWireCapture(t *testing.T) { + t.Setenv("WAYLAND_DISPLAY", "wayland-0") + t.Setenv("XDG_SESSION_TYPE", "wayland") + t.Setenv("DISPLAY", "") + + if got := captureDevice(); got != "" { + t.Fatalf("captureDevice() = %q, want no direct capture device", got) + } + if got := captureInput(); got != "" { + t.Fatalf("captureInput() = %q, want no direct capture input", got) + } + if got := captureFFmpegInputArgs(15); got != nil { + t.Fatalf("captureFFmpegInputArgs() = %#v, want nil", got) + } + if got := captureFFmpegStrategies(15, nil); len(got) != 0 { + t.Fatalf("captureFFmpegStrategies() = %#v, want no unverified strategy", got) + } +} + +func TestXWaylandStrategyIsExplicitlyLimited(t *testing.T) { + t.Setenv("WAYLAND_DISPLAY", "wayland-0") + t.Setenv("XDG_SESSION_TYPE", "wayland") + t.Setenv("DISPLAY", ":1") + + strategies := captureFFmpegStrategies(15, nil) + if len(strategies) != 1 { + t.Fatalf("got %d capture strategies, want 1", len(strategies)) + } + if !strings.Contains(strategies[0].Name, "XWayland") { + t.Fatalf("strategy name %q must identify its XWayland limitation", strategies[0].Name) + } +} + +func TestX11CaptureUsesTheAgentSessionDisplay(t *testing.T) { + t.Setenv("WAYLAND_DISPLAY", "") + t.Setenv("XDG_SESSION_TYPE", "x11") + t.Setenv("DISPLAY", ":42") + + if got := captureDevice(); got != "x11grab" { + t.Fatalf("captureDevice() = %q, want x11grab", got) + } + if got := captureInput(); got != ":42" { + t.Fatalf("captureInput() = %q, want :42", got) + } + args := captureFFmpegInputArgs(15) + if got, want := args[len(args)-1], ":42"; got != want { + t.Fatalf("capture display = %q, want %q", got, want) + } +} + +func TestHeadlessLinuxDoesNotGuessX11Display(t *testing.T) { + t.Setenv("WAYLAND_DISPLAY", "") + t.Setenv("XDG_SESSION_TYPE", "") + t.Setenv("DISPLAY", "") + + if got := captureDevice(); got != "" { + t.Fatalf("captureDevice() = %q, want no direct capture device", got) + } + if got := captureInput(); got != "" { + t.Fatalf("captureInput() = %q, want no direct capture input", got) + } + if got := captureFFmpegInputArgs(15); got != nil { + t.Fatalf("captureFFmpegInputArgs() = %#v, want nil", got) + } +} + +func TestPortalServiceListed(t *testing.T) { + if !portalServiceListed([]string{"org.freedesktop.Notifications", desktopPortalBusName}) { + t.Fatal("expected portal service to be detected in activatable names") + } + if portalServiceListed([]string{"org.freedesktop.Notifications"}) { + t.Fatal("unexpected portal service detection") + } +} + +func TestPipeWireSocketAvailable(t *testing.T) { + runtimeDir := t.TempDir() + if pipeWireSocketAvailable(runtimeDir) { + t.Fatal("unexpected PipeWire socket before socket creation") + } + + socket := filepath.Join(runtimeDir, "pipewire-0") + listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socket, Net: "unix"}) + if err != nil { + t.Fatalf("create test PipeWire socket: %v", err) + } + t.Cleanup(func() { _ = listener.Close() }) + + if !pipeWireSocketAvailable(runtimeDir) { + t.Fatal("expected PipeWire socket to be detected") + } +} + +func TestWaylandInputRequiresExplicitYdotoolFallback(t *testing.T) { + t.Setenv("WAYLAND_DISPLAY", "wayland-0") + t.Setenv("XDG_SESSION_TYPE", "wayland") + t.Setenv("DISPLAY", "") + t.Setenv(waylandInputFallbackEnv, "") + + if got := linuxInputBackendForSession(); got != linuxInputBackendNoWaylandPortal { + t.Fatalf("default pure-Wayland input backend = %d, want portal-required", got) + } + + t.Setenv(waylandInputFallbackEnv, "ydotool") + if got := linuxInputBackendForSession(); got != linuxInputBackendYdotool { + t.Fatalf("opt-in pure-Wayland input backend = %d, want ydotool", got) + } + + t.Setenv(waylandInputFallbackEnv, "") + t.Setenv("DISPLAY", ":1") + if got := linuxInputBackendForSession(); got != linuxInputBackendX11 { + t.Fatalf("default XWayland input backend = %d, want X11", got) + } +} diff --git a/betterdesk-agent/agent/desktop_windows.go b/betterdesk-agent/agent/desktop_windows.go index 28b6194a..d4c46980 100644 --- a/betterdesk-agent/agent/desktop_windows.go +++ b/betterdesk-agent/agent/desktop_windows.go @@ -2,7 +2,14 @@ package agent -import "fmt" +import ( + "context" + "fmt" + "os/exec" + "strings" + "sync" + "time" +) // captureDevice returns the ffmpeg input format for screen capture on Windows. func captureDevice() string { @@ -23,10 +30,74 @@ func captureFFmpegInputArgs(fps int) []string { } } -// captureFFmpegStrategies returns the (single) Windows capture strategy. +// captureFFmpegStrategies preserves gdigrab as the stable primary source and +// adds ddagrab only as a runtime-probed DXGI fallback. Windows.Graphics.Capture +// needs a native D3D11 frame bridge, so it is intentionally not represented by +// an ffmpeg strategy until that bridge exists. func captureFFmpegStrategies(fps int, _ *DesktopStreamer) []CaptureStrategy { - return []CaptureStrategy{{ + return windowsCaptureFFmpegStrategies(fps, ffmpegSupportsDDAGrab()) +} + +func windowsCaptureFFmpegStrategies(fps int, dxgiAvailable bool) []CaptureStrategy { + strategies := []CaptureStrategy{{ Name: "gdigrab", Args: captureFFmpegInputArgs(fps), }} + if !dxgiAvailable { + return strategies + } + + // ddagrab is FFmpeg's Desktop Duplication (DXGI) source filter. Keep it + // behind gdigrab because it captures one output at a time and support for + // the filter varies across otherwise stock Windows FFmpeg builds. ddagrab + // outputs D3D11 frames, so download them before the existing software + // encoder path. The streamer accepts it only after it has produced frames. + return append(strategies, CaptureStrategy{ + Name: "ddagrab(DXGI fallback)", + Args: []string{ + "-f", "lavfi", + "-i", fmt.Sprintf("ddagrab=output_idx=0:framerate=%d:draw_mouse=1,hwdownload,format=bgra", fps), + }, + }) +} + +var ( + ddagrabProbeOnce sync.Once + ddagrabAvailable bool +) + +// ffmpegSupportsDDAGrab reports whether the FFmpeg binary on PATH exposes the +// ddagrab filter. It is a bounded capability probe only; acquiring frames is +// still the final compatibility check in the normal strategy fallback loop. +func ffmpegSupportsDDAGrab() bool { + ddagrabProbeOnce.Do(func() { + path, err := exec.LookPath("ffmpeg") + if err != nil { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, path, "-hide_banner", "-filters").CombinedOutput() + if err != nil || ctx.Err() != nil { + return + } + ddagrabAvailable = ffmpegHasDXGIFallbackFilters(out) + }) + return ddagrabAvailable +} + +func ffmpegHasDXGIFallbackFilters(output []byte) bool { + return ffmpegFilterListed(output, "ddagrab") && + ffmpegFilterListed(output, "hwdownload") && + ffmpegFilterListed(output, "format") +} + +func ffmpegFilterListed(output []byte, filter string) bool { + for _, field := range strings.Fields(string(output)) { + if field == filter { + return true + } + } + return false } diff --git a/betterdesk-agent/agent/desktop_windows_test.go b/betterdesk-agent/agent/desktop_windows_test.go new file mode 100644 index 00000000..9daced7b --- /dev/null +++ b/betterdesk-agent/agent/desktop_windows_test.go @@ -0,0 +1,55 @@ +//go:build windows + +package agent + +import "testing" + +func TestWindowsCaptureKeepsGDIGRABPrimaryWithoutDXGI(t *testing.T) { + strategies := windowsCaptureFFmpegStrategies(15, false) + if len(strategies) != 1 { + t.Fatalf("got %d capture strategies, want 1", len(strategies)) + } + if strategies[0].Name != "gdigrab" { + t.Fatalf("capture strategy = %q, want gdigrab", strategies[0].Name) + } +} + +func TestWindowsCaptureAddsDXGIFallbackWhenAvailable(t *testing.T) { + strategies := windowsCaptureFFmpegStrategies(15, true) + if len(strategies) != 2 { + t.Fatalf("got %d capture strategies, want 2", len(strategies)) + } + if strategies[0].Name != "gdigrab" { + t.Fatalf("primary capture strategy = %q, want gdigrab", strategies[0].Name) + } + if strategies[1].Name != "ddagrab(DXGI fallback)" { + t.Fatalf("fallback capture strategy = %q, want DXGI ddagrab", strategies[1].Name) + } + if got, want := strategies[1].Args[3], "ddagrab=output_idx=0:framerate=15:draw_mouse=1,hwdownload,format=bgra"; got != want { + t.Fatalf("ddagrab source = %q, want %q", got, want) + } +} + +func TestFFmpegFilterListed(t *testing.T) { + output := []byte(" ... ddagrab V->V Grab Windows desktop via Desktop Duplication API.\n") + if !ffmpegFilterListed(output, "ddagrab") { + t.Fatal("expected ddagrab filter to be detected") + } + if ffmpegFilterListed(output, "gdigrab") { + t.Fatal("unexpected unrelated filter detection") + } +} + +func TestFFmpegHasDXGIFallbackFilters(t *testing.T) { + output := []byte(` + ... ddagrab V->V Grab Windows desktop via Desktop Duplication API. + ... format V->V Convert the input video to one of the specified pixel formats. + ... hwdownload V->V Download a hardware frame to a normal frame. +`) + if !ffmpegHasDXGIFallbackFilters(output) { + t.Fatal("expected complete DXGI fallback filter set") + } + if ffmpegHasDXGIFallbackFilters([]byte(" ... ddagrab V->V\n ... format V->V\n")) { + t.Fatal("expected missing hwdownload filter to reject DXGI fallback") + } +} diff --git a/betterdesk-agent/agent/feature_advertisement_policy_test.go b/betterdesk-agent/agent/feature_advertisement_policy_test.go new file mode 100644 index 00000000..68c4e3d2 --- /dev/null +++ b/betterdesk-agent/agent/feature_advertisement_policy_test.go @@ -0,0 +1,80 @@ +package agent + +import ( + "encoding/json" + "testing" +) + +func TestManifestDoesNotAdvertiseTerminalCommandsWhenDisabled(t *testing.T) { + manifest := BuildManifest( + &Config{Screenshot: true, Terminal: false}, + &SystemCollector{cachedInfo: &SystemInfo{}}, + "test", + ) + capabilities, ok := manifest["capabilities"].([]string) + if !ok { + t.Fatalf("capabilities = %#v, want []string", manifest["capabilities"]) + } + + if hasCapability(capabilities, "commands") { + t.Fatalf("terminal-disabled agent advertised commands: %v", capabilities) + } + if !hasCapability(capabilities, "remote_desktop") { + t.Fatalf("desktop-enabled agent did not advertise remote_desktop: %v", capabilities) + } +} + +func TestUnsupportedDesktopControlsAreDeniedBeforeHostActions(t *testing.T) { + tests := []struct { + control string + want string + }{ + {control: "privacy_mode", want: "privacy mode is unavailable on this host"}, + {control: " block_input ", want: "local input blocking is unavailable on this host"}, + {control: "RESTART_DEVICE", want: "remote restart is unavailable on this host"}, + {control: "recording", want: "recording is unavailable on this host"}, + } + for _, tt := range tests { + t.Run(tt.control, func(t *testing.T) { + if got := unsupportedDesktopControlReason(tt.control); got != tt.want { + t.Fatalf("unsupportedDesktopControlReason(%q) = %q, want %q", tt.control, got, tt.want) + } + }) + } + + if got := unsupportedDesktopControlReason("disable_clipboard"); got != "" { + t.Fatalf("supported session control denied: %q", got) + } +} + +func TestBlockInputControlIsDeniedWithoutChangingSessionState(t *testing.T) { + a := &Agent{} + a.desktopStreams.Store("desktop-1", &DesktopStreamer{}) + defer a.desktopStreams.Delete("desktop-1") + + payload, err := json.Marshal(map[string]any{ + "session_id": "desktop-1", + "control": "block_input", + "enabled": true, + }) + if err != nil { + t.Fatalf("marshal control payload: %v", err) + } + a.handleDesktopControl(&Message{Payload: payload}) + + if _, exists := a.desktopFlags.Load("desktop-1"); exists { + t.Fatal("denied block_input control must not create session flags") + } + if remoteInputInjectionBlocked() { + t.Fatal("denied block_input control must not block remote input") + } +} + +func hasCapability(capabilities []string, want string) bool { + for _, capability := range capabilities { + if capability == want { + return true + } + } + return false +} diff --git a/betterdesk-agent/agent/input.go b/betterdesk-agent/agent/input.go index b644c856..1b3528b5 100644 --- a/betterdesk-agent/agent/input.go +++ b/betterdesk-agent/agent/input.go @@ -33,6 +33,9 @@ func InjectInputEvent(evt *InputEvent) error { if evt == nil { return fmt.Errorf("nil input event") } + if remoteInputInjectionBlocked() { + return fmt.Errorf("remote input is blocked") + } return injectInput(evt) } @@ -48,23 +51,30 @@ func (a *Agent) handleDesktopInput(msg *Message) { log.Printf("[input] Parse error: %v", err) return } + evt.SessionID = normalizeDesktopSessionID(evt.SessionID) if !a.hasActiveDesktopStream(evt.SessionID) { return } - // block_input is an operator-side lock of the *local* user's input on - // Windows-class clients; for CDAP we treat it as "operator has exclusive - // control" and still inject operator events. Privacy mode does not - // suppress input either — it only affects capture (see desktop stream). + if a.isRemoteInputBlocked(evt.SessionID) { + const message = "remote input is blocked for this session" + log.Printf("[input] Dropped %s for blocked session %s", evt.Type, evt.SessionID) + a.sendDesktopInputError(&evt, message) + return + } - if err := injectInput(&evt); err != nil { + if err := InjectInputEvent(&evt); err != nil { log.Printf("[input] Injection failed (%s): %v", evt.Type, err) - if shouldEmitInputError(evt.SessionID, evt.Type, err.Error()) { - _ = a.sendMessage("desktop_input_error", map[string]any{ - "session_id": evt.SessionID, - "type": evt.Type, - "message": err.Error(), - }) - } + a.sendDesktopInputError(&evt, err.Error()) + } +} + +func (a *Agent) sendDesktopInputError(evt *InputEvent, message string) { + if shouldEmitInputError(evt.SessionID, evt.Type, message) { + _ = a.sendMessage("desktop_input_error", map[string]any{ + "session_id": evt.SessionID, + "type": evt.Type, + "message": message, + }) } } diff --git a/betterdesk-agent/agent/input_linux.go b/betterdesk-agent/agent/input_linux.go index 2a7ee2f8..aab1c2ef 100644 --- a/betterdesk-agent/agent/input_linux.go +++ b/betterdesk-agent/agent/input_linux.go @@ -4,6 +4,7 @@ package agent import ( "fmt" + "os" "os/exec" "strings" ) @@ -11,29 +12,57 @@ import ( // injectInput injects a keyboard or mouse event on Linux. // // Strategy: -// 1. Wayland session → prefer ydotool, because xdotool only reaches XWayland windows. -// 2. X11 or XWayland fallback → use xdotool. +// 1. X11 or XWayland → use xdotool. +// 2. Pure Wayland → require the future RemoteDesktop portal path. An +// externally managed ydotool daemon is available only as an explicit +// administrator-selected fallback; the agent never starts it or elevates. func injectInput(evt *InputEvent) error { + switch linuxInputBackendForSession() { + case linuxInputBackendX11: + return injectInputX11(evt) + case linuxInputBackendYdotool: + return injectInputWayland(evt) + case linuxInputBackendNoWaylandPortal: + return fmt.Errorf( + "Wayland remote input requires xdg-desktop-portal RemoteDesktop support, which this build does not yet implement; set %s=ydotool only to opt into a separately managed ydotoold fallback", + waylandInputFallbackEnv, + ) + default: + return fmt.Errorf("no supported input backend found (start the agent in an X11/XWayland desktop session with $DISPLAY set)") + } +} + +type linuxInputBackend uint8 + +const ( + linuxInputBackendUnavailable linuxInputBackend = iota + linuxInputBackendX11 + linuxInputBackendYdotool + linuxInputBackendNoWaylandPortal +) + +const waylandInputFallbackEnv = "BETTERDESK_WAYLAND_INPUT_FALLBACK" + +func linuxInputBackendForSession() linuxInputBackend { if isWaylandSession() { - if commandExists("ydotool") { - if err := injectInputWayland(evt); err == nil { - return nil - } else if !hasX11Display() { - return err - } + if waylandYdotoolFallbackEnabled() { + return linuxInputBackendYdotool } if hasX11Display() { - return injectInputX11(evt) + // xdotool can control XWayland clients but not native Wayland + // surfaces. It remains the least-privileged default available. + return linuxInputBackendX11 } - return fmt.Errorf("Wayland input injection requires ydotool and a running ydotoold daemon") + return linuxInputBackendNoWaylandPortal } if hasX11Display() { - return injectInputX11(evt) + return linuxInputBackendX11 } - if commandExists("ydotool") { - return injectInputWayland(evt) - } - return fmt.Errorf("no supported input backend found (install xdotool for X11 or ydotool for Wayland)") + return linuxInputBackendUnavailable +} + +func waylandYdotoolFallbackEnabled() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(waylandInputFallbackEnv)), "ydotool") } // ── X11 / XWayland path (xdotool) ──────────────────────────────────────── @@ -104,23 +133,18 @@ func injectInputX11(evt *InputEvent) error { func xdotool(args ...string) error { path, err := exec.LookPath("xdotool") if err != nil { - return fmt.Errorf("xdotool not found — install it with: sudo apt install xdotool") + return fmt.Errorf("xdotool not found — install it through the local package manager") } return exec.Command(path, args...).Run() } -func commandExists(name string) bool { - _, err := exec.LookPath(name) - return err == nil -} - // ── Pure-Wayland path (ydotool) ────────────────────────────────────────── // injectInputWayland uses ydotool for input injection on pure-Wayland sessions. // // Requirements: -// - ydotool installed: sudo apt install ydotool (or build from source) -// - ydotoold daemon running: sudo ydotoold & +// - ydotool installed and its daemon endpoint configured separately +// - the process granted access to that endpoint by the local administrator // // ydotool 1.x command syntax is used here. On Debian/Ubuntu the package may be // older (0.x); if commands fail, upgrade or use XWayland instead. @@ -205,7 +229,7 @@ func ydotool(args ...string) error { path, err := exec.LookPath("ydotool") if err != nil { return fmt.Errorf( - "ydotool not found — install it and start ydotoold: sudo apt install ydotool && sudo ydotoold &", + "ydotool fallback is selected but ydotool is not installed or not on PATH", ) } return exec.Command(path, args...).Run() diff --git a/betterdesk-agent/agent/manifest.go b/betterdesk-agent/agent/manifest.go index 244d8d4b..db096a7b 100644 --- a/betterdesk-agent/agent/manifest.go +++ b/betterdesk-agent/agent/manifest.go @@ -14,8 +14,13 @@ func BuildManifest(cfg *Config, sys *SystemCollector, version string) map[string // alerts, logs, remote_desktop, video_stream, audio, clipboard, // file_transfer, input_control. Anything else is rejected with // "unknown capability". Both terminal and screenshot map to - // remote_desktop — deduplicate via a set. - capsSet := map[string]bool{"telemetry": true, "commands": true} + // remote_desktop — deduplicate via a set. The server uses "commands" as + // its terminal admission capability, so never advertise it when the local + // terminal handler is disabled. + capsSet := map[string]bool{"telemetry": true} + if cfg.Terminal { + capsSet["commands"] = true + } if cfg.Terminal || cfg.Screenshot { capsSet["remote_desktop"] = true } @@ -29,6 +34,8 @@ func BuildManifest(cfg *Config, sys *SystemCollector, version string) map[string if cfg.Clipboard { capsSet["clipboard"] = true } + // Audio intentionally remains absent until audioCodecCapability can + // advertise a CDAP-compatible stream rather than a best-effort backend. caps := make([]string, 0, len(capsSet)) for c := range capsSet { caps = append(caps, c) diff --git a/betterdesk-agent/agent/monitors_linux.go b/betterdesk-agent/agent/monitors_linux.go index 65a800ff..dd663525 100644 --- a/betterdesk-agent/agent/monitors_linux.go +++ b/betterdesk-agent/agent/monitors_linux.go @@ -174,7 +174,20 @@ func monitorsSwaymsg() []MonitorInfo { // to install to make screen capture work on this Linux machine. func desktopCaptureHint() string { if isWaylandSession() { - return "Install gst-plugins-good and pipewire (Wayland) or grant the screen-capture portal permission, e.g. 'sudo dnf install gstreamer1-plugins-good gstreamer1-plugin-pipewire' on Fedora/Nobara." + readiness := detectWaylandPortalReadiness() + switch { + case readiness.Portal && readiness.PipeWire: + return "Wayland Portal and PipeWire are available, but this build does not yet implement the required OpenPipeWireRemote file-descriptor bridge. Live portal capture is disabled; install grim or wayshot for screenshot fallback, or use an X11 session." + case readiness.Portal: + return "Wayland Portal is available but PipeWire is not reachable in this session. Live portal capture is disabled; install grim or wayshot for screenshot fallback, or use an X11 session." + case readiness.PipeWire: + return "PipeWire is available but no XDG Desktop Portal service was detected. Live Wayland capture is disabled; install grim or wayshot for screenshot fallback, or use an X11 session." + default: + return "No usable XDG Desktop Portal/PipeWire pair was detected for this Wayland session. Install grim or wayshot for screenshot fallback, or use an X11 session." + } + } + if !hasX11Display() { + return "No graphical X11 display is available to this agent process. Start it in the logged-in desktop session with $DISPLAY set." } return "Install ffmpeg or scrot/grim/imagemagick (e.g. 'sudo apt install ffmpeg' or 'sudo dnf install ffmpeg') and ensure $DISPLAY is set." } diff --git a/betterdesk-agent/agent/platform_capabilities_test.go b/betterdesk-agent/agent/platform_capabilities_test.go new file mode 100644 index 00000000..e9d71aa1 --- /dev/null +++ b/betterdesk-agent/agent/platform_capabilities_test.go @@ -0,0 +1,167 @@ +package agent + +import ( + "encoding/json" + "testing" +) + +func TestCodecAnswerDoesNotAdvertiseUnavailableAudio(t *testing.T) { + answer := codecAnswerPayload("desktop-1", []string{CodecH264, CodecMJPEG}, (&Agent{}).audioCodecCapability()) + + if got := answer["audio_codec"]; got != CodecNone { + t.Fatalf("audio_codec = %q, want %q", got, CodecNone) + } + if got := answer["video_codec"]; got != CodecH264 { + t.Fatalf("video_codec = %q, want %q", got, CodecH264) + } +} + +func TestManifestDoesNotAdvertiseUnavailableAudio(t *testing.T) { + manifest := BuildManifest(&Config{}, &SystemCollector{cachedInfo: &SystemInfo{}}, "test") + capabilities, ok := manifest["capabilities"].([]string) + if !ok { + t.Fatalf("capabilities = %#v, want []string", manifest["capabilities"]) + } + for _, capability := range capabilities { + if capability == "audio" { + t.Fatal("manifest must not advertise unavailable audio") + } + } +} + +func TestBlockInputStopsSessionAndExportedInjectionPaths(t *testing.T) { + a := &Agent{} + a.desktopStreams.Store("desktop-1", &DesktopStreamer{}) + if !a.setSessionControl("desktop-1", "block_input", true) { + t.Fatal("expected active-session block_input control to be applied") + } + t.Cleanup(func() { + a.finishDesktopStream("desktop-1", nil) + }) + + if !a.isRemoteInputBlocked("desktop-1") { + t.Fatal("expected desktop session input to be blocked") + } + if !remoteInputInjectionBlocked() { + t.Fatal("expected exported input path to be blocked") + } + if err := InjectInputEvent(&InputEvent{}); err == nil { + t.Fatal("expected exported input injection to be rejected") + } + + if !a.setSessionControl("desktop-1", "block_input", false) { + t.Fatal("expected active-session block_input control to clear") + } + if a.isRemoteInputBlocked("desktop-1") { + t.Fatal("expected session input block to clear") + } + if remoteInputInjectionBlocked() { + t.Fatal("expected exported input block to clear") + } +} + +func TestClipboardDisableBlocksScopedAndUnscopedOperations(t *testing.T) { + a := &Agent{} + a.desktopStreams.Store("desktop-1", &DesktopStreamer{}) + t.Cleanup(func() { + a.desktopStreams.Delete("desktop-1") + a.finishDesktopSession("desktop-1") + }) + + if !a.setSessionControl("desktop-1", "disable_clipboard", true) { + t.Fatal("expected active-session disable_clipboard control to be applied") + } + if !a.isClipboardOperationBlocked("desktop-1") { + t.Fatal("expected scoped clipboard operation to be blocked") + } + if !a.isClipboardOperationBlocked("") { + t.Fatal("expected unscoped clipboard operation to be blocked") + } + + if !a.setSessionControl("desktop-1", "disable_clipboard", false) { + t.Fatal("expected active-session disable_clipboard control to clear") + } + if a.isClipboardOperationBlocked("desktop-1") { + t.Fatal("expected scoped clipboard operation to be allowed") + } + if a.isClipboardOperationBlocked("") { + t.Fatal("expected unscoped clipboard operation to be allowed") + } +} + +func TestOldDesktopStreamCannotClearReplacementControls(t *testing.T) { + a := &Agent{} + oldStream := &DesktopStreamer{} + newStream := &DesktopStreamer{} + a.desktopStreams.Store("desktop-1", oldStream) + if !a.setSessionControl("desktop-1", "block_input", true) { + t.Fatal("expected old-session block_input control to be applied") + } + + a.desktopControlMu.Lock() + a.resetSessionFlags("desktop-1") + a.desktopStreams.Store("desktop-1", newStream) + a.desktopControlMu.Unlock() + if !a.setSessionControl("desktop-1", "block_input", true) { + t.Fatal("expected replacement-session block_input control to be applied") + } + t.Cleanup(func() { + a.finishDesktopStream("desktop-1", newStream) + }) + + a.finishDesktopStream("desktop-1", oldStream) + current, active := a.desktopStreams.Load("desktop-1") + if !active || current != newStream { + t.Fatal("old capture cleanup removed the replacement stream") + } + if !a.isRemoteInputBlocked("desktop-1") { + t.Fatal("old capture cleanup removed replacement input controls") + } +} + +func TestInactiveSessionControlCannotCreateGlobalInputBlock(t *testing.T) { + a := &Agent{} + payload, err := json.Marshal(map[string]any{ + "session_id": "inactive-session", + "control": "block_input", + "enabled": true, + }) + if err != nil { + t.Fatalf("marshal control payload: %v", err) + } + + a.handleDesktopControl(&Message{Payload: payload}) + if remoteInputInjectionBlocked() { + t.Fatal("inactive session control must not block exported input") + } + if _, found := a.desktopFlags.Load("inactive-session"); found { + t.Fatal("inactive session control must not create retained flags") + } +} + +func TestPrivacyModeIsNotRetainedAsAnUnsupportedControl(t *testing.T) { + a := &Agent{} + payload, err := json.Marshal(map[string]any{ + "session_id": "desktop-1", + "control": "privacy_mode", + "enabled": true, + }) + if err != nil { + t.Fatalf("marshal control payload: %v", err) + } + + a.handleDesktopControl(&Message{Payload: payload}) + if _, found := a.desktopFlags.Load("desktop-1"); found { + t.Fatal("privacy mode must not be retained without an enforceable privacy curtain") + } +} + +func TestCurrentCaptureMonitorListDoesNotClaimPhysicalSelection(t *testing.T) { + monitors := currentCaptureMonitorList() + if len(monitors) != 1 { + t.Fatalf("got %d advertised capture sources, want 1", len(monitors)) + } + if monitors[0].Index != 0 || monitors[0].Name != "Current capture" || !monitors[0].Primary { + t.Fatalf("unexpected capture source: %#v", monitors[0]) + } +} diff --git a/betterdesk-agent/agent/remote_control.go b/betterdesk-agent/agent/remote_control.go index 65a02139..edd0a195 100644 --- a/betterdesk-agent/agent/remote_control.go +++ b/betterdesk-agent/agent/remote_control.go @@ -12,19 +12,22 @@ import ( type desktopSessionFlags struct { mu sync.RWMutex blockInput bool - privacyMode bool clipboardDisabled bool lockAfterSession bool } +// remoteInputBlockers also protects the exported InjectInputEvent helper, +// which is used by the support-agent transport and therefore has no CDAP +// session parameter. Blocking is intentionally conservative: if any active +// remote desktop session blocks input, no remote path may inject locally. +var remoteInputBlockers sync.Map // *desktopSessionFlags → struct{} + func (f *desktopSessionFlags) set(control string, enabled bool) { f.mu.Lock() defer f.mu.Unlock() switch control { case "block_input": f.blockInput = enabled - case "privacy_mode": - f.privacyMode = enabled case "disable_clipboard": f.clipboardDisabled = enabled case "lock_after_session": @@ -38,12 +41,6 @@ func (f *desktopSessionFlags) isBlocked() bool { return f.blockInput } -func (f *desktopSessionFlags) isPrivacy() bool { - f.mu.RLock() - defer f.mu.RUnlock() - return f.privacyMode -} - func (f *desktopSessionFlags) isClipboardDisabled() bool { f.mu.RLock() defer f.mu.RUnlock() @@ -56,10 +53,15 @@ func (f *desktopSessionFlags) shouldLockAfter() bool { return f.lockAfterSession } -func (a *Agent) sessionFlags(sessionID string) *desktopSessionFlags { +func normalizeDesktopSessionID(sessionID string) string { if sessionID == "" { - sessionID = "_default" + return "default" } + return sessionID +} + +func (a *Agent) sessionFlags(sessionID string) *desktopSessionFlags { + sessionID = normalizeDesktopSessionID(sessionID) if v, ok := a.desktopFlags.Load(sessionID); ok { return v.(*desktopSessionFlags) } @@ -68,6 +70,125 @@ func (a *Agent) sessionFlags(sessionID string) *desktopSessionFlags { return actual.(*desktopSessionFlags) } +func (a *Agent) resetSessionFlags(sessionID string) { + sessionID = normalizeDesktopSessionID(sessionID) + if old, loaded := a.desktopFlags.LoadAndDelete(sessionID); loaded { + remoteInputBlockers.Delete(old.(*desktopSessionFlags)) + } + a.desktopFlags.Store(sessionID, &desktopSessionFlags{}) +} + +func (a *Agent) lookupSessionFlags(sessionID string) (*desktopSessionFlags, bool) { + v, ok := a.desktopFlags.Load(normalizeDesktopSessionID(sessionID)) + if !ok { + return nil, false + } + return v.(*desktopSessionFlags), true +} + +// setSessionControl applies a control only while its target desktop session is +// active. The mutex closes the race where a late block_input message could +// otherwise leave the exported, session-less injection path disabled after a +// session ended. +func (a *Agent) setSessionControl(sessionID, control string, enabled bool) bool { + sessionID = normalizeDesktopSessionID(sessionID) + a.desktopControlMu.Lock() + defer a.desktopControlMu.Unlock() + if _, active := a.desktopStreams.Load(sessionID); !active { + return false + } + flags := a.sessionFlags(sessionID) + if control == "block_input" { + if enabled { + // Register before changing the session flag so the exported, + // session-less injection path cannot race an enable request. + remoteInputBlockers.Store(flags, struct{}{}) + } else { + remoteInputBlockers.Delete(flags) + } + } + flags.set(control, enabled) + return true +} + +func remoteInputInjectionBlocked() bool { + blocked := false + remoteInputBlockers.Range(func(_, _ any) bool { + blocked = true + return false + }) + return blocked +} + +// isRemoteInputBlocked reports whether remote input injection is disabled for +// a desktop session. It deliberately does not attempt to suppress the local +// user's keyboard or mouse; doing that reliably requires a platform-specific +// accessibility/security boundary that this agent does not own. +func (a *Agent) isRemoteInputBlocked(sessionID string) bool { + flags, ok := a.lookupSessionFlags(sessionID) + return ok && flags.isBlocked() +} + +// isClipboardOperationBlocked applies the per-session disable_clipboard flag. +// Widget commands have no desktop-session ID, so while any active desktop +// session disables clipboard access we deny those unscoped operations too. +// That conservative rule prevents a widget command from bypassing the +// operator's session-scoped control. +func (a *Agent) isClipboardOperationBlocked(sessionID string) bool { + if sessionID != "" { + flags, ok := a.lookupSessionFlags(sessionID) + return ok && flags.isClipboardDisabled() + } + + blocked := false + a.desktopStreams.Range(func(key, _ any) bool { + id, ok := key.(string) + if !ok { + return true + } + if flags, ok := a.lookupSessionFlags(id); ok && flags.isClipboardDisabled() { + blocked = true + return false + } + return true + }) + return blocked +} + +// finishDesktopSession applies end-of-session cleanup without a stream identity. +// It is used by tests and callers that already removed the target stream. +func (a *Agent) finishDesktopSession(sessionID string) { + a.finishDesktopStream(sessionID, nil) +} + +// finishDesktopStream applies the one end-of-session control the shared agent +// can enforce directly, then removes all session-scoped controls. The stream +// identity prevents an old capture goroutine from deleting controls belonging +// to a replacement session with the same ID. The lock command uses normal OS +// APIs and does not request elevation. +func (a *Agent) finishDesktopStream(sessionID string, expected *DesktopStreamer) { + sessionID = normalizeDesktopSessionID(sessionID) + a.desktopControlMu.Lock() + if current, active := a.desktopStreams.Load(sessionID); expected != nil && active && current != expected { + a.desktopControlMu.Unlock() + return + } + flags, ok := a.lookupSessionFlags(sessionID) + a.desktopStreams.Delete(sessionID) + a.desktopFlags.Delete(sessionID) + if ok { + remoteInputBlockers.Delete(flags) + } + shouldLock := ok && flags.shouldLockAfter() + a.desktopControlMu.Unlock() + if !shouldLock { + return + } + if err := lockWorkstation(); err != nil { + log.Printf("[agent] lock_after_session failed for %s: %v", sessionID, err) + } +} + func (a *Agent) handleDesktopControl(msg *Message) { var p struct { SessionID string `json:"session_id"` @@ -78,17 +199,22 @@ func (a *Agent) handleDesktopControl(msg *Message) { log.Printf("[agent] desktop_control decode: %v", err) return } + p.SessionID = normalizeDesktopSessionID(p.SessionID) + p.Control = normalizeDesktopControl(p.Control) + if reason := unsupportedDesktopControlReason(p.Control); reason != "" { + log.Printf("[agent] denied desktop_control %s for session %s: %s", p.Control, p.SessionID, reason) + return + } switch p.Control { case "lock_screen": if err := lockWorkstation(); err != nil { log.Printf("[agent] lock_screen failed: %v", err) } - case "restart_device": - if err := restartHost(); err != nil { - log.Printf("[agent] restart_device failed: %v", err) + case "disable_clipboard", "lock_after_session": + if !a.setSessionControl(p.SessionID, p.Control, p.Enabled) { + log.Printf("[agent] ignored desktop_control %s for inactive session %s", p.Control, p.SessionID) + return } - case "block_input", "privacy_mode", "disable_clipboard", "lock_after_session": - a.sessionFlags(p.SessionID).set(p.Control, p.Enabled) log.Printf("[agent] desktop_control %s=%v session=%s", p.Control, p.Enabled, p.SessionID) case "show_cursor", "quality_set": // Acknowledged — capture pipeline handles quality/cursor separately. diff --git a/betterdesk-server/api/auth_handlers.go b/betterdesk-server/api/auth_handlers.go index ddc5cc85..bedf426f 100644 --- a/betterdesk-server/api/auth_handlers.go +++ b/betterdesk-server/api/auth_handlers.go @@ -107,6 +107,13 @@ func (s *Server) requirePermission(perm string, handler http.HandlerFunc) http.H return func(w http.ResponseWriter, r *http.Request) { userRole := getRoleFromCtx(r) + // Device credentials authenticate an agent to its own protocol only. + // Never honor DB permission overrides for this internal role. + if auth.IsDeviceRole(userRole) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "Device credentials cannot access this endpoint"}) + return + } + if auth.IsProRole(userRole) && auth.ProRoleBlocksPermission(perm) { writeJSON(w, http.StatusForbidden, map[string]string{"error": "Insufficient permissions"}) return @@ -1175,6 +1182,8 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler { path == "/api/auth/oidc/session" || path == "/api/auth/oidc/exchange" || path == "/api/auth/sso/status" || strings.HasPrefix(path, "/ws/bd-mgmt/") || path == "/api/devices/register" || path == "/api/devices/register/status" || + path == "/api/devices/self/access-policy" || path == "/api/devices/self/help-request" || + path == "/api/devices/self/totp" || path == "/api/guest/access-links/validate" || path == "/api/guest/access-links/peers" { next.ServeHTTP(w, r) return @@ -1193,6 +1202,13 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Invalid or missing credentials"}) return } + if auth.IsDeviceRole(role) { + // Device JWTs are issued for the CDAP session only. The REST device + // self-service endpoints validate a bound device token explicitly, + // rather than treating a device as an operator-level API principal. + writeJSON(w, http.StatusForbidden, map[string]string{"error": "Device credentials cannot access this endpoint"}) + return + } ctx := context.WithValue(r.Context(), ctxKeyRole, role) ctx = context.WithValue(ctx, ctxKeyUsername, username) diff --git a/betterdesk-server/api/bd_mgmt_handlers.go b/betterdesk-server/api/bd_mgmt_handlers.go index a7ec3e38..e7a2460a 100644 --- a/betterdesk-server/api/bd_mgmt_handlers.go +++ b/betterdesk-server/api/bd_mgmt_handlers.go @@ -86,7 +86,7 @@ func (c *bdMgmtNonceCache) markUsed(key string, now time.Time) bool { } func bdMgmtSignaturePayload(deviceID, ts, nonce string) []byte { - return []byte(fmt.Sprintf("bd-mgmt-v1\n%s\n%s\n%s", deviceID, ts, nonce)) + return fmt.Appendf(nil, "bd-mgmt-v1\n%s\n%s\n%s", deviceID, ts, nonce) } func canonicalizeDevicePublicKey(encoded string) (string, error) { @@ -109,27 +109,31 @@ func (s *Server) storeBdMgmtPublicKey(deviceID, encoded string) error { } func (s *Server) loadBdMgmtPublicKey(deviceID string) ([]byte, error) { + // An explicit management key is an Ed25519 identity bound during + // enrollment. Prefer it over a generic peer PK, which may be a different + // protocol's 32-byte key and therefore is not necessarily an Ed25519 key. + stored, err := s.db.GetConfig(bdMgmtPublicKeyConfigPref + deviceID) + if err != nil { + return nil, err + } + if stored != "" { + decoded, err := base64.StdEncoding.DecodeString(stored) + if err != nil { + return nil, fmt.Errorf("invalid stored public key: %w", err) + } + if len(decoded) != ed25519.PublicKeySize { + return nil, fmt.Errorf("invalid stored public key length: %d", len(decoded)) + } + return decoded, nil + } + if peerInfo, err := s.db.GetPeer(deviceID); err == nil && peerInfo != nil && len(peerInfo.PK) == ed25519.PublicKeySize { pk := make([]byte, len(peerInfo.PK)) copy(pk, peerInfo.PK) return pk, nil } - stored, err := s.db.GetConfig(bdMgmtPublicKeyConfigPref + deviceID) - if err != nil { - return nil, err - } - if stored == "" { - return nil, errors.New("no bound device public key") - } - decoded, err := base64.StdEncoding.DecodeString(stored) - if err != nil { - return nil, fmt.Errorf("invalid stored public key: %w", err) - } - if len(decoded) != ed25519.PublicKeySize { - return nil, fmt.Errorf("invalid stored public key length: %d", len(decoded)) - } - return decoded, nil + return nil, errors.New("no bound device public key") } func (s *Server) verifyBdMgmtRequest(r *http.Request, deviceID string) error { diff --git a/betterdesk-server/api/branding_handlers.go b/betterdesk-server/api/branding_handlers.go index 1d0cf827..bd7841a9 100644 --- a/betterdesk-server/api/branding_handlers.go +++ b/betterdesk-server/api/branding_handlers.go @@ -3,14 +3,13 @@ package api import ( "encoding/base64" "encoding/json" + "fmt" "log" "net/http" "strconv" "strings" "time" - "golang.org/x/crypto/bcrypt" - "github.com/unitronix/betterdesk-server/db" "github.com/unitronix/betterdesk-server/events" ) @@ -123,8 +122,10 @@ type EnrollmentRequest struct { Platform string `json:"platform"` Version string `json:"version"` DeviceType string `json:"device_type,omitempty"` // "betterdesk", "rustdesk", "os_agent", etc. + BundleID string `json:"bundle_id,omitempty"` + Tags string `json:"tags,omitempty"` // comma-separated enrollment metadata PublicKey string `json:"public_key,omitempty"` - Token string `json:"token,omitempty"` // Optional enrollment token + Token string `json:"token,omitempty"` // Optional enrollment credential, POST body only } // EnrollmentResponse is returned to the desktop client. @@ -173,6 +174,44 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) { mode = "open" } + // Enrollment decisions and lifecycle blocks take precedence over an + // existing peer row. In particular, reject+ban creates a peer solely to + // retain audit metadata, so checking it after the existing-peer fast path + // would incorrectly approve that device and could reissue a credential. + if banned, _ := s.db.IsPeerBanned(req.DeviceID); banned { + resp := EnrollmentResponse{ + Status: "rejected", + DeviceID: req.DeviceID, + Message: "Device is banned", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(resp) + return + } + if removed, _ := s.db.IsPeerSoftDeleted(req.DeviceID); removed { + resp := EnrollmentResponse{ + Status: "rejected", + DeviceID: req.DeviceID, + Message: "Device has been removed", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(resp) + return + } + if rejected, _ := s.db.GetConfig(rejectedDevicePrefix + req.DeviceID); rejected != "" { + resp := EnrollmentResponse{ + Status: "rejected", + DeviceID: req.DeviceID, + Message: "Device enrollment was rejected", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(resp) + return + } + // Check if device already exists (re-registration = always approve) existing, _ := s.db.GetPeer(req.DeviceID) if existing != nil { @@ -190,17 +229,22 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(resp) return } + // An already-enrolled device must never be allowed to replace its + // public key through an unauthenticated re-registration request. + var boundKeyErr error if req.PublicKey != "" { incomingCanonical, _ := canonicalizeDevicePublicKey(req.PublicKey) - if bound, err := s.loadBdMgmtPublicKey(req.DeviceID); err == nil && len(bound) == 32 { + if bound, err := s.loadBdMgmtPublicKey(req.DeviceID); err == nil { boundCanonical := base64.StdEncoding.EncodeToString(bound) if incomingCanonical != boundCanonical { http.Error(w, "public_key does not match enrolled device identity", http.StatusForbidden) return } - } else if err := s.storeBdMgmtPublicKey(req.DeviceID, incomingCanonical); err != nil { - log.Printf("[API] Failed to bind public key for %s: %v", req.DeviceID, err) + } else { + boundKeyErr = err } + } else { + _, boundKeyErr = s.loadBdMgmtPublicKey(req.DeviceID) } // Device already known — return approved with current config @@ -211,43 +255,61 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) { displayName, _ := s.db.GetConfig("device_display_name_" + req.DeviceID) resp := s.buildEnrollmentResponse("approved", req.DeviceID, syncMode, displayName) - // Re-issue a device_token so an agent that lost its local copy - // (e.g. user reset agent-config) can recover authentication for the - // CDAP sidecar without manual intervention. Existing tokens remain - // valid — server stores only hashes so we cannot return the prior one. - if token, err := s.issueEnrollmentDeviceToken(req.DeviceID); err == nil { - resp.DeviceToken = token - log.Printf("[API] Re-issued enrollment device token for %s (len=%d)", req.DeviceID, len(token)) + // Reissuing a token is privileged: require either the existing + // device-bound credential or a replay-protected proof of possession of + // the key already bound to this device. A UUID alone is metadata, not a + // secret, and must never authorize token recovery. + hasBoundCredential := s.hasEnrollmentCredential( + req.DeviceID, + enrollmentTokenCandidates(r, req.Token), + true, + ) + authorized := s.authorizeEnrollmentTokenIssue(r, req.DeviceID, req.PublicKey, req.Token, true) + if authorized { + // A legacy device can attach a management key only after proving + // possession of a bound device token. Without that credential, + // accepting a new key here would let an attacker seize the identity. + if boundKeyErr != nil && req.PublicKey != "" { + if err := s.storeBdMgmtPublicKey(req.DeviceID, req.PublicKey); err != nil { + log.Printf("[API] Failed to bind public key for %s: %v", req.DeviceID, err) + } + } + // A normal authenticated refresh must not rotate/re-emit a usable + // device token. Only recovery with proof but without an active + // bound credential gets a replacement. + if !hasBoundCredential { + if token, err := s.issueEnrollmentDeviceToken(req.DeviceID); err == nil { + resp.DeviceToken = token + log.Printf("[API] Re-issued enrollment device token for %s (len=%d)", req.DeviceID, len(token)) + } else { + log.Printf("[API] Failed to re-issue enrollment device token for %s: %v", req.DeviceID, err) + } + } } else { - log.Printf("[API] Failed to re-issue enrollment device token for %s: %v", req.DeviceID, err) + resp.Message = "Device identity proof is required to issue a device token" } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) return } - // Check if banned or soft-deleted - if banned, _ := s.db.IsPeerBanned(req.DeviceID); banned { - resp := EnrollmentResponse{ - Status: "rejected", - DeviceID: req.DeviceID, - Message: "Device is banned", - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) - json.NewEncoder(w).Encode(resp) - return - } - switch mode { case "open": // Auto-approve: create peer immediately s.createPeerFromEnrollment(&req, clientIP) resp := s.buildEnrollmentResponse("approved", req.DeviceID, "standard", "") - if token, err := s.issueEnrollmentDeviceToken(req.DeviceID); err == nil { - resp.DeviceToken = token + // Open mode admits a new device, but it does not make an unauthenticated + // request eligible to receive a reusable device credential. The first + // request can prove possession of its supplied key; a later status poll + // can use that now-bound key if the first request did not include proof. + if s.authorizeEnrollmentTokenIssue(r, req.DeviceID, req.PublicKey, req.Token, false) { + if token, err := s.issueEnrollmentDeviceToken(req.DeviceID); err == nil { + resp.DeviceToken = token + } else { + log.Printf("[API] Failed to auto-issue enrollment device token for %s: %v", req.DeviceID, err) + } } else { - log.Printf("[API] Failed to auto-issue enrollment device token for %s: %v", req.DeviceID, err) + resp.Message = "Device identity proof is required to issue a device token" } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) @@ -261,7 +323,20 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) { case "managed": // Each support-agent installation registers without a shared bundle token. // Operator approval issues a unique device_token per device. - s.storePendingDevice(&req, clientIP) + if err := s.storePendingDevice(&req, clientIP); err != nil { + resp := EnrollmentResponse{ + Status: "rejected", + DeviceID: req.DeviceID, + Error: "identity_conflict", + Message: "Device ID is already pending for a different machine", + SuggestedDeviceID: suggestAlternateDeviceID(req.DeviceID), + ServerTime: timeNowUnixMilli(), + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(resp) + return + } resp := EnrollmentResponse{ Status: "pending", DeviceID: req.DeviceID, @@ -295,7 +370,8 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) { case "locked": // Only allow enrollment with a valid token if req.Token != "" { - if tok, err := s.db.GetDeviceTokenByHash(hashToken(req.Token)); err == nil && tok != nil { + if tok, err := s.db.ValidateToken(hashToken(req.Token)); err == nil && tok != nil && + (tok.PeerID == "" || tok.PeerID == req.DeviceID) { // Valid token — activate and bind to this device, then approve if tok.Status == "pending" { _ = s.db.BindTokenToPeer(tok.TokenHash, req.DeviceID) @@ -341,20 +417,78 @@ func (s *Server) handleDeviceRegisterStatus(w http.ResponseWriter, r *http.Reque return } + // A banned, removed, or explicitly rejected device must not use the + // otherwise-public status endpoint to obtain an approved response or a + // replacement token. + if banned, _ := s.db.IsPeerBanned(deviceID); banned { + resp := EnrollmentResponse{ + Status: "rejected", + DeviceID: deviceID, + Message: "Device is banned", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(resp) + return + } + if removed, _ := s.db.IsPeerSoftDeleted(deviceID); removed { + resp := EnrollmentResponse{ + Status: "rejected", + DeviceID: deviceID, + Message: "Device has been removed", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(resp) + return + } + if rejected, _ := s.db.GetConfig(rejectedDevicePrefix + deviceID); rejected != "" { + resp := EnrollmentResponse{ + Status: "rejected", + DeviceID: deviceID, + Message: "Device enrollment was rejected", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(resp) + return + } + // Check if approved (exists in peers table) if peer, _ := s.db.GetPeer(deviceID); peer != nil { + if peer.Banned || peer.Disabled || peer.SoftDeleted { + resp := EnrollmentResponse{ + Status: "rejected", + DeviceID: deviceID, + ServerTime: timeNowUnixMilli(), + Message: "Device enrollment is no longer active", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(resp) + return + } syncMode, _ := s.db.GetConfig("device_sync_mode_" + deviceID) if syncMode == "" { syncMode = "standard" } displayName, _ := s.db.GetConfig("device_display_name_" + deviceID) resp := s.buildEnrollmentResponse("approved", deviceID, syncMode, displayName) - // Issue a device_token on poll (same as re-register) so agents approved - // via the panel recover CDAP auth without another POST /devices/register. - if token, err := s.issueEnrollmentDeviceToken(deviceID); err == nil { - resp.DeviceToken = token + // Status polling remains available for enrollment state, but token + // issuance requires the device-bound credential or a signed proof. + hasBoundCredential := s.hasEnrollmentCredential( + deviceID, + enrollmentTokenCandidates(r, ""), + true, + ) + if !hasBoundCredential && s.authorizeEnrollmentTokenIssue(r, deviceID, "", "", true) { + if token, err := s.issueEnrollmentDeviceToken(deviceID); err == nil { + resp.DeviceToken = token + } else { + log.Printf("[API] Failed to issue enrollment device token on status poll for %s: %v", deviceID, err) + } } else { - log.Printf("[API] Failed to issue enrollment device token on status poll for %s: %v", deviceID, err) + resp.Message = "Device identity proof is required to issue a device token" } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) @@ -376,20 +510,6 @@ func (s *Server) handleDeviceRegisterStatus(w http.ResponseWriter, r *http.Reque return } - // Check if explicitly rejected - rejected, _ := s.db.GetConfig("rejected_device_" + deviceID) - if rejected != "" { - resp := EnrollmentResponse{ - Status: "rejected", - DeviceID: deviceID, - Message: "Device enrollment was rejected", - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) - json.NewEncoder(w).Encode(resp) - return - } - // Unknown — not registered resp := EnrollmentResponse{ Status: "unknown", @@ -415,27 +535,34 @@ const ( // enrollmentDecision is persisted under enrollment_decision_ so Approved / // Rejected filters can show Go enrollment history (#351). type enrollmentDecision struct { - DeviceID string `json:"device_id"` - Hostname string `json:"hostname"` - Platform string `json:"platform"` - Version string `json:"version"` - IP string `json:"ip"` - Status string `json:"status"` // approved | rejected - Banned bool `json:"banned"` - DecidedAt string `json:"decided_at"` - CreatedAt string `json:"created_at,omitempty"` - Actor string `json:"actor,omitempty"` + DeviceID string `json:"device_id"` + UUID string `json:"uuid,omitempty"` + Hostname string `json:"hostname"` + Platform string `json:"platform"` + Version string `json:"version"` + DeviceType string `json:"device_type,omitempty"` + BundleID string `json:"bundle_id,omitempty"` + Tags string `json:"tags,omitempty"` + IP string `json:"ip"` + Status string `json:"status"` // approved | rejected + Banned bool `json:"banned"` + DecidedAt string `json:"decided_at"` + CreatedAt string `json:"created_at,omitempty"` + Actor string `json:"actor,omitempty"` } type pendingEnrollmentMeta struct { - DeviceID string `json:"device_id"` - UUID string `json:"uuid"` - Hostname string `json:"hostname"` - Platform string `json:"platform"` - Version string `json:"version"` - PublicKey string `json:"public_key"` - IP string `json:"ip"` - CreatedAt string `json:"created_at"` + DeviceID string `json:"device_id"` + UUID string `json:"uuid"` + Hostname string `json:"hostname"` + Platform string `json:"platform"` + Version string `json:"version"` + DeviceType string `json:"device_type,omitempty"` + BundleID string `json:"bundle_id,omitempty"` + Tags string `json:"tags,omitempty"` + PublicKey string `json:"public_key"` + IP string `json:"ip"` + CreatedAt string `json:"created_at"` } func parsePendingEnrollmentMeta(raw string) pendingEnrollmentMeta { @@ -573,12 +700,15 @@ func (s *Server) handleApproveDevice(w http.ResponseWriter, r *http.Request) { // Create the peer enrollment := &EnrollmentRequest{ - DeviceID: pending.DeviceID, - UUID: pending.UUID, - Hostname: pending.Hostname, - Platform: pending.Platform, - Version: pending.Version, - PublicKey: pending.PublicKey, + DeviceID: pending.DeviceID, + UUID: pending.UUID, + Hostname: pending.Hostname, + Platform: pending.Platform, + Version: pending.Version, + DeviceType: pending.DeviceType, + BundleID: pending.BundleID, + Tags: pending.Tags, + PublicKey: pending.PublicKey, } s.createPeerFromEnrollment(enrollment, pending.IP) @@ -590,9 +720,11 @@ func (s *Server) handleApproveDevice(w http.ResponseWriter, r *http.Request) { s.db.UpdatePeerFields(deviceID, map[string]string{"note": req.DisplayName}) } - // Apply tags if provided (comma-separated, normalized) - tags := normalizeEnrollmentTags(req.Tags) - if tags != "" { + // Preserve enrollment-provided tags unless the approving operator supplied + // an explicit replacement. + tags := normalizeEnrollmentTags(pending.Tags) + if operatorTags := normalizeEnrollmentTags(req.Tags); operatorTags != "" { + tags = operatorTags s.db.UpdatePeerFields(deviceID, map[string]string{"tags": tags}) } @@ -602,16 +734,20 @@ func (s *Server) handleApproveDevice(w http.ResponseWriter, r *http.Request) { actor := getUsernameFromCtx(r) s.storeEnrollmentDecision(enrollmentDecision{ - DeviceID: deviceID, - Hostname: pending.Hostname, - Platform: pending.Platform, - Version: pending.Version, - IP: pending.IP, - Status: "approved", - Banned: false, - DecidedAt: timeNowISO(), - CreatedAt: pending.CreatedAt, - Actor: actor, + DeviceID: deviceID, + UUID: pending.UUID, + Hostname: pending.Hostname, + Platform: pending.Platform, + Version: pending.Version, + DeviceType: pending.DeviceType, + BundleID: pending.BundleID, + Tags: tags, + IP: pending.IP, + Status: "approved", + Banned: false, + DecidedAt: timeNowISO(), + CreatedAt: pending.CreatedAt, + Actor: actor, }) if s.auditLog != nil { @@ -666,15 +802,19 @@ func (s *Server) handleRejectDevice(w http.ResponseWriter, r *http.Request) { // Store rejection marker with metadata (status poll + history UI). rejectedPayload, _ := json.Marshal(map[string]interface{}{ - "rejected": true, - "device_id": deviceID, - "hostname": pending.Hostname, - "platform": pending.Platform, - "version": pending.Version, - "ip": pending.IP, - "created_at": pending.CreatedAt, - "banned": req.Ban, - "decided_at": timeNowISO(), + "rejected": true, + "device_id": deviceID, + "uuid": pending.UUID, + "hostname": pending.Hostname, + "platform": pending.Platform, + "version": pending.Version, + "device_type": pending.DeviceType, + "bundle_id": pending.BundleID, + "tags": pending.Tags, + "ip": pending.IP, + "created_at": pending.CreatedAt, + "banned": req.Ban, + "decided_at": timeNowISO(), }) s.db.SetConfig(rejectedDevicePrefix+deviceID, string(rejectedPayload)) @@ -687,12 +827,15 @@ func (s *Server) handleRejectDevice(w http.ResponseWriter, r *http.Request) { } if existing == nil { s.createPeerFromEnrollment(&EnrollmentRequest{ - DeviceID: pending.DeviceID, - UUID: pending.UUID, - Hostname: pending.Hostname, - Platform: pending.Platform, - Version: pending.Version, - PublicKey: pending.PublicKey, + DeviceID: pending.DeviceID, + UUID: pending.UUID, + Hostname: pending.Hostname, + Platform: pending.Platform, + Version: pending.Version, + DeviceType: pending.DeviceType, + BundleID: pending.BundleID, + Tags: pending.Tags, + PublicKey: pending.PublicKey, }, pending.IP) } if err := s.db.BanPeer(deviceID, enrollmentRejectBanReason); err != nil { @@ -707,16 +850,20 @@ func (s *Server) handleRejectDevice(w http.ResponseWriter, r *http.Request) { actor := getUsernameFromCtx(r) s.storeEnrollmentDecision(enrollmentDecision{ - DeviceID: deviceID, - Hostname: pending.Hostname, - Platform: pending.Platform, - Version: pending.Version, - IP: pending.IP, - Status: "rejected", - Banned: req.Ban, - DecidedAt: timeNowISO(), - CreatedAt: pending.CreatedAt, - Actor: actor, + DeviceID: deviceID, + UUID: pending.UUID, + Hostname: pending.Hostname, + Platform: pending.Platform, + Version: pending.Version, + DeviceType: pending.DeviceType, + BundleID: pending.BundleID, + Tags: pending.Tags, + IP: pending.IP, + Status: "rejected", + Banned: req.Ban, + DecidedAt: timeNowISO(), + CreatedAt: pending.CreatedAt, + Actor: actor, }) if s.auditLog != nil { @@ -873,10 +1020,11 @@ func (s *Server) issueEnrollmentDeviceToken(deviceID string) (string, error) { } func (s *Server) createPeerFromEnrollment(req *EnrollmentRequest, clientIP string) { - devType := req.DeviceType + devType := strings.TrimSpace(req.DeviceType) if devType == "" { devType = "betterdesk" } + tags := normalizeEnrollmentTags(req.Tags) s.db.UpsertPeer(&db.Peer{ ID: req.DeviceID, @@ -886,6 +1034,7 @@ func (s *Server) createPeerFromEnrollment(req *EnrollmentRequest, clientIP strin OS: req.Platform, Version: req.Version, DeviceType: devType, + Tags: tags, Status: "ONLINE", }) @@ -894,8 +1043,19 @@ func (s *Server) createPeerFromEnrollment(req *EnrollmentRequest, clientIP strin s.db.UpdatePeerSysinfo(req.DeviceID, req.Hostname, req.Platform, req.Version) } - // Persist device_type via UpdatePeerFields - s.db.UpdatePeerFields(req.DeviceID, map[string]string{"device_type": devType}) + // Persist metadata via UpdatePeerFields so both SQLite and PostgreSQL + // retain it when the peer row already existed. + fields := map[string]string{"device_type": devType} + if tags != "" { + fields["tags"] = tags + } + s.db.UpdatePeerFields(req.DeviceID, fields) + + if bundleID := normalizeEnrollmentBundleID(req.BundleID); bundleID != "" { + if err := s.db.SetConfig(deviceBundleIDPrefix+req.DeviceID, bundleID); err != nil { + log.Printf("[API] Failed to persist bundle ID for %s: %v", req.DeviceID, err) + } + } if req.PublicKey != "" { if err := s.storeBdMgmtPublicKey(req.DeviceID, req.PublicKey); err != nil { @@ -904,26 +1064,61 @@ func (s *Server) createPeerFromEnrollment(req *EnrollmentRequest, clientIP strin } } -type pendingDeviceInfo struct { - DeviceID string `json:"device_id"` - Hostname string `json:"hostname"` - Platform string `json:"platform"` - Version string `json:"version"` - IP string `json:"ip"` - CreatedAt string `json:"created_at"` +// pendingDeviceInfo is also returned to the approval UI. It intentionally +// mirrors pendingEnrollmentMeta so UUID, device type, tags, bundle ID, and +// public-key binding survive the pending → approved transition. +type pendingDeviceInfo = pendingEnrollmentMeta + +const deviceBundleIDPrefix = "device_bundle_id_" + +func normalizeEnrollmentBundleID(raw string) string { + bundleID := strings.TrimSpace(raw) + if len(bundleID) > 128 { + return bundleID[:128] + } + return bundleID } -func (s *Server) storePendingDevice(req *EnrollmentRequest, clientIP string) { - info := pendingDeviceInfo{ - DeviceID: req.DeviceID, - Hostname: req.Hostname, - Platform: req.Platform, - Version: req.Version, - IP: clientIP, - CreatedAt: timeNowISO(), +// storePendingDevice keeps the first identity submission immutable. Otherwise +// a later unauthenticated retry could replace the public key used to recover a +// token after operator approval. +func (s *Server) storePendingDevice(req *EnrollmentRequest, clientIP string) error { + if raw, err := s.db.GetConfig(pendingDevicePrefix + req.DeviceID); err == nil && raw != "" { + existing := parsePendingEnrollmentMeta(raw) + if existing.DeviceID == "" { + existing.DeviceID = req.DeviceID + } + if existing.UUID != "" && req.UUID != "" && existing.UUID != req.UUID { + return fmt.Errorf("pending device UUID does not match") + } + if existing.PublicKey != "" && req.PublicKey != "" { + incoming, _ := canonicalizeDevicePublicKey(req.PublicKey) + stored, err := canonicalizeDevicePublicKey(existing.PublicKey) + if err != nil || incoming != stored { + return fmt.Errorf("pending device public key does not match") + } + } + return nil } - data, _ := json.Marshal(info) - s.db.SetConfig(pendingDevicePrefix+req.DeviceID, string(data)) + + info := pendingDeviceInfo{ + DeviceID: req.DeviceID, + UUID: req.UUID, + Hostname: req.Hostname, + Platform: req.Platform, + Version: req.Version, + DeviceType: strings.TrimSpace(req.DeviceType), + BundleID: normalizeEnrollmentBundleID(req.BundleID), + Tags: normalizeEnrollmentTags(req.Tags), + PublicKey: req.PublicKey, + IP: clientIP, + CreatedAt: timeNowISO(), + } + data, err := json.Marshal(info) + if err != nil { + return err + } + return s.db.SetConfig(pendingDevicePrefix+req.DeviceID, string(data)) } func (s *Server) listPendingDevices() []pendingDeviceInfo { @@ -977,14 +1172,16 @@ func normalizeEnrollmentTags(raw string) string { return strings.Join(out, ",") } -// handleDeviceSelfAccessPolicy lets an enrolled device push its local access -// password and unattended flag (support agent minimal client). +// handleDeviceSelfAccessPolicy lets an enrolled device publish its local +// access policy. The password itself never leaves the Support Agent: the +// server keeps only a non-verifier marker for UI/audit status. // POST /api/devices/self/access-policy func (s *Server) handleDeviceSelfAccessPolicy(w http.ResponseWriter, r *http.Request) { var body struct { DeviceID string `json:"device_id"` DeviceToken string `json:"device_token"` - Password string `json:"password"` + Password string `json:"password,omitempty"` // rejected legacy field + PasswordSet bool `json:"password_set"` UnattendedEnabled bool `json:"unattended_enabled"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { @@ -996,14 +1193,12 @@ func (s *Server) handleDeviceSelfAccessPolicy(w http.ResponseWriter, r *http.Req return } - tokenHash := hashToken(body.DeviceToken) - dt, err := s.db.ValidateToken(tokenHash) - if err != nil || dt == nil { + if !s.hasBoundActiveDeviceToken(body.DeviceID, body.DeviceToken) { http.Error(w, "invalid device token", http.StatusForbidden) return } - if dt.PeerID != "" && dt.PeerID != body.DeviceID { - http.Error(w, "token bound to another device", http.StatusForbidden) + if body.Password != "" { + http.Error(w, "password material must remain on the device", http.StatusBadRequest) return } @@ -1012,13 +1207,13 @@ func (s *Server) handleDeviceSelfAccessPolicy(w http.ResponseWriter, r *http.Req UnattendedEnabled: body.UnattendedEnabled, UpdatedBy: "device:" + body.DeviceID, } - if body.Password != "" { - hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost) - if err != nil { - http.Error(w, "failed to hash password", http.StatusInternalServerError) - return - } - policy.PasswordHash = string(hash) + if body.PasswordSet { + // This marker is intentionally not a password verifier. Relay and CDAP + // authorization always verify the local secret on the device. + policy.PasswordHash = "LOCAL_ONLY" + } else { + // Clear legacy server-side password hashes after an agent upgrades. + policy.PasswordHash = "CLEAR" } if err := s.db.SaveAccessPolicy(policy); err != nil { http.Error(w, "failed to save policy", http.StatusInternalServerError) diff --git a/betterdesk-server/api/device_access_policy_test.go b/betterdesk-server/api/device_access_policy_test.go new file mode 100644 index 00000000..711716b8 --- /dev/null +++ b/betterdesk-server/api/device_access_policy_test.go @@ -0,0 +1,86 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/unitronix/betterdesk-server/config" + "github.com/unitronix/betterdesk-server/db" + "github.com/unitronix/betterdesk-server/peer" +) + +func TestDeviceSelfAccessPolicyNeverAcceptsPasswordMaterial(t *testing.T) { + database := testSetupDB(t) + defer database.Close() + const deviceID = "BD-LOCAL-PASSWORD" + if err := database.UpsertPeer(&db.Peer{ID: deviceID, Status: "ONLINE", DeviceType: "os_agent"}); err != nil { + t.Fatal(err) + } + srv := New(config.DefaultConfig(), database, peer.NewMap(), nil, "test") + token, err := srv.issueEnrollmentDeviceToken(deviceID) + if err != nil { + t.Fatal(err) + } + + post := func(body map[string]any) *httptest.ResponseRecorder { + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/api/devices/self/access-policy", bytes.NewReader(raw)) + rec := httptest.NewRecorder() + srv.handleDeviceSelfAccessPolicy(rec, req) + return rec + } + + rec := post(map[string]any{ + "device_id": deviceID, "device_token": token, + "password": "must-not-leave-the-agent", "password_set": true, + }) + if rec.Code != http.StatusBadRequest { + t.Fatalf("password payload status = %d body=%s", rec.Code, rec.Body.String()) + } + + rec = post(map[string]any{ + "device_id": deviceID, "device_token": token, + "password_set": true, "unattended_enabled": true, + }) + if rec.Code != http.StatusNoContent { + t.Fatalf("password status payload status = %d body=%s", rec.Code, rec.Body.String()) + } + policy, err := database.GetAccessPolicy(deviceID) + if err != nil { + t.Fatal(err) + } + if !policy.PasswordSet || policy.PasswordHash != "LOCAL_ONLY" { + t.Fatalf("expected local-only password marker, got %+v", policy) + } +} + +func TestDeviceTOTPIgnoresCredentialsInQueryString(t *testing.T) { + database := testSetupDB(t) + defer database.Close() + const deviceID = "BD-TOTP-QUERY" + if err := database.UpsertPeer(&db.Peer{ID: deviceID, Status: "ONLINE", DeviceType: "os_agent"}); err != nil { + t.Fatal(err) + } + srv := New(config.DefaultConfig(), database, peer.NewMap(), nil, "test") + token, err := srv.issueEnrollmentDeviceToken(deviceID) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest( + http.MethodPost, + "/api/devices/self/totp?device_id="+deviceID+"&device_token="+token, + bytes.NewBufferString(`{"action":"status"}`), + ) + rec := httptest.NewRecorder() + srv.handleDeviceSelfTOTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("query credentials status = %d body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/betterdesk-server/api/device_role_test.go b/betterdesk-server/api/device_role_test.go new file mode 100644 index 00000000..8ffdd06b --- /dev/null +++ b/betterdesk-server/api/device_role_test.go @@ -0,0 +1,81 @@ +package api + +import ( + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/unitronix/betterdesk-server/auth" + "github.com/unitronix/betterdesk-server/config" + "github.com/unitronix/betterdesk-server/db" + "github.com/unitronix/betterdesk-server/peer" +) + +func TestDeviceJWTCannotAccessOperatorAPIHandlers(t *testing.T) { + database := testSetupDB(t) + defer database.Close() + + srv := New(config.DefaultConfig(), database, peer.NewMap(), nil, "test") + jwtManager := auth.NewJWTManager("device-role-test-secret", time.Hour) + srv.SetJWTManager(jwtManager) + + token, err := jwtManager.Generate("device:AGENT001", auth.RoleDevice) + if err != nil { + t.Fatal(err) + } + called := false + handler := srv.authMiddleware(srv.requireRole(auth.RoleOperator, func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/api/peers", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403: %s", rec.Code, rec.Body.String()) + } + if called { + t.Fatal("device-scoped JWT reached an operator API handler") + } +} + +func TestDeviceSelfServiceRequiresActiveBoundToken(t *testing.T) { + database := testSetupDB(t) + defer database.Close() + + if err := database.UpsertPeer(&db.Peer{ID: "AGENT001", Status: "ONLINE"}); err != nil { + t.Fatal(err) + } + srv := New(config.DefaultConfig(), database, peer.NewMap(), nil, "test") + + const pendingToken = "pending-enrollment-token-0123456789" + pendingHash := sha256.Sum256([]byte(pendingToken)) + if err := database.CreateDeviceToken(&db.DeviceToken{ + Token: pendingToken, + TokenHash: hex.EncodeToString(pendingHash[:]), + Name: "pending enrollment", + Status: db.TokenStatusPending, + }); err != nil { + t.Fatal(err) + } + if _, ok := deviceTokenPeerID(srv, "AGENT001", pendingToken); ok { + t.Fatal("unbound pending enrollment token authenticated a device self-service request") + } + + boundToken, err := srv.issueEnrollmentDeviceToken("AGENT001") + if err != nil { + t.Fatal(err) + } + if _, ok := deviceTokenPeerID(srv, "AGENT001", boundToken); !ok { + t.Fatal("active token bound to its device was rejected") + } + if _, ok := deviceTokenPeerID(srv, "OTHER001", boundToken); ok { + t.Fatal("device token authenticated a different device") + } +} diff --git a/betterdesk-server/api/device_totp_handlers.go b/betterdesk-server/api/device_totp_handlers.go index 21e80612..bce8f07c 100644 --- a/betterdesk-server/api/device_totp_handlers.go +++ b/betterdesk-server/api/device_totp_handlers.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/unitronix/betterdesk-server/auth" + "github.com/unitronix/betterdesk-server/db" ) type deviceTOTPStatus struct { @@ -14,21 +15,33 @@ type deviceTOTPStatus struct { Secret string `json:"secret,omitempty"` } -func deviceTokenPeerID(s *Server, deviceID, token string) (string, bool) { - if deviceID == "" || token == "" { - return "", false +// hasBoundActiveDeviceToken verifies a credential used by a device self-service +// endpoint. Enrollment tokens are intentionally allowed to be unbound while a +// device is enrolling, but they must never authorize actions as an enrolled +// device before they are active and tied to that exact peer. +func (s *Server) hasBoundActiveDeviceToken(deviceID, token string) bool { + if s.db == nil || deviceID == "" || token == "" { + return false } dt, err := s.db.ValidateToken(hashToken(token)) - if err != nil || dt == nil { - return "", false + if err != nil || dt == nil || dt.Status != db.TokenStatusActive || dt.PeerID != deviceID { + return false } - if dt.PeerID != "" && dt.PeerID != deviceID { + peer, err := s.db.GetPeer(deviceID) + return err == nil && peer != nil && !peer.Banned && !peer.SoftDeleted +} + +func deviceTokenPeerID(s *Server, deviceID, token string) (string, bool) { + if !s.hasBoundActiveDeviceToken(deviceID, token) { return "", false } return deviceID, true } -// GET/POST /api/devices/self/totp +// POST /api/devices/self/totp +// +// Device credentials and TOTP codes are accepted only in the JSON body. Query +// parameters are routinely captured by proxies, browser history, and logs. func (s *Server) handleDeviceSelfTOTP(w http.ResponseWriter, r *http.Request) { var body struct { DeviceID string `json:"device_id"` @@ -36,17 +49,14 @@ func (s *Server) handleDeviceSelfTOTP(w http.ResponseWriter, r *http.Request) { Action string `json:"action"` Code string `json:"code"` } - if r.Method == http.MethodPost { - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "Invalid JSON", http.StatusBadRequest) - return - } + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return } - if body.DeviceID == "" { - body.DeviceID = r.URL.Query().Get("device_id") - } - if body.DeviceToken == "" { - body.DeviceToken = r.URL.Query().Get("device_token") + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return } if _, ok := deviceTokenPeerID(s, body.DeviceID, body.DeviceToken); !ok { http.Error(w, "invalid device token", http.StatusForbidden) @@ -56,13 +66,6 @@ func (s *Server) handleDeviceSelfTOTP(w http.ResponseWriter, r *http.Request) { enabledKey := "device_totp_enabled_" + body.DeviceID secretKey := "device_totp_secret_" + body.DeviceID - if r.Method == http.MethodGet { - enabled, _ := s.db.GetConfig(enabledKey) - resp := deviceTOTPStatus{Enabled: enabled == "true"} - writeDeviceJSON(w, resp) - return - } - switch strings.ToLower(strings.TrimSpace(body.Action)) { case "setup": secret := auth.GenerateTOTPSecret() diff --git a/betterdesk-server/api/enrollment_proof.go b/betterdesk-server/api/enrollment_proof.go new file mode 100644 index 00000000..4ea221b7 --- /dev/null +++ b/betterdesk-server/api/enrollment_proof.go @@ -0,0 +1,225 @@ +package api + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/unitronix/betterdesk-server/db" +) + +const ( + enrollmentProofClockSkew = 5 * time.Minute + enrollmentProofNonceMax = 256 +) + +var enrollmentProofNonceCache = &bdMgmtNonceCache{items: make(map[string]time.Time)} + +// enrollmentProofPayload is deliberately distinct from the management-channel +// signature format. A valid management proof must not be replayable to mint a +// device token, and vice versa. +func enrollmentProofPayload(method, path, deviceID, publicKey, timestamp, nonce string) []byte { + return fmt.Appendf(nil, + "bd-enrollment-v1\n%s\n%s\n%s\n%s\n%s\n%s", + method, path, deviceID, publicKey, timestamp, nonce, + ) +} + +// verifyEnrollmentDeviceProof verifies a short-lived proof of possession for +// the device identity. Existing peers must use their already-bound key; an +// untrusted request cannot replace that key. A first registration may prove +// possession of the public_key it is binding, which is sufficient in open mode +// where admission itself is intentionally unrestricted. +func (s *Server) verifyEnrollmentDeviceProof(r *http.Request, deviceID, incomingPublicKey string) error { + timestamp := strings.TrimSpace(r.Header.Get("X-BD-Enrollment-Timestamp")) + nonce := strings.TrimSpace(r.Header.Get("X-BD-Enrollment-Nonce")) + signature := strings.TrimSpace(r.Header.Get("X-BD-Enrollment-Signature")) + if timestamp == "" || nonce == "" || signature == "" { + return fmt.Errorf("missing enrollment proof headers") + } + if len(nonce) > enrollmentProofNonceMax { + return fmt.Errorf("enrollment proof nonce is too long") + } + + signedAt, err := time.Parse(time.RFC3339, timestamp) + if err != nil { + return fmt.Errorf("invalid enrollment proof timestamp: %w", err) + } + now := time.Now().UTC() + delta := now.Sub(signedAt.UTC()) + if delta < 0 { + delta = -delta + } + if delta > enrollmentProofClockSkew { + return fmt.Errorf("enrollment proof timestamp outside allowed skew") + } + + canonicalKey, publicKey, err := s.enrollmentProofPublicKey(deviceID, incomingPublicKey) + if err != nil { + return err + } + decodedSignature, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + return fmt.Errorf("invalid enrollment proof signature encoding: %w", err) + } + if len(decodedSignature) != ed25519.SignatureSize { + return fmt.Errorf("invalid enrollment proof signature length") + } + + payload := enrollmentProofPayload(r.Method, r.URL.Path, deviceID, canonicalKey, timestamp, nonce) + if !ed25519.Verify(ed25519.PublicKey(publicKey), payload, decodedSignature) { + return fmt.Errorf("invalid enrollment proof signature") + } + if enrollmentProofNonceCache.markUsed("enrollment:"+deviceID+":"+nonce, now) { + return fmt.Errorf("replayed enrollment proof") + } + return nil +} + +// enrollmentProofPublicKey returns the only key that may authenticate an +// enrollment token issuance for deviceID. Pending metadata is trusted only +// because it was captured on the initial request and is immutable afterward. +func (s *Server) enrollmentProofPublicKey(deviceID, incomingPublicKey string) (string, []byte, error) { + if s.db == nil { + return "", nil, fmt.Errorf("device database unavailable") + } + + peerInfo, err := s.db.GetPeer(deviceID) + if err != nil { + return "", nil, fmt.Errorf("load enrolled device: %w", err) + } + if peerInfo != nil { + publicKey, err := s.loadBdMgmtPublicKey(deviceID) + if err != nil { + return "", nil, fmt.Errorf("no bound device identity: %w", err) + } + canonical := base64.StdEncoding.EncodeToString(publicKey) + if incomingPublicKey != "" { + incomingCanonical, err := canonicalizeDevicePublicKey(incomingPublicKey) + if err != nil { + return "", nil, err + } + if incomingCanonical != canonical { + return "", nil, fmt.Errorf("public_key does not match bound device identity") + } + } + return canonical, publicKey, nil + } + + if pending, ok := s.pendingEnrollmentForProof(deviceID); ok && pending.PublicKey != "" { + canonical, err := canonicalizeDevicePublicKey(pending.PublicKey) + if err != nil { + return "", nil, fmt.Errorf("invalid pending device public key: %w", err) + } + if incomingPublicKey != "" { + incomingCanonical, err := canonicalizeDevicePublicKey(incomingPublicKey) + if err != nil { + return "", nil, err + } + if incomingCanonical != canonical { + return "", nil, fmt.Errorf("public_key does not match pending device identity") + } + } + publicKey, err := base64.StdEncoding.DecodeString(canonical) + if err != nil { + return "", nil, err + } + return canonical, publicKey, nil + } + + canonical, err := canonicalizeDevicePublicKey(incomingPublicKey) + if err != nil { + return "", nil, fmt.Errorf("public_key required for initial enrollment proof: %w", err) + } + publicKey, err := base64.StdEncoding.DecodeString(canonical) + if err != nil { + return "", nil, err + } + return canonical, publicKey, nil +} + +func (s *Server) pendingEnrollmentForProof(deviceID string) (pendingDeviceInfo, bool) { + var pending pendingDeviceInfo + raw, err := s.db.GetConfig(pendingDevicePrefix + deviceID) + if err != nil || raw == "" { + return pending, false + } + if err := json.Unmarshal([]byte(raw), &pending); err != nil || pending.DeviceID == "" { + return pendingDeviceInfo{}, false + } + return pending, true +} + +// enrollmentTokenCandidates reads credentials only from a POST body field or +// the standard Authorization header. Tokens are intentionally never accepted +// from query parameters, which are commonly retained by access logs and +// intermediaries. +func enrollmentTokenCandidates(r *http.Request, bodyToken string) []string { + candidates := make([]string, 0, 2) + if token := strings.TrimSpace(bodyToken); token != "" { + candidates = append(candidates, token) + } + if authorization := r.Header.Get("Authorization"); len(authorization) > len("Bearer ") && + strings.EqualFold(authorization[:len("Bearer ")], "Bearer ") { + if token := strings.TrimSpace(authorization[len("Bearer "):]); token != "" { + candidates = append(candidates, token) + } + } + return candidates +} + +// enrollmentProofProvided distinguishes a legacy credential-only request from +// a request that attempted proof-of-possession. The latter must not fall back +// to the bearer credential after an invalid or replayed proof: otherwise a +// captured request could mint another token despite nonce replay protection. +func enrollmentProofProvided(r *http.Request) bool { + if r == nil { + return false + } + return strings.TrimSpace(r.Header.Get("X-BD-Enrollment-Timestamp")) != "" || + strings.TrimSpace(r.Header.Get("X-BD-Enrollment-Nonce")) != "" || + strings.TrimSpace(r.Header.Get("X-BD-Enrollment-Signature")) != "" +} + +// hasEnrollmentCredential accepts a valid device/enrollment token only when +// it is either unbound (for first enrollment) or bound to the exact device. +// Reissuance always requires an active, device-bound credential. +func (s *Server) hasEnrollmentCredential(deviceID string, candidates []string, requireBound bool) bool { + for _, candidate := range candidates { + token, err := s.db.ValidateToken(hashToken(candidate)) + if err != nil || token == nil { + continue + } + if requireBound { + if token.Status == db.TokenStatusActive && token.PeerID == deviceID { + return true + } + continue + } + if token.PeerID == "" || token.PeerID == deviceID { + return true + } + } + return false +} + +// authorizeEnrollmentTokenIssue requires a credential that is already tied to +// the device, or a replay-protected proof made by that device's private key. +func (s *Server) authorizeEnrollmentTokenIssue(r *http.Request, deviceID, publicKey, bodyToken string, requireBoundToken bool) bool { + if r == nil { + return false + } + if enrollmentProofProvided(r) { + // Do not fall back to a valid bearer token here. A replayed proof must + // be rejected rather than bypassing the nonce cache through Authorization. + return s.verifyEnrollmentDeviceProof(r, deviceID, publicKey) == nil + } + if s.hasEnrollmentCredential(deviceID, enrollmentTokenCandidates(r, bodyToken), requireBoundToken) { + return true + } + return false +} diff --git a/betterdesk-server/api/enrollment_test.go b/betterdesk-server/api/enrollment_test.go index 38f02481..625033dd 100644 --- a/betterdesk-server/api/enrollment_test.go +++ b/betterdesk-server/api/enrollment_test.go @@ -2,16 +2,35 @@ package api import ( "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" + "time" "github.com/unitronix/betterdesk-server/config" "github.com/unitronix/betterdesk-server/db" "github.com/unitronix/betterdesk-server/peer" ) +func signedEnrollmentHeaders(t *testing.T, privateKey ed25519.PrivateKey, method, path, deviceID string, publicKey ed25519.PublicKey) http.Header { + t.Helper() + timestamp := time.Now().UTC().Format(time.RFC3339) + nonce := fmt.Sprintf("%s-%d", t.Name(), time.Now().UnixNano()) + canonicalKey := base64.StdEncoding.EncodeToString(publicKey) + signature := ed25519.Sign(privateKey, enrollmentProofPayload(method, path, deviceID, canonicalKey, timestamp, nonce)) + + headers := make(http.Header) + headers.Set("X-BD-Enrollment-Timestamp", timestamp) + headers.Set("X-BD-Enrollment-Nonce", nonce) + headers.Set("X-BD-Enrollment-Signature", base64.StdEncoding.EncodeToString(signature)) + return headers +} + func TestDeviceRegisterIdentityConflict(t *testing.T) { database := testSetupDB(t) defer database.Close() @@ -58,6 +77,10 @@ func TestDeviceRegisterSameUUIDReissues(t *testing.T) { database := testSetupDB(t) defer database.Close() + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } database.UpsertPeer(&db.Peer{ ID: "BD-TEST2", UUID: "same-machine-uuid", @@ -66,6 +89,9 @@ func TestDeviceRegisterSameUUIDReissues(t *testing.T) { cfg := config.DefaultConfig() cfg.EnrollmentMode = "open" srv := New(cfg, database, peer.NewMap(), nil, "test") + if err := srv.storeBdMgmtPublicKey("BD-TEST2", base64.StdEncoding.EncodeToString(publicKey)); err != nil { + t.Fatal(err) + } mux := http.NewServeMux() mux.HandleFunc("POST /api/devices/register", srv.handleDeviceRegister) @@ -75,9 +101,11 @@ func TestDeviceRegisterSameUUIDReissues(t *testing.T) { "hostname": "host-a", "platform": "linux amd64", "device_type": "os_agent", + "public_key": base64.StdEncoding.EncodeToString(publicKey), }) req := httptest.NewRequest(http.MethodPost, "/api/devices/register", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") + applyHeaders(req, signedEnrollmentHeaders(t, privateKey, http.MethodPost, "/api/devices/register", "BD-TEST2", publicKey)) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) @@ -96,6 +124,240 @@ func TestDeviceRegisterSameUUIDReissues(t *testing.T) { } } +func TestDeviceRegisterDoesNotReissueTokenWithoutIdentityProof(t *testing.T) { + database := testSetupDB(t) + defer database.Close() + + publicKey, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + if err := database.UpsertPeer(&db.Peer{ + ID: "BD-NOPROOF", + UUID: "machine-uuid-no-proof", + }); err != nil { + t.Fatal(err) + } + + srv := New(config.DefaultConfig(), database, peer.NewMap(), nil, "test") + if err := srv.storeBdMgmtPublicKey("BD-NOPROOF", base64.StdEncoding.EncodeToString(publicKey)); err != nil { + t.Fatal(err) + } + + body, _ := json.Marshal(map[string]any{ + "device_id": "BD-NOPROOF", + "uuid": "machine-uuid-no-proof", + "public_key": base64.StdEncoding.EncodeToString(publicKey), + }) + req := httptest.NewRequest(http.MethodPost, "/api/devices/register", bytes.NewReader(body)) + rec := httptest.NewRecorder() + srv.handleDeviceRegister(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + var resp EnrollmentResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.DeviceToken != "" { + t.Fatal("unauthenticated re-registration must not receive a device token") + } +} + +func TestDeviceRegisterStatusIssuesTokenOnlyAfterIdentityProof(t *testing.T) { + database := testSetupDB(t) + defer database.Close() + + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + if err := database.UpsertPeer(&db.Peer{ + ID: "BD-STATUS", + UUID: "machine-uuid-status", + }); err != nil { + t.Fatal(err) + } + + srv := New(config.DefaultConfig(), database, peer.NewMap(), nil, "test") + if err := srv.storeBdMgmtPublicKey("BD-STATUS", base64.StdEncoding.EncodeToString(publicKey)); err != nil { + t.Fatal(err) + } + + noProof := httptest.NewRequest(http.MethodGet, "/api/devices/register/status?device_id=BD-STATUS", nil) + noProofRec := httptest.NewRecorder() + srv.handleDeviceRegisterStatus(noProofRec, noProof) + var noProofResp EnrollmentResponse + if err := json.Unmarshal(noProofRec.Body.Bytes(), &noProofResp); err != nil { + t.Fatal(err) + } + if noProofResp.DeviceToken != "" { + t.Fatal("unauthenticated status poll must not receive a device token") + } + + proof := httptest.NewRequest(http.MethodGet, "/api/devices/register/status?device_id=BD-STATUS", nil) + applyHeaders(proof, signedEnrollmentHeaders(t, privateKey, http.MethodGet, "/api/devices/register/status", "BD-STATUS", publicKey)) + proofRec := httptest.NewRecorder() + srv.handleDeviceRegisterStatus(proofRec, proof) + if proofRec.Code != http.StatusOK { + t.Fatalf("signed status = %d, want 200: %s", proofRec.Code, proofRec.Body.String()) + } + var proofResp EnrollmentResponse + if err := json.Unmarshal(proofRec.Body.Bytes(), &proofResp); err != nil { + t.Fatal(err) + } + if proofResp.DeviceToken == "" { + t.Fatal("signed status poll must receive a device token") + } + + refresh := httptest.NewRequest(http.MethodGet, "/api/devices/register/status?device_id=BD-STATUS", nil) + applyHeaders(refresh, signedEnrollmentHeaders(t, privateKey, http.MethodGet, "/api/devices/register/status", "BD-STATUS", publicKey)) + refresh.Header.Set("Authorization", "Bearer "+proofResp.DeviceToken) + refreshRec := httptest.NewRecorder() + srv.handleDeviceRegisterStatus(refreshRec, refresh) + if refreshRec.Code != http.StatusOK { + t.Fatalf("authenticated refresh = %d, want 200: %s", refreshRec.Code, refreshRec.Body.String()) + } + var refreshResp EnrollmentResponse + if err := json.Unmarshal(refreshRec.Body.Bytes(), &refreshResp); err != nil { + t.Fatal(err) + } + if refreshResp.DeviceToken != "" { + t.Fatal("ordinary authenticated refresh must not re-issue a device token") + } +} + +func TestEnrollmentProofCannotBeReplayed(t *testing.T) { + database := testSetupDB(t) + defer database.Close() + + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + if err := database.UpsertPeer(&db.Peer{ID: "BD-REPLAY", UUID: "machine-uuid-replay"}); err != nil { + t.Fatal(err) + } + srv := New(config.DefaultConfig(), database, peer.NewMap(), nil, "test") + if err := srv.storeBdMgmtPublicKey("BD-REPLAY", base64.StdEncoding.EncodeToString(publicKey)); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/devices/register/status?device_id=BD-REPLAY", nil) + applyHeaders(req, signedEnrollmentHeaders(t, privateKey, http.MethodGet, "/api/devices/register/status", "BD-REPLAY", publicKey)) + if err := srv.verifyEnrollmentDeviceProof(req, "BD-REPLAY", ""); err != nil { + t.Fatalf("first proof verification: %v", err) + } + if err := srv.verifyEnrollmentDeviceProof(req, "BD-REPLAY", ""); err == nil { + t.Fatal("replayed enrollment proof was accepted") + } +} + +func TestOpenEnrollmentRequiresProofBeforeIssuingDeviceToken(t *testing.T) { + database := testSetupDB(t) + defer database.Close() + + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + srv := New(config.DefaultConfig(), database, peer.NewMap(), nil, "test") + + body, _ := json.Marshal(map[string]any{ + "device_id": "BD-OPEN01", + "uuid": "machine-uuid-open", + "device_type": "os_agent", + "public_key": base64.StdEncoding.EncodeToString(publicKey), + }) + unauthenticated := httptest.NewRequest(http.MethodPost, "/api/devices/register", bytes.NewReader(body)) + unauthenticatedRec := httptest.NewRecorder() + srv.handleDeviceRegister(unauthenticatedRec, unauthenticated) + var unauthenticatedResp EnrollmentResponse + if err := json.Unmarshal(unauthenticatedRec.Body.Bytes(), &unauthenticatedResp); err != nil { + t.Fatal(err) + } + if unauthenticatedResp.DeviceToken != "" { + t.Fatal("open enrollment without proof must not issue a device token") + } + + proof := httptest.NewRequest(http.MethodPost, "/api/devices/register", bytes.NewReader(body)) + applyHeaders(proof, signedEnrollmentHeaders(t, privateKey, http.MethodPost, "/api/devices/register", "BD-OPEN01", publicKey)) + proofRec := httptest.NewRecorder() + srv.handleDeviceRegister(proofRec, proof) + var proofResp EnrollmentResponse + if err := json.Unmarshal(proofRec.Body.Bytes(), &proofResp); err != nil { + t.Fatal(err) + } + if proofResp.DeviceToken == "" { + t.Fatal("open enrollment with proof must issue a device token") + } +} + +func TestManagedApprovalPreservesEnrollmentMetadata(t *testing.T) { + database := testSetupDB(t) + defer database.Close() + + publicKey, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + cfg := config.DefaultConfig() + cfg.EnrollmentMode = config.EnrollmentModeManaged + srv := New(cfg, database, peer.NewMap(), nil, "test") + + body, _ := json.Marshal(map[string]any{ + "device_id": "BD-META1", + "uuid": "machine-uuid-metadata", + "hostname": "agent-host", + "platform": "linux", + "version": "1.2.3", + "device_type": "os_agent", + "bundle_id": "support-bundle-a", + "tags": "support, linux, support", + "public_key": base64.StdEncoding.EncodeToString(publicKey), + }) + register := httptest.NewRequest(http.MethodPost, "/api/devices/register", bytes.NewReader(body)) + registerRec := httptest.NewRecorder() + srv.handleDeviceRegister(registerRec, register) + if registerRec.Code != http.StatusAccepted { + t.Fatalf("managed registration status = %d, want 202: %s", registerRec.Code, registerRec.Body.String()) + } + + pendingRaw, err := database.GetConfig(pendingDevicePrefix + "BD-META1") + if err != nil { + t.Fatal(err) + } + pending := parsePendingEnrollmentMeta(pendingRaw) + if pending.UUID != "machine-uuid-metadata" || pending.DeviceType != "os_agent" || + pending.BundleID != "support-bundle-a" || pending.Tags != "support,linux" || + pending.PublicKey != base64.StdEncoding.EncodeToString(publicKey) { + t.Fatalf("pending metadata was not preserved: %+v", pending) + } + + approve := httptest.NewRequest(http.MethodPost, "/api/enrollment/approve/BD-META1", bytes.NewBufferString(`{"sync_mode":"standard"}`)) + approve.SetPathValue("id", "BD-META1") + approveRec := httptest.NewRecorder() + srv.handleApproveDevice(approveRec, approve) + if approveRec.Code != http.StatusOK { + t.Fatalf("approval status = %d, want 200: %s", approveRec.Code, approveRec.Body.String()) + } + + approved, err := database.GetPeer("BD-META1") + if err != nil { + t.Fatal(err) + } + if approved == nil { + t.Fatal("approved peer missing") + } + if approved.UUID != "machine-uuid-metadata" || approved.DeviceType != "os_agent" || approved.Tags != "support,linux" { + t.Fatalf("approved peer metadata = %+v", approved) + } + if bundleID, err := database.GetConfig(deviceBundleIDPrefix + "BD-META1"); err != nil || bundleID != "support-bundle-a" { + t.Fatalf("bundle ID = %q, err=%v", bundleID, err) + } +} + func TestSuggestAlternateDeviceID(t *testing.T) { if got := suggestAlternateDeviceID("BD-ABC"); got != "BD-ABC-2" { t.Fatalf("got %q", got) @@ -104,3 +366,142 @@ func TestSuggestAlternateDeviceID(t *testing.T) { t.Fatalf("got %q", got) } } + +func TestEnrollmentProofReplayCannotUseBoundTokenFallback(t *testing.T) { + database := testSetupDB(t) + defer database.Close() + + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + const deviceID = "BD-PROOF-REPLAY" + if err := database.UpsertPeer(&db.Peer{ID: deviceID, UUID: "proof-replay-machine"}); err != nil { + t.Fatal(err) + } + + srv := New(config.DefaultConfig(), database, peer.NewMap(), nil, "test") + if err := srv.storeBdMgmtPublicKey(deviceID, base64.StdEncoding.EncodeToString(publicKey)); err != nil { + t.Fatal(err) + } + boundToken, err := srv.issueEnrollmentDeviceToken(deviceID) + if err != nil { + t.Fatal(err) + } + + headers := signedEnrollmentHeaders(t, privateKey, http.MethodPost, "/api/devices/register", deviceID, publicKey) + headers.Set("Authorization", "Bearer "+boundToken) + newRequest := func() *http.Request { + req := httptest.NewRequest(http.MethodPost, "/api/devices/register", nil) + applyHeaders(req, headers) + return req + } + + if !srv.authorizeEnrollmentTokenIssue(newRequest(), deviceID, base64.StdEncoding.EncodeToString(publicKey), "", true) { + t.Fatal("first proof should authorize token issuance") + } + if srv.authorizeEnrollmentTokenIssue(newRequest(), deviceID, base64.StdEncoding.EncodeToString(publicKey), "", true) { + t.Fatal("replayed proof must not bypass nonce protection with a bound token") + } +} + +func TestEnrollmentStateBlocksRegistrationAndStatus(t *testing.T) { + t.Run("rejected", func(t *testing.T) { + database := testSetupDB(t) + defer database.Close() + + const deviceID = "BD-REJECTED-STATE" + if err := database.SetConfig(rejectedDevicePrefix+deviceID, `{"rejected":true}`); err != nil { + t.Fatal(err) + } + srv := New(config.DefaultConfig(), database, peer.NewMap(), nil, "test") + body, err := json.Marshal(map[string]any{ + "device_id": deviceID, + "uuid": "rejected-machine", + }) + if err != nil { + t.Fatal(err) + } + + register := httptest.NewRecorder() + srv.handleDeviceRegister(register, httptest.NewRequest(http.MethodPost, "/api/devices/register", bytes.NewReader(body))) + if register.Code != http.StatusForbidden { + t.Fatalf("rejected registration status = %d, want 403: %s", register.Code, register.Body.String()) + } + if peerInfo, err := database.GetPeer(deviceID); err != nil || peerInfo != nil { + t.Fatalf("rejected registration unexpectedly created peer: peer=%+v err=%v", peerInfo, err) + } + + status := httptest.NewRecorder() + srv.handleDeviceRegisterStatus(status, httptest.NewRequest(http.MethodGet, "/api/devices/register/status?device_id="+deviceID, nil)) + if status.Code != http.StatusForbidden { + t.Fatalf("rejected status poll = %d, want 403: %s", status.Code, status.Body.String()) + } + }) + + t.Run("banned", func(t *testing.T) { + database := testSetupDB(t) + defer database.Close() + + const deviceID = "BD-BANNED-STATE" + if err := database.UpsertPeer(&db.Peer{ID: deviceID, UUID: "banned-machine"}); err != nil { + t.Fatal(err) + } + if err := database.BanPeer(deviceID, "test"); err != nil { + t.Fatal(err) + } + srv := New(config.DefaultConfig(), database, peer.NewMap(), nil, "test") + body, err := json.Marshal(map[string]any{ + "device_id": deviceID, + "uuid": "banned-machine", + }) + if err != nil { + t.Fatal(err) + } + + register := httptest.NewRecorder() + srv.handleDeviceRegister(register, httptest.NewRequest(http.MethodPost, "/api/devices/register", bytes.NewReader(body))) + if register.Code != http.StatusForbidden { + t.Fatalf("banned registration status = %d, want 403: %s", register.Code, register.Body.String()) + } + + status := httptest.NewRecorder() + srv.handleDeviceRegisterStatus(status, httptest.NewRequest(http.MethodGet, "/api/devices/register/status?device_id="+deviceID, nil)) + if status.Code != http.StatusForbidden { + t.Fatalf("banned status poll = %d, want 403: %s", status.Code, status.Body.String()) + } + }) + + t.Run("removed", func(t *testing.T) { + database := testSetupDB(t) + defer database.Close() + + const deviceID = "BD-REMOVED-STATE" + if err := database.UpsertPeer(&db.Peer{ID: deviceID, UUID: "removed-machine"}); err != nil { + t.Fatal(err) + } + if err := database.DeletePeer(deviceID); err != nil { + t.Fatal(err) + } + srv := New(config.DefaultConfig(), database, peer.NewMap(), nil, "test") + body, err := json.Marshal(map[string]any{ + "device_id": deviceID, + "uuid": "removed-machine", + }) + if err != nil { + t.Fatal(err) + } + + register := httptest.NewRecorder() + srv.handleDeviceRegister(register, httptest.NewRequest(http.MethodPost, "/api/devices/register", bytes.NewReader(body))) + if register.Code != http.StatusForbidden { + t.Fatalf("removed registration status = %d, want 403: %s", register.Code, register.Body.String()) + } + + status := httptest.NewRecorder() + srv.handleDeviceRegisterStatus(status, httptest.NewRequest(http.MethodGet, "/api/devices/register/status?device_id="+deviceID, nil)) + if status.Code != http.StatusForbidden { + t.Fatalf("removed status poll = %d, want 403: %s", status.Code, status.Body.String()) + } + }) +} diff --git a/betterdesk-server/api/help_handlers.go b/betterdesk-server/api/help_handlers.go index 107156f5..65a18461 100644 --- a/betterdesk-server/api/help_handlers.go +++ b/betterdesk-server/api/help_handlers.go @@ -134,20 +134,10 @@ func (s *Server) handleDeviceSelfHelpRequest(w http.ResponseWriter, r *http.Requ return } - tokenHash := hashToken(body.DeviceToken) - dt, err := s.db.ValidateToken(tokenHash) - if err != nil || dt == nil { + if !s.hasBoundActiveDeviceToken(body.DeviceID, body.DeviceToken) { http.Error(w, `{"error":"invalid device token"}`, http.StatusForbidden) return } - if dt.PeerID != "" && dt.PeerID != body.DeviceID { - http.Error(w, `{"error":"token bound to another device"}`, http.StatusForbidden) - return - } - if peer, _ := s.db.GetPeer(body.DeviceID); peer == nil { - http.Error(w, `{"error":"device not enrolled"}`, http.StatusForbidden) - return - } message := strings.TrimSpace(body.Message) if message == "" { diff --git a/betterdesk-server/api/server.go b/betterdesk-server/api/server.go index 5f18f48a..b85b1662 100644 --- a/betterdesk-server/api/server.go +++ b/betterdesk-server/api/server.go @@ -269,6 +269,7 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("GET /api/peers/{id}/access-policy", s.requireRole(auth.RoleOperator, s.handleGetAccessPolicy)) mux.HandleFunc("PUT /api/peers/{id}/access-policy", s.requireRole(auth.RoleAdmin, s.handleSaveAccessPolicy)) mux.HandleFunc("DELETE /api/peers/{id}/access-policy", s.requireRole(auth.RoleAdmin, s.handleDeleteAccessPolicy)) + mux.HandleFunc("POST /api/peers/{id}/session-grant", s.requireRole(auth.RoleOperator, s.handleIssueSupportSessionGrant)) mux.HandleFunc("GET /api/peers/{id}/policy", s.handleGetPeerPolicy) // Blocklist management @@ -446,7 +447,6 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("GET /api/devices/register/status", s.rateLimitPublic(s.enrollmentLimiter, s.handleDeviceRegisterStatus)) mux.HandleFunc("POST /api/devices/self/access-policy", s.rateLimitPublic(s.enrollmentLimiter, s.handleDeviceSelfAccessPolicy)) mux.HandleFunc("POST /api/devices/self/help-request", s.rateLimitPublic(s.enrollmentLimiter, s.handleDeviceSelfHelpRequest)) - mux.HandleFunc("GET /api/devices/self/totp", s.rateLimitPublic(s.enrollmentLimiter, s.handleDeviceSelfTOTP)) mux.HandleFunc("POST /api/devices/self/totp", s.rateLimitPublic(s.enrollmentLimiter, s.handleDeviceSelfTOTP)) // Help requests — operator panel (raised by agents via CDAP or REST self endpoint) diff --git a/betterdesk-server/api/session_grant_handlers.go b/betterdesk-server/api/session_grant_handlers.go new file mode 100644 index 00000000..e1dee429 --- /dev/null +++ b/betterdesk-server/api/session_grant_handlers.go @@ -0,0 +1,203 @@ +package api + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/unitronix/betterdesk-server/db" + "github.com/unitronix/betterdesk-server/sessiongrant" +) + +const ( + supportGrantDefaultTTL = 5 * time.Minute + supportGrantMaxTTL = 10 * time.Minute +) + +type supportSessionGrantRequest struct { + SessionID string `json:"session_id"` + Transport string `json:"transport"` + Capabilities []string `json:"capabilities"` + TTLSeconds int `json:"ttl_seconds,omitempty"` +} + +type supportSessionGrantResponse struct { + Grant string `json:"grant"` + ExpiresAt string `json:"expires_at"` + PublicKey string `json:"public_key"` +} + +// handleIssueSupportSessionGrant mints a short-lived, operator-bound grant for +// an inbound Support Agent session. Device credentials can never call this +// endpoint: the route requires an authenticated operator role. +func (s *Server) handleIssueSupportSessionGrant(w http.ResponseWriter, r *http.Request) { + deviceID := strings.TrimSpace(r.PathValue("id")) + if deviceID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "peer ID required"}) + return + } + if !s.peerOrgScopeCheck(w, r, deviceID) { + return + } + if s.keyPair == nil || len(s.keyPair.PrivateKey) == 0 { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "session grant signer unavailable"}) + return + } + peerInfo, err := s.db.GetPeer(deviceID) + if err != nil || peerInfo == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "peer not found"}) + return + } + if !isPassiveSupportPeer(peerInfo) { + writeJSON(w, http.StatusConflict, map[string]string{"error": "peer is not a passive support agent"}) + return + } + if peerInfo.Disabled || peerInfo.SoftDeleted { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "peer is unavailable"}) + return + } + if banned, _ := s.db.IsPeerBanned(deviceID); banned { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "peer is banned"}) + return + } + + var body supportSessionGrantRequest + r.Body = http.MaxBytesReader(w, r.Body, 32<<10) + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid session grant request"}) + return + } + body.SessionID = strings.TrimSpace(body.SessionID) + body.Transport = strings.ToLower(strings.TrimSpace(body.Transport)) + operatorID := strings.TrimSpace(getUsernameFromCtx(r)) + if operatorID == "" || len(body.SessionID) > 128 || !validSupportTransport(body.Transport) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid session grant binding"}) + return + } + capabilities, ok := normalizeSupportGrantCapabilities(body.Capabilities) + if !ok { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid or empty capability set"}) + return + } + ttl := supportGrantDefaultTTL + if body.TTLSeconds < 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid grant lifetime"}) + return + } + if body.TTLSeconds > 0 { + ttl = time.Duration(body.TTLSeconds) * time.Second + } + if ttl <= 0 || ttl > supportGrantMaxTTL { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid grant lifetime"}) + return + } + + signer, err := sessiongrant.NewSigner(s.keyPair.PrivateKey) + if err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "session grant signer unavailable"}) + return + } + now := time.Now().UTC() + nonce, err := newSessionGrantNonce() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not create session grant"}) + return + } + grant, err := signer.Issue(sessiongrant.Claims{ + DeviceID: deviceID, + OperatorID: operatorID, + SessionID: body.SessionID, + Transport: body.Transport, + Initiator: "operator", + Capabilities: capabilities, + IssuedAt: now.Unix(), + ExpiresAt: now.Add(ttl).Unix(), + Nonce: nonce, + }) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "could not issue session grant"}) + return + } + if s.auditLog != nil { + s.auditLog.Log("support_session_grant_issued", s.remoteIP(r), deviceID, map[string]string{ + "operator": operatorID, + "transport": body.Transport, + "session_id": body.SessionID, + }) + } + writeJSON(w, http.StatusOK, supportSessionGrantResponse{ + Grant: grant, + ExpiresAt: now.Add(ttl).Format(time.RFC3339), + PublicKey: s.keyPair.PublicKeyBase64(), + }) +} + +func validSupportTransport(transport string) bool { + switch transport { + case "cdap", "relay", "interop": + return true + default: + return false + } +} + +func normalizeSupportGrantCapabilities(input []string) ([]string, bool) { + allowed := map[string]struct{}{ + "screen_view": {}, + "input": {}, + "system_audio": {}, + "clipboard": {}, + "files": {}, + "terminal": {}, + "chat": {}, + "multi_monitor": {}, + "privacy_mode": {}, + "block_input": {}, + "restart": {}, + "recording": {}, + } + seen := make(map[string]struct{}, len(input)) + out := make([]string, 0, len(input)) + for _, capability := range input { + capability = strings.ToLower(strings.TrimSpace(capability)) + if _, exists := allowed[capability]; !exists { + return nil, false + } + if _, duplicate := seen[capability]; duplicate { + continue + } + seen[capability] = struct{}{} + out = append(out, capability) + } + return out, len(out) > 0 +} + +func isPassiveSupportPeer(peerInfo *db.Peer) bool { + if peerInfo == nil { + return false + } + switch strings.ToLower(strings.TrimSpace(peerInfo.DeviceType)) { + case "os_agent", "support-agent", "support_agent": + return true + } + for _, tag := range strings.FieldsFunc(strings.ToLower(peerInfo.Tags), func(r rune) bool { + return r == ',' || r == ';' || r == '|' || r == ' ' || r == '\t' || r == '\n' + }) { + if tag == "support-agent" || tag == "support_agent" { + return true + } + } + return false +} + +func newSessionGrantNonce() (string, error) { + raw := make([]byte, 24) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("session grant nonce: %w", err) + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} diff --git a/betterdesk-server/api/session_grant_handlers_test.go b/betterdesk-server/api/session_grant_handlers_test.go new file mode 100644 index 00000000..3f21b731 --- /dev/null +++ b/betterdesk-server/api/session_grant_handlers_test.go @@ -0,0 +1,102 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/unitronix/betterdesk-server/config" + servercrypto "github.com/unitronix/betterdesk-server/crypto" + "github.com/unitronix/betterdesk-server/db" + "github.com/unitronix/betterdesk-server/peer" + "github.com/unitronix/betterdesk-server/sessiongrant" +) + +func TestIssueSupportSessionGrantBindsOperatorDeviceAndTransport(t *testing.T) { + database := testSetupDB(t) + defer database.Close() + if err := database.UpsertPeer(&db.Peer{ID: "BD-12345", Status: "ONLINE", DeviceType: "os_agent"}); err != nil { + t.Fatal(err) + } + keyPair, err := servercrypto.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + srv := New(config.DefaultConfig(), database, peer.NewMap(), nil, "test") + srv.SetKeyPair(keyPair) + + body := []byte(`{"session_id":"session-1","transport":"relay","capabilities":["screen_view","input"],"ttl_seconds":60}`) + req := httptest.NewRequest(http.MethodPost, "/api/peers/BD-12345/session-grant", bytes.NewReader(body)) + req.SetPathValue("id", "BD-12345") + req = req.WithContext(context.WithValue(req.Context(), ctxKeyUsername, "operator-1")) + rec := httptest.NewRecorder() + + srv.handleIssueSupportSessionGrant(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var response supportSessionGrantResponse + if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { + t.Fatal(err) + } + claims, err := sessiongrant.Verify(response.Grant, keyPair.PublicKey, "BD-12345", "relay", time.Now()) + if err != nil { + t.Fatal(err) + } + if claims.OperatorID != "operator-1" || claims.SessionID != "session-1" || claims.Initiator != "operator" { + t.Fatalf("unexpected claims: %+v", claims) + } +} + +func TestIssueSupportSessionGrantRejectsUnknownCapabilities(t *testing.T) { + database := testSetupDB(t) + defer database.Close() + if err := database.UpsertPeer(&db.Peer{ID: "BD-12345", Status: "ONLINE", DeviceType: "os_agent"}); err != nil { + t.Fatal(err) + } + keyPair, err := servercrypto.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + srv := New(config.DefaultConfig(), database, peer.NewMap(), nil, "test") + srv.SetKeyPair(keyPair) + + req := httptest.NewRequest(http.MethodPost, "/api/peers/BD-12345/session-grant", + bytes.NewBufferString(`{"session_id":"session-1","transport":"relay","capabilities":["session_initiate"]}`)) + req.SetPathValue("id", "BD-12345") + req = req.WithContext(context.WithValue(req.Context(), ctxKeyUsername, "operator-1")) + rec := httptest.NewRecorder() + + srv.handleIssueSupportSessionGrant(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestIssueSupportSessionGrantRejectsNegativeLifetime(t *testing.T) { + database := testSetupDB(t) + defer database.Close() + if err := database.UpsertPeer(&db.Peer{ID: "BD-12345", Status: "ONLINE", DeviceType: "os_agent"}); err != nil { + t.Fatal(err) + } + keyPair, err := servercrypto.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + srv := New(config.DefaultConfig(), database, peer.NewMap(), nil, "test") + srv.SetKeyPair(keyPair) + req := httptest.NewRequest(http.MethodPost, "/api/peers/BD-12345/session-grant", + bytes.NewBufferString(`{"session_id":"session-1","transport":"cdap","capabilities":["screen_view"],"ttl_seconds":-1}`)) + req.SetPathValue("id", "BD-12345") + req = req.WithContext(context.WithValue(req.Context(), ctxKeyUsername, "operator-1")) + rec := httptest.NewRecorder() + + srv.handleIssueSupportSessionGrant(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/betterdesk-server/auth/permissions.go b/betterdesk-server/auth/permissions.go index c124c9cd..ee5c5d12 100644 --- a/betterdesk-server/auth/permissions.go +++ b/betterdesk-server/auth/permissions.go @@ -24,8 +24,8 @@ const ( PermUserDelete = "user.delete" // Server configuration - PermServerConfig = "server.config" // read/write server_config - PermServerKeys = "server.keys" // manage API keys + PermServerConfig = "server.config" // read/write server_config + PermServerKeys = "server.keys" // manage API keys PermServerAttestation = "server.attestation" // run/view server performance attestation // Organization permissions @@ -62,10 +62,10 @@ const ( PermBrandingEdit = "branding.edit" // Billing / commercialization - PermBillingView = "billing.view" - PermBillingManage = "billing.manage" + PermBillingView = "billing.view" + PermBillingManage = "billing.manage" PermBillingReports = "billing.reports" - PermBillingExport = "billing.export" + PermBillingExport = "billing.export" ) // AllPermissions is the complete list of permission strings for validation. @@ -88,13 +88,14 @@ var AllPermissions = []string{ // Custom overrides from the DB take precedence. // // Role scoping (Discussion #99): -// super_admin — all permissions, manages other super admins -// server_admin — server infrastructure only, read-only user list -// global_admin — all-org user/device/org management, NO server access -// admin — legacy alias, equivalent to super_admin -// operator — day-to-day device ops + chat -// viewer — read-only dashboards -// pro — API-only RustDesk PRO activation; no device or org device access +// +// super_admin — all permissions, manages other super admins +// server_admin — server infrastructure only, read-only user list +// global_admin — all-org user/device/org management, NO server access +// admin — legacy alias, equivalent to super_admin +// operator — day-to-day device ops + chat +// viewer — read-only dashboards +// pro — API-only RustDesk PRO activation; no device or org device access var DefaultRolePermissions = map[string]map[string]bool{ RoleSuperAdmin: buildPermMap(AllPermissions), RoleAdmin: buildPermMap(AllPermissions), // legacy admin = super_admin @@ -143,6 +144,9 @@ var DefaultRolePermissions = map[string]map[string]bool{ PermChatAccess, }), RolePro: buildPermMap([]string{}), + // Device credentials authenticate an agent to its own transport only. + // They never confer panel/API permissions. + RoleDevice: buildPermMap([]string{}), } // buildPermMap converts a slice of permission strings into a lookup map. @@ -169,6 +173,9 @@ func ProRoleBlocksPermission(permission string) bool { // according to default role mappings. Returns true for super_admin and legacy admin. // For DB-overridden permissions, use the Database.HasRolePermission method instead. func RoleHasPermission(role, permission string) bool { + if IsDeviceRole(role) { + return false + } if IsProRole(role) && ProRoleBlocksPermission(permission) { return false } diff --git a/betterdesk-server/auth/roles.go b/betterdesk-server/auth/roles.go index 0cca4503..c9bb5d3b 100644 --- a/betterdesk-server/auth/roles.go +++ b/betterdesk-server/auth/roles.go @@ -19,6 +19,10 @@ const ( RoleOperator = "operator" RoleViewer = "viewer" RolePro = "pro" // API-only RustDesk PRO activation; no device access + + // RoleDevice is an internal, device-scoped principal used for authenticated + // agents. It is intentionally not a user-assignable role. + RoleDevice = "device" ) // RoleLevel returns the numeric privilege level for a role. @@ -38,6 +42,8 @@ func RoleLevel(role string) int { return 1 case RolePro: return 0 + case RoleDevice: + return 0 default: return 0 } @@ -80,9 +86,19 @@ func CanAssignRole(callerRole, targetRole string) bool { // HasPermission returns true if userRole has at least the privileges of requiredRole. // Kept for backward compatibility — prefer requirePermission middleware. func HasPermission(userRole, requiredRole string) bool { + // A device credential must never satisfy a user-role check merely because + // both roles have the same numeric level. + if IsDeviceRole(userRole) { + return userRole == requiredRole + } return RoleLevel(userRole) >= RoleLevel(requiredRole) } +// IsDeviceRole reports whether role is the internal device-only principal. +func IsDeviceRole(role string) bool { + return role == RoleDevice +} + // ValidRole returns true if the given string is a recognised role. func ValidRole(r string) bool { switch r { diff --git a/betterdesk-server/cdap/auth.go b/betterdesk-server/cdap/auth.go index be7428ca..74d7bf87 100644 --- a/betterdesk-server/cdap/auth.go +++ b/betterdesk-server/cdap/auth.go @@ -12,6 +12,7 @@ import ( "github.com/coder/websocket" "github.com/unitronix/betterdesk-server/audit" "github.com/unitronix/betterdesk-server/auth" + "github.com/unitronix/betterdesk-server/db" ) // handleAuth reads the initial "auth" message from the client, validates @@ -184,6 +185,9 @@ func (g *Gateway) authDeviceToken(p AuthPayload, clientIP string) (string, strin if p.Token == "" { return "", "", fmt.Errorf("device token required") } + if p.DeviceID == "" { + return "", "", fmt.Errorf("device_id required for device token authentication") + } h := sha256.Sum256([]byte(p.Token)) tokenHash := hex.EncodeToString(h[:]) @@ -196,15 +200,32 @@ func (g *Gateway) authDeviceToken(p AuthPayload, clientIP string) (string, strin return "", "", fmt.Errorf("invalid or expired device token") } - // Bind token to device if not already bound - if dt.PeerID == "" && p.DeviceID != "" { - g.db.BindTokenToPeer(tokenHash, p.DeviceID) + // A CDAP device token is a device-scoped credential, not a general + // enrollment capability. It must already be active and bound to the exact + // device claiming it; otherwise a stolen/pre-issued token could be used to + // impersonate an arbitrary device. + if dt.Status != db.TokenStatusActive || dt.PeerID == "" || dt.PeerID != p.DeviceID { + g.auditAction("cdap_auth_failed", clientIP, map[string]string{ + "device_id": p.DeviceID, + "reason": "device token not bound to requested device", + }) + return "", "", fmt.Errorf("device token is not bound to this device") + } + peerInfo, err := g.db.GetPeer(p.DeviceID) + if err != nil || peerInfo == nil || peerInfo.Banned || peerInfo.SoftDeleted { + g.auditAction("cdap_auth_failed", clientIP, map[string]string{ + "device_id": p.DeviceID, + "reason": "device not enrolled or unavailable", + }) + return "", "", fmt.Errorf("device is not enrolled or available") } // Increment usage - g.db.IncrementTokenUse(tokenHash) + if err := g.db.IncrementTokenUse(tokenHash); err != nil { + return "", "", fmt.Errorf("record device token use: %w", err) + } - return fmt.Sprintf("token:%s", dt.Name), "operator", nil + return "device:" + p.DeviceID, auth.RoleDevice, nil } // auditAction logs a CDAP action to the audit log. diff --git a/betterdesk-server/cdap/auth_device_token_test.go b/betterdesk-server/cdap/auth_device_token_test.go new file mode 100644 index 00000000..2f649b55 --- /dev/null +++ b/betterdesk-server/cdap/auth_device_token_test.go @@ -0,0 +1,82 @@ +package cdap + +import ( + "crypto/sha256" + "encoding/hex" + "path/filepath" + "testing" + + "github.com/unitronix/betterdesk-server/auth" + "github.com/unitronix/betterdesk-server/config" + "github.com/unitronix/betterdesk-server/db" + "github.com/unitronix/betterdesk-server/events" + "github.com/unitronix/betterdesk-server/peer" +) + +func newDeviceTokenAuthGateway(t *testing.T) (*Gateway, db.Database, string) { + t.Helper() + + database, err := db.OpenSQLite(filepath.Join(t.TempDir(), "cdap-device-token.db")) + if err != nil { + t.Fatal(err) + } + if err := database.Migrate(); err != nil { + database.Close() + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + + const deviceID = "AGENT001" + if err := database.UpsertPeer(&db.Peer{ID: deviceID, Status: "ONLINE"}); err != nil { + t.Fatal(err) + } + + const plainToken = "cdap-device-token-0123456789" + sum := sha256.Sum256([]byte(plainToken)) + if err := database.CreateDeviceToken(&db.DeviceToken{ + Token: plainToken, + TokenHash: hex.EncodeToString(sum[:]), + Name: "agent token", + PeerID: deviceID, + Status: db.TokenStatusActive, + }); err != nil { + t.Fatal(err) + } + + return New(config.DefaultConfig(), database, peer.NewMap(), events.NewBus()), database, plainToken +} + +func TestDeviceTokenAuthenticatesAsDeviceScopedRole(t *testing.T) { + gateway, _, token := newDeviceTokenAuthGateway(t) + + username, role, err := gateway.authDeviceToken(AuthPayload{ + Token: token, + DeviceID: "AGENT001", + }, "127.0.0.1") + if err != nil { + t.Fatalf("authDeviceToken: %v", err) + } + if username != "device:AGENT001" { + t.Fatalf("username = %q, want device-scoped identity", username) + } + if role != auth.RoleDevice { + t.Fatalf("role = %q, want %q", role, auth.RoleDevice) + } + if auth.HasPermission(role, auth.RoleOperator) { + t.Fatal("device role must not satisfy operator role checks") + } + if auth.RoleHasPermission(role, auth.PermDeviceView) { + t.Fatal("device role must not receive panel permissions") + } +} + +func TestDeviceTokenCannotAuthenticateAnotherDevice(t *testing.T) { + gateway, _, token := newDeviceTokenAuthGateway(t) + + if _, _, err := gateway.authDeviceToken(AuthPayload{ + Token: token, + DeviceID: "OTHER001", + }, "127.0.0.1"); err == nil { + t.Fatal("device token bound to AGENT001 authenticated OTHER001") + } +} diff --git a/betterdesk-server/cdap/desktop.go b/betterdesk-server/cdap/desktop.go index 794af001..855e1375 100644 --- a/betterdesk-server/cdap/desktop.go +++ b/betterdesk-server/cdap/desktop.go @@ -6,14 +6,18 @@ package cdap import ( "bytes" "context" + "crypto/rand" + "encoding/base64" "encoding/json" "fmt" "log" + "strings" "sync" "sync/atomic" "time" "github.com/coder/websocket" + "github.com/unitronix/betterdesk-server/sessiongrant" ) // DesktopSession represents an active remote desktop session relaying @@ -34,13 +38,18 @@ type DesktopSession struct { // DesktopStartPayload is sent to the device to initiate a desktop session. type DesktopStartPayload struct { - SessionID string `json:"session_id"` - Width int `json:"width"` - Height int `json:"height"` - Quality int `json:"quality"` // JPEG quality 1-100 - FPS int `json:"fps"` // target frames per second - Codecs []string `json:"codecs,omitempty"` // codecs the operator can decode - VideoCodec string `json:"video_codec,omitempty"` // operator codec preference ("auto" = let agent choose) + SessionID string `json:"session_id"` + Width int `json:"width"` + Height int `json:"height"` + Quality int `json:"quality"` // JPEG quality 1-100 + FPS int `json:"fps"` // target frames per second + OperatorName string `json:"operator_name,omitempty"` + Codecs []string `json:"codecs,omitempty"` // codecs the operator can decode + VideoCodec string `json:"video_codec,omitempty"` // operator codec preference ("auto" = let agent choose) + // SessionGrant is present only for passive Support Agent targets. It is + // signed by the BetterDesk server and verified locally before consent. + SessionGrant string `json:"session_grant,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` } // DesktopFramePayload is sent from the device to the browser. @@ -120,6 +129,10 @@ func (g *Gateway) StartDesktopSession(ctx context.Context, browserConn *websocke } sessionID := fmt.Sprintf("desk_%s_%d", deviceID, time.Now().UnixNano()) + grant, capabilities, err := g.issuePassiveDesktopGrant(deviceID, username, sessionID) + if err != nil { + return nil, err + } ds := &DesktopSession{ ID: sessionID, @@ -132,13 +145,16 @@ func (g *Gateway) StartDesktopSession(ctx context.Context, browserConn *websocke } startPayload := DesktopStartPayload{ - SessionID: sessionID, - Width: width, - Height: height, - Quality: quality, - FPS: fps, - Codecs: codecs, - VideoCodec: videoCodec, + SessionID: sessionID, + Width: width, + Height: height, + Quality: quality, + FPS: fps, + OperatorName: username, + Codecs: codecs, + VideoCodec: videoCodec, + SessionGrant: grant, + Capabilities: capabilities, } data, _ := json.Marshal(startPayload) msg := &Message{ @@ -167,6 +183,61 @@ func (g *Gateway) StartDesktopSession(ctx context.Context, browserConn *websocke return ds, nil } +func (g *Gateway) issuePassiveDesktopGrant(deviceID, operatorID, sessionID string) (string, []string, error) { + peerInfo, err := g.db.GetPeer(deviceID) + if err != nil || peerInfo == nil || peerInfo.Banned || peerInfo.Disabled || peerInfo.SoftDeleted || + !isPassiveSupportDevice(peerInfo.DeviceType, peerInfo.Tags) { + return "", nil, nil + } + if g.sessionGrantSigner == nil { + return "", nil, fmt.Errorf("passive session grants are not configured") + } + nonce, err := newDesktopGrantNonce() + if err != nil { + return "", nil, err + } + capabilities := []string{"screen_view", "input"} + now := time.Now().UTC() + grant, err := g.sessionGrantSigner.Issue(sessiongrant.Claims{ + DeviceID: deviceID, + OperatorID: operatorID, + SessionID: sessionID, + Transport: "cdap", + Initiator: "operator", + Capabilities: capabilities, + IssuedAt: now.Unix(), + ExpiresAt: now.Add(5 * time.Minute).Unix(), + Nonce: nonce, + }) + if err != nil { + return "", nil, fmt.Errorf("issue passive session grant: %w", err) + } + return grant, capabilities, nil +} + +func isPassiveSupportDevice(deviceType, tags string) bool { + switch strings.ToLower(strings.TrimSpace(deviceType)) { + case "os_agent", "support-agent", "support_agent": + return true + } + for _, tag := range strings.FieldsFunc(strings.ToLower(tags), func(r rune) bool { + return r == ',' || r == ';' || r == '|' || r == ' ' || r == '\t' || r == '\n' + }) { + if tag == "support-agent" || tag == "support_agent" { + return true + } + } + return false +} + +func newDesktopGrantNonce() (string, error) { + raw := make([]byte, 24) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("generate passive session nonce: %w", err) + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + // RelayDesktopInput forwards mouse/keyboard input from browser to device. func (g *Gateway) RelayDesktopInput(ctx context.Context, sessionID string, input *DesktopInputPayload) error { val, ok := g.desktopSessions.Load(sessionID) diff --git a/betterdesk-server/cdap/desktop_grant_test.go b/betterdesk-server/cdap/desktop_grant_test.go new file mode 100644 index 00000000..289e60bd --- /dev/null +++ b/betterdesk-server/cdap/desktop_grant_test.go @@ -0,0 +1,52 @@ +package cdap + +import ( + "testing" + "time" + + servercrypto "github.com/unitronix/betterdesk-server/crypto" + "github.com/unitronix/betterdesk-server/db" + "github.com/unitronix/betterdesk-server/sessiongrant" +) + +func TestPassiveDesktopGrantBindsCDAPOperatorAndTarget(t *testing.T) { + gateway, database, _ := newDeviceTokenAuthGateway(t) + if err := database.UpsertPeer(&db.Peer{ + ID: "AGENT001", Status: "ONLINE", DeviceType: "os_agent", Tags: "support-agent", + }); err != nil { + t.Fatal(err) + } + keyPair, err := servercrypto.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + if err := gateway.SetSessionGrantPrivateKey(keyPair.PrivateKey); err != nil { + t.Fatal(err) + } + + grant, capabilities, err := gateway.issuePassiveDesktopGrant("AGENT001", "operator-1", "session-1") + if err != nil { + t.Fatal(err) + } + if grant == "" || len(capabilities) != 2 { + t.Fatalf("grant=%q capabilities=%v", grant, capabilities) + } + claims, err := sessiongrant.Verify(grant, keyPair.PublicKey, "AGENT001", "cdap", time.Now()) + if err != nil { + t.Fatal(err) + } + if claims.OperatorID != "operator-1" || claims.SessionID != "session-1" { + t.Fatalf("unexpected claims: %+v", claims) + } +} + +func TestPassiveDesktopGrantIsNotIssuedForOrdinaryDevice(t *testing.T) { + gateway, _, _ := newDeviceTokenAuthGateway(t) + grant, capabilities, err := gateway.issuePassiveDesktopGrant("AGENT001", "operator-1", "session-1") + if err != nil { + t.Fatal(err) + } + if grant != "" || capabilities != nil { + t.Fatalf("ordinary device received passive grant: %q %v", grant, capabilities) + } +} diff --git a/betterdesk-server/cdap/gateway.go b/betterdesk-server/cdap/gateway.go index 84afd37d..6a47dfd2 100644 --- a/betterdesk-server/cdap/gateway.go +++ b/betterdesk-server/cdap/gateway.go @@ -6,6 +6,7 @@ package cdap import ( "context" + "crypto/ed25519" "encoding/json" "fmt" "log" @@ -25,6 +26,7 @@ import ( "github.com/unitronix/betterdesk-server/peer" "github.com/unitronix/betterdesk-server/ratelimit" "github.com/unitronix/betterdesk-server/security" + "github.com/unitronix/betterdesk-server/sessiongrant" ) // Gateway is the CDAP WebSocket server. @@ -37,6 +39,9 @@ type Gateway struct { blocklist *security.Blocklist jwt *auth.JWTManager limiter *ratelimit.IPLimiter + // sessionGrantSigner binds passive Support Agent sessions to the + // authenticated CDAP operator and target device. + sessionGrantSigner *sessiongrant.Signer httpSrv *http.Server ln net.Listener @@ -112,6 +117,18 @@ func (g *Gateway) SetAuditLogger(al *audit.Logger) { g.auditLog = al } // SetJWTManager sets the JWT manager. func (g *Gateway) SetJWTManager(jm *auth.JWTManager) { g.jwt = jm } +// SetSessionGrantPrivateKey enables signed passive-session grants for Support +// Agent devices. It must receive the server identity key whose public half is +// embedded in their signed branding profile. +func (g *Gateway) SetSessionGrantPrivateKey(key ed25519.PrivateKey) error { + signer, err := sessiongrant.NewSigner(key) + if err != nil { + return err + } + g.sessionGrantSigner = signer + return nil +} + // SetRateLimiter overrides the default rate limiter. func (g *Gateway) SetRateLimiter(l *ratelimit.IPLimiter) { g.limiter = l } diff --git a/betterdesk-server/main.go b/betterdesk-server/main.go index 2d89b3b6..c3d4e530 100644 --- a/betterdesk-server/main.go +++ b/betterdesk-server/main.go @@ -419,6 +419,9 @@ func main() { cdapGw.SetBlocklist(blocklist) cdapGw.SetAuditLogger(auditLogger) cdapGw.SetJWTManager(jwtManager) + if err := cdapGw.SetSessionGrantPrivateKey(kp.PrivateKey); err != nil { + log.Fatalf("Failed to configure CDAP session grant signer: %v", err) + } cdapGw.SetVersion(Version) apiSrv.SetCDAPGateway(cdapGw) } diff --git a/betterdesk-server/sessiongrant/grant.go b/betterdesk-server/sessiongrant/grant.go new file mode 100644 index 00000000..b05dfdfc --- /dev/null +++ b/betterdesk-server/sessiongrant/grant.go @@ -0,0 +1,183 @@ +// Package sessiongrant signs short-lived, target-bound grants for passive +// Support Agent sessions. It is intentionally transport-neutral so relay, +// CDAP, and future compatibility adapters can apply the same authorization +// decision. +package sessiongrant + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +const ( + tokenVersion = "v1" + audience = "betterdesk-support-agent" + maxTTL = 10 * time.Minute +) + +// Claims binds a server authorization decision to exactly one target-side +// session. It never contains a password, TOTP value, device token, or other +// long-lived secret. +type Claims struct { + Version int `json:"v"` + Audience string `json:"aud"` + DeviceID string `json:"device_id"` + OperatorID string `json:"operator_id"` + SessionID string `json:"session_id"` + Transport string `json:"transport"` + Initiator string `json:"initiator"` + Capabilities []string `json:"capabilities"` + IssuedAt int64 `json:"iat"` + ExpiresAt int64 `json:"exp"` + Nonce string `json:"nonce"` +} + +// Signer holds the server-side signing material. Only public keys are given to +// agents for verification. +type Signer struct { + privateKey ed25519.PrivateKey + now func() time.Time +} + +// NewSigner constructs a signer from an existing Ed25519 private key. +func NewSigner(privateKey ed25519.PrivateKey) (*Signer, error) { + if len(privateKey) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("invalid Ed25519 private key length") + } + return &Signer{privateKey: privateKey, now: time.Now}, nil +} + +// PublicKey returns the verifier material safe to distribute to agents. +func (s *Signer) PublicKey() ed25519.PublicKey { + return s.privateKey.Public().(ed25519.PublicKey) +} + +// Issue creates a signed grant. The supplied claims must have an explicit, +// short expiration; the server refuses to create grants wider than maxTTL. +func (s *Signer) Issue(claims Claims) (string, error) { + if s == nil || len(s.privateKey) != ed25519.PrivateKeySize { + return "", fmt.Errorf("session grant signer is unavailable") + } + now := s.now().UTC() + claims = normalize(claims) + if claims.Version == 0 { + claims.Version = 1 + } + if claims.Audience == "" { + claims.Audience = audience + } + if claims.IssuedAt == 0 { + claims.IssuedAt = now.Unix() + } + if err := validate(claims, now); err != nil { + return "", err + } + payload, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("marshal session grant: %w", err) + } + signature := ed25519.Sign(s.privateKey, payload) + return strings.Join([]string{ + tokenVersion, + base64.RawURLEncoding.EncodeToString(payload), + base64.RawURLEncoding.EncodeToString(signature), + }, "."), nil +} + +// Verify checks the signature and all target-side invariants. expectedDeviceID +// and expectedTransport prevent a valid grant from being replayed to another +// agent or transport adapter. +func Verify(token string, publicKey ed25519.PublicKey, expectedDeviceID, expectedTransport string, now time.Time) (Claims, error) { + if len(publicKey) != ed25519.PublicKeySize { + return Claims{}, fmt.Errorf("invalid session-grant public key") + } + parts := strings.Split(token, ".") + if len(parts) != 3 || parts[0] != tokenVersion { + return Claims{}, fmt.Errorf("invalid session-grant format") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return Claims{}, fmt.Errorf("decode session-grant payload: %w", err) + } + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil || len(signature) != ed25519.SignatureSize { + return Claims{}, fmt.Errorf("invalid session-grant signature") + } + if !ed25519.Verify(publicKey, payload, signature) { + return Claims{}, fmt.Errorf("session-grant signature verification failed") + } + var claims Claims + if err := json.Unmarshal(payload, &claims); err != nil { + return Claims{}, fmt.Errorf("decode session grant: %w", err) + } + claims = normalize(claims) + if err := validate(claims, now.UTC()); err != nil { + return Claims{}, err + } + if expectedDeviceID != "" && claims.DeviceID != expectedDeviceID { + return Claims{}, fmt.Errorf("session grant is for another device") + } + if expectedTransport != "" && claims.Transport != expectedTransport { + return Claims{}, fmt.Errorf("session grant is for another transport") + } + return claims, nil +} + +func normalize(claims Claims) Claims { + claims.Audience = strings.TrimSpace(claims.Audience) + claims.DeviceID = strings.TrimSpace(claims.DeviceID) + claims.OperatorID = strings.TrimSpace(claims.OperatorID) + claims.SessionID = strings.TrimSpace(claims.SessionID) + claims.Transport = strings.TrimSpace(strings.ToLower(claims.Transport)) + claims.Initiator = strings.TrimSpace(strings.ToLower(claims.Initiator)) + claims.Nonce = strings.TrimSpace(claims.Nonce) + caps := make([]string, 0, len(claims.Capabilities)) + for _, cap := range claims.Capabilities { + if cap = strings.TrimSpace(strings.ToLower(cap)); cap != "" { + caps = append(caps, cap) + } + } + sort.Strings(caps) + claims.Capabilities = caps[:0] + for _, cap := range caps { + if len(claims.Capabilities) == 0 || claims.Capabilities[len(claims.Capabilities)-1] != cap { + claims.Capabilities = append(claims.Capabilities, cap) + } + } + return claims +} + +func validate(claims Claims, now time.Time) error { + if claims.Version != 1 { + return fmt.Errorf("unsupported session-grant version") + } + if claims.Audience != audience { + return fmt.Errorf("invalid session-grant audience") + } + if claims.DeviceID == "" || claims.OperatorID == "" || claims.SessionID == "" || claims.Nonce == "" { + return fmt.Errorf("session grant is missing a required binding") + } + switch claims.Transport { + case "cdap", "relay", "interop": + default: + return fmt.Errorf("unsupported session-grant transport") + } + if claims.Initiator != "operator" { + return fmt.Errorf("session grant must be initiated by an operator") + } + if claims.ExpiresAt <= claims.IssuedAt || claims.ExpiresAt <= now.Unix() { + return fmt.Errorf("session grant has expired") + } + if time.Unix(claims.ExpiresAt, 0).Sub(time.Unix(claims.IssuedAt, 0)) > maxTTL { + return fmt.Errorf("session grant exceeds maximum lifetime") + } + if len(claims.Capabilities) == 0 { + return fmt.Errorf("session grant has no capabilities") + } + return nil +} diff --git a/betterdesk-server/sessiongrant/grant_test.go b/betterdesk-server/sessiongrant/grant_test.go new file mode 100644 index 00000000..e0e27725 --- /dev/null +++ b/betterdesk-server/sessiongrant/grant_test.go @@ -0,0 +1,104 @@ +package sessiongrant + +import ( + "crypto/ed25519" + "crypto/rand" + "strings" + "testing" + "time" +) + +func testSigner(t *testing.T, now time.Time) *Signer { + t.Helper() + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + signer, err := NewSigner(privateKey) + if err != nil { + t.Fatal(err) + } + signer.now = func() time.Time { return now } + return signer +} + +func validClaims(now time.Time) Claims { + return Claims{ + DeviceID: "BD-12345", + OperatorID: "operator-1", + SessionID: "session-1", + Transport: "relay", + Initiator: "operator", + Capabilities: []string{"desktop", "clipboard", "desktop"}, + ExpiresAt: now.Add(5 * time.Minute).Unix(), + Nonce: "nonce-1", + } +} + +func TestIssueAndVerifyBoundSessionGrant(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + signer := testSigner(t, now) + token, err := signer.Issue(validClaims(now)) + if err != nil { + t.Fatal(err) + } + claims, err := Verify(token, signer.PublicKey(), "BD-12345", "relay", now) + if err != nil { + t.Fatal(err) + } + if claims.Audience != audience || claims.IssuedAt != now.Unix() { + t.Fatalf("unexpected normalized claims: %+v", claims) + } + if len(claims.Capabilities) != 2 || claims.Capabilities[0] != "clipboard" || claims.Capabilities[1] != "desktop" { + t.Fatalf("capabilities were not canonicalized: %#v", claims.Capabilities) + } +} + +func TestVerifyRejectsTamperingWrongTargetAndExpiredGrants(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + signer := testSigner(t, now) + token, err := signer.Issue(validClaims(now)) + if err != nil { + t.Fatal(err) + } + + if _, err := Verify(token, signer.PublicKey(), "BD-other", "relay", now); err == nil { + t.Fatal("expected device-binding rejection") + } + if _, err := Verify(token, signer.PublicKey(), "BD-12345", "cdap", now); err == nil { + t.Fatal("expected transport-binding rejection") + } + parts := strings.Split(token, ".") + replacement := byte('A') + if parts[1][0] == replacement { + replacement = 'B' + } + parts[1] = string(replacement) + parts[1][1:] + tampered := strings.Join(parts, ".") + if _, err := Verify(tampered, signer.PublicKey(), "BD-12345", "relay", now); err == nil { + t.Fatal("expected signature rejection") + } + if _, err := Verify(token, signer.PublicKey(), "BD-12345", "relay", now.Add(6*time.Minute)); err == nil { + t.Fatal("expected expiration rejection") + } +} + +func TestIssueRejectsOverlyBroadOrMissingGrant(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + signer := testSigner(t, now) + claims := validClaims(now) + claims.ExpiresAt = now.Add(maxTTL + time.Second).Unix() + if _, err := signer.Issue(claims); err == nil { + t.Fatal("expected long lifetime rejection") + } + claims = validClaims(now) + claims.Capabilities = nil + if _, err := signer.Issue(claims); err == nil { + t.Fatal("expected empty-capability rejection") + } + claims = validClaims(now) + claims.Initiator = "support_agent" + if _, err := signer.Issue(claims); err == nil { + t.Fatal("expected outbound-initiator rejection") + } +} diff --git a/betterdesk-server/signal/handler.go b/betterdesk-server/signal/handler.go index 66ed29a6..a5d97858 100644 --- a/betterdesk-server/signal/handler.go +++ b/betterdesk-server/signal/handler.go @@ -45,6 +45,58 @@ func relayTransportMismatch(initiator, target peer.ConnType) bool { return (initiator == peer.ConnWS) != (target == peer.ConnWS) } +// isInboundOnlyDeviceType identifies agents that may be contacted by an +// operator/client but must never start RustDesk P2P or relay sessions +// themselves. Normalize common spelling variants because metadata has existed +// in both underscore and hyphen forms. +func isInboundOnlyDeviceType(deviceType string) bool { + normalized := strings.ToLower(strings.TrimSpace(deviceType)) + normalized = strings.NewReplacer("-", "", "_", "", " ", "").Replace(normalized) + return normalized == "osagent" || normalized == "supportagent" +} + +func isInboundOnlyPeer(p *db.Peer) bool { + if p == nil { + return false + } + if isInboundOnlyDeviceType(p.DeviceType) { + return true + } + for _, tag := range strings.FieldsFunc(p.Tags, func(r rune) bool { + return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' + }) { + if isInboundOnlyDeviceType(tag) { + return true + } + } + return false +} + +// targetAcceptsInboundSession checks durable target status before forwarding a +// new signaling or relay request. The in-memory peer map can remain populated +// briefly after an administrator disables, bans, or deletes a device. +func (s *Server) targetAcceptsInboundSession(targetID string) bool { + if targetID == "" || s.db == nil { + return targetID != "" + } + p, err := s.db.GetPeer(targetID) + if err != nil { + log.Printf("[signal] Target %s database lookup failed: %v", targetID, err) + return false + } + if p != nil { + return !p.Banned && !p.Disabled && !p.SoftDeleted + } + state, err := s.db.GetPeerIDState(targetID) + if err != nil { + log.Printf("[signal] Target %s state lookup failed: %v", targetID, err) + return false + } + // A target that was never stored by the BetterDesk inventory can still use + // compatibility signaling in open mode; a known soft-deleted ID cannot. + return state != db.PeerIDSoftDeleted +} + // handleUDPMessage dispatches a UDP message to the appropriate handler. func (s *Server) handleUDPMessage(msg *pb.RendezvousMessage, raddr *net.UDPAddr) { switch { @@ -1979,6 +2031,23 @@ func (s *Server) finalizeAuthorizedInitiator(initiatorID string, raddr *net.UDPA s.logUnauthorizedInitiator(raddr, initiatorID, targetID, "initiator_not_enrolled") return "", false } + if isInboundOnlyPeer(dbPeer) { + s.logUnauthorizedInitiator(raddr, initiatorID, targetID, "initiator_inbound_only_device") + return "", false + } + } else if s.db != nil { + // Open mode still needs to prevent an approved support/OS agent from + // initiating connections. Do not rely on the in-memory peer map: it + // does not carry durable device_type metadata. + dbPeer, err := s.db.GetPeer(initiatorID) + if err != nil { + s.logUnauthorizedInitiator(raddr, initiatorID, targetID, "initiator_type_lookup_failed") + return "", false + } + if isInboundOnlyPeer(dbPeer) { + s.logUnauthorizedInitiator(raddr, initiatorID, targetID, "initiator_inbound_only_device") + return "", false + } } return initiatorID, true diff --git a/betterdesk-server/signal/handler_test.go b/betterdesk-server/signal/handler_test.go index c23a6a03..17fa8a69 100644 --- a/betterdesk-server/signal/handler_test.go +++ b/betterdesk-server/signal/handler_test.go @@ -956,6 +956,79 @@ func TestOpenRegisteredInitiatorCanRequestRelay(t *testing.T) { } } +func TestInboundOnlyAgentsCannotInitiatePunchOrRelay(t *testing.T) { + for _, tc := range []struct { + name string + deviceType string + tags string + }{ + {name: "os_agent type", deviceType: "os_agent"}, + {name: "support-agent type", deviceType: "support-agent"}, + {name: "support-agent tag", deviceType: "desktop", tags: "managed,support-agent"}, + } { + t.Run(tc.name, func(t *testing.T) { + srv, database := newTestSignalServer(t, config.EnrollmentModeOpen) + if err := database.UpsertPeer(&db.Peer{ + ID: "AGENTOUT1", + DeviceType: tc.deviceType, + Tags: tc.tags, + Status: "ONLINE", + IP: "198.51.100.101", + }); err != nil { + t.Fatalf("UpsertPeer initiator: %v", err) + } + putOnlinePeer(srv, "AGENTOUT1", "198.51.100.101", 51000, peer.ConnTCP) + putOnlinePeer(srv, "TARGETOUT1", "203.0.113.101", 52000, peer.ConnTCP) + + punch := srv.handlePunchHoleRequestTCP( + &pb.PunchHoleRequest{Id: "TARGETOUT1"}, + udpAddr("198.51.100.101", 51000), + ) + if response := punch.GetPunchHoleResponse(); response == nil || response.Failure != pb.PunchHoleResponse_ID_NOT_EXIST { + t.Fatalf("PunchHole response = %+v, want unauthorized rejection", punch) + } + + relay := srv.handleRequestRelayTCP(&pb.RequestRelay{ + Id: "TARGETOUT1", + Uuid: "inbound-only-relay-" + strings.ReplaceAll(tc.name, " ", "-"), + }, udpAddr("198.51.100.101", 51000), peer.ConnTCP) + if response := relay.GetRelayResponse(); response == nil || response.RefuseReason != refuseInitiatorNotAuthorized { + t.Fatalf("RequestRelay response = %+v, want unauthorized rejection", relay) + } + }) + } +} + +func TestInboundOnlyAgentCanBeConnectionTarget(t *testing.T) { + srv, database := newTestSignalServer(t, config.EnrollmentModeOpen) + if err := database.UpsertPeer(&db.Peer{ + ID: "CLIENTIN1", + DeviceType: "betterdesk", + Status: "ONLINE", + IP: "198.51.100.102", + }); err != nil { + t.Fatalf("UpsertPeer initiator: %v", err) + } + if err := database.UpsertPeer(&db.Peer{ + ID: "AGENTIN01", + DeviceType: "os_agent", + Status: "ONLINE", + IP: "203.0.113.102", + }); err != nil { + t.Fatalf("UpsertPeer target: %v", err) + } + putOnlinePeer(srv, "CLIENTIN1", "198.51.100.102", 51000, peer.ConnTCP) + putOnlinePeer(srv, "AGENTIN01", "203.0.113.102", 52000, peer.ConnTCP) + + relay := srv.handleRequestRelayTCP(&pb.RequestRelay{ + Id: "AGENTIN01", + Uuid: "agent-is-target-relay", + }, udpAddr("198.51.100.102", 51000), peer.ConnTCP) + if response := relay.GetRelayResponse(); response == nil || response.RefuseReason != "" { + t.Fatalf("RequestRelay to inbound-only target = %+v, want accepted", relay) + } +} + func TestPanelProxyLoopbackCanPunchHoleWithoutPeer(t *testing.T) { srv, _ := newTestSignalServer(t, config.EnrollmentModeManaged) putOnlinePeer(srv, "TGTWEB1", "203.0.113.90", 52000, peer.ConnTCP) diff --git a/betterdesk-support-agent/README.md b/betterdesk-support-agent/README.md index 73a812f7..92e39eb4 100644 --- a/betterdesk-support-agent/README.md +++ b/betterdesk-support-agent/README.md @@ -8,6 +8,18 @@ Go binary** (Fyne GUI). One codebase, one binary — two distribution forms: | **Installer** | `betterdesk-support -install` then launches at login | XDG autostart / HKCU Run / LaunchAgent | per-user config dir | | **Portable** | run the binary directly, no install | none | `data/` next to the binary (with a `portable` marker file) | +## Product identity and compatibility + +BetterDesk Support Agent is a BetterDesk product and a passive remote-support +target. It does not provide an outbound “connect to peer” workflow. + +Some releases include a desktop-client wire compatibility surface so approved +operators can connect to the Support Agent. That surface is being isolated +behind a BetterDesk compatibility adapter and is subject to the provenance gate +in [`../docs/important/support-agent-provenance.md`](../docs/important/support-agent-provenance.md). +Until that gate is complete, do not describe the compatibility component as a +fork, clone, clean-room implementation, or independently licensable component. + The remote-desktop engine is reused from the shared `betterdesk-agent` module, so the support agent offers the same remote-desktop capabilities while exposing only a minimal "quick help" surface. @@ -51,11 +63,17 @@ is additionally written with `0600` permissions. Appearance and connection details are **baked at build time** by the Console Generator into `resources/branding.json` (embedded via `go:embed`). Release -builds **seal** that JSON (AES-GCM) so casual string dumps do not show server -keys in cleartext. Fields: `product_name`, `company_name`, `tagline`, +builds require an Ed25519-signed profile; the matching public key is embedded +in the binary and any verification, expiry, or endpoint-allowlist failure +disables its connection profile. `BETTERDESK_BUNDLE_SIGNING_KEY_FILE` is +required for every distributed build. The legacy AES seal is accepted only by +non-release developer builds and is obfuscation, not a trust boundary. Fields: +`product_name`, `company_name`, `tagline`, `support_email`, `primary_color`, `accent_color`, `logo_data_url`, `default_language`, `allow_unattended`, `capabilities`, `server_address`, -`server_key`, `api_key`, and nested `server { address, api_url, public_key, cdap_url }`. +`server_key`, `bundle_id`, `profile_issued_at`, `profile_expires_at`, +`allowed_endpoints`, and nested +`server { address, api_url, public_key, cert_pin, cdap_url }`. Optional build hardening: @@ -72,19 +90,20 @@ BETTERDESK_AGENT_BRANDING=/path/to/branding.json ./betterdesk-support ## Connection resilience -The agent remembers the last healthy CDAP WebSocket and API base URL in -encrypted local state. On start and during “Test connection” it prefers those -endpoints, then falls back to branding-derived candidates (including TLS -scheme swaps) so operator-side config churn does not brick end-user installs. +The agent remembers last-known healthy endpoint metadata in encrypted local +state. Distributed builds use only the HTTPS/WSS endpoints explicitly allowed +by their signed profile; they never downgrade to HTTP/WS after a failure. ## Build ```bash -# Host platform, unbranded -./build.sh +# Generate or provide an Ed25519 PKCS#8 key outside the workspace, then build. +BETTERDESK_BUNDLE_SIGNING_KEY_FILE=/secure/path/branding-ed25519.pem \ + ./build.sh # With a generated branding profile -./build.sh -b /tmp/branding.json -o dist/acme-support +BETTERDESK_BUNDLE_SIGNING_KEY_FILE=/secure/path/branding-ed25519.pem \ + ./build.sh -b /tmp/branding.json -o dist/acme-support # Windows target (needs mingw-w64 CGO toolchain) ./build.sh -p windows @@ -151,5 +170,5 @@ Supervised consent prompts require the GUI; use unattended access mode for `-nog |----------|--------| | `BETTERDESK_AGENT_BRANDING` | Load branding from an external JSON file | | `BETTERDESK_AGENT_DATA_DIR` | Force the state directory | -| `BETTERDESK_CDAP_TLS=1` | Use `wss://` for the CDAP gateway | -| `BETTERDESK_AGENT_INSECURE_TLS=1` | Skip TLS verification for help requests (self-signed test servers) | +| `BETTERDESK_CDAP_TLS=1` | Enable TLS in non-release developer profiles | +| `BETTERDESK_AGENT_INSECURE_TLS=1` | Non-release-only local self-signed test override; ignored by release binaries | diff --git a/betterdesk-support-agent/access_policy.go b/betterdesk-support-agent/access_policy.go new file mode 100644 index 00000000..f148d2ae --- /dev/null +++ b/betterdesk-support-agent/access_policy.go @@ -0,0 +1,81 @@ +package main + +import "strings" + +// incomingCapabilities adapts the enforceable Support Agent feature policy to +// the switches exposed by the shared CDAP engine and signal host. +type incomingCapabilities struct { + Desktop bool + Files bool + Clipboard bool + Audio bool + Terminal bool + Restart bool +} + +func (b Branding) incomingCapabilities() incomingCapabilities { + policy := hostCapabilityPolicyFor(b) + return incomingCapabilities{ + Desktop: policy.allows(hostFeatureScreenView) && policy.allows(hostFeatureInput), + Files: policy.allows(hostFeatureFiles), + Clipboard: policy.allows(hostFeatureClipboard), + Audio: policy.allows(hostFeatureAudio), + Terminal: policy.allows(hostFeatureTerminal), + Restart: policy.allows(hostFeatureRestart), + } +} + +// incomingAccessPolicy combines the user-selected access mode with the +// immutable bundle policy. Treat unsupported or stale unattended settings as +// supervised rather than granting an unattended session. +type incomingAccessPolicy struct { + mode string + passwordConfigured bool + unattended bool + capabilities incomingCapabilities +} + +func accessPolicyFor(b Branding, st *AppState) incomingAccessPolicy { + _, mode, password, _ := st.Snapshot() + return incomingAccessPolicy{ + mode: mode, + passwordConfigured: strings.TrimSpace(password) != "", + unattended: mode == AccessUnattended && b.AllowUnattended, + capabilities: b.incomingCapabilities(), + } +} + +// allowsUnattended is the complete local unattended-access predicate. A +// branded unattended mode without a usable local password must fail closed; +// otherwise CDAP and relay could disagree about whether a session is allowed. +func (p incomingAccessPolicy) allowsUnattended() bool { + return p.unattended && p.passwordConfigured +} + +func (p incomingAccessPolicy) requiresConsent() bool { + return !p.allowsUnattended() +} + +func (p incomingAccessPolicy) allowsSignalHost(headless bool) bool { + if p.mode == AccessDisabled || !p.capabilities.Desktop || !p.passwordConfigured { + return false + } + return !headless || p.allowsUnattended() +} + +func (p incomingAccessPolicy) signalHostDisabledReason(headless bool) string { + switch { + case p.mode == AccessDisabled: + return "access mode is disabled" + case !p.capabilities.Desktop: + return "desktop capability is disabled by branding" + case !p.passwordConfigured: + return "no local access password is configured" + case headless && p.mode == AccessUnattended && !p.unattended: + return "unattended access is disabled by branding" + case headless: + return "headless mode has no local consent UI; approved unattended access is required" + default: + return "" + } +} diff --git a/betterdesk-support-agent/access_policy_test.go b/betterdesk-support-agent/access_policy_test.go new file mode 100644 index 00000000..1c1e6679 --- /dev/null +++ b/betterdesk-support-agent/access_policy_test.go @@ -0,0 +1,118 @@ +package main + +import "testing" + +func boolRef(v bool) *bool { + return &v +} + +func TestHeadlessConsentRequiresApprovedUnattendedAccess(t *testing.T) { + st := &AppState{ + DeviceID: "BD-TEST", + AccessMode: AccessUnattended, + AccessPassword: "secret12", + } + + if headlessConsent(Branding{}, st)("session", "operator") { + t.Fatal("unattended mode must not bypass branding approval") + } + if !headlessConsent(Branding{AllowUnattended: true}, st)("session", "operator") { + t.Fatal("approved unattended mode should be allowed in headless mode") + } + + st.AccessMode = AccessSupervised + if headlessConsent(Branding{AllowUnattended: true}, st)("session", "operator") { + t.Fatal("supervised mode must be denied without a local UI") + } + + st.AccessMode = AccessDisabled + if headlessConsent(Branding{AllowUnattended: true}, st)("session", "operator") { + t.Fatal("disabled access mode must never be allowed") + } +} + +func TestUnattendedPolicyRequiresUsablePassword(t *testing.T) { + st := &AppState{ + DeviceID: "BD-TEST", + AccessMode: AccessUnattended, + AccessPassword: " \t ", + } + brand := Branding{AllowUnattended: true} + policy := accessPolicyFor(brand, st) + + if policy.allowsUnattended() { + t.Fatal("whitespace-only password must not enable unattended access") + } + if !policy.requiresConsent() { + t.Fatal("passwordless unattended policy must require consent") + } + if policy.allowsSignalHost(true) { + t.Fatal("passwordless unattended policy must not expose a headless relay") + } + if headlessConsent(brand, st)("session", "operator") { + t.Fatal("headless CDAP consent must not bypass a missing password") + } +} + +func TestSignalHostPolicyDisablesUnsafeModes(t *testing.T) { + st := &AppState{ + DeviceID: "BD-TEST", + AccessMode: AccessSupervised, + AccessPassword: "secret12", + } + brand := Branding{AllowUnattended: true} + + if !accessPolicyFor(brand, st).allowsSignalHost(false) { + t.Fatal("supervised GUI mode should permit a consent-gated signal host") + } + if accessPolicyFor(brand, st).allowsSignalHost(true) { + t.Fatal("supervised headless mode must not expose a signal host") + } + + st.AccessMode = AccessDisabled + if accessPolicyFor(brand, st).allowsSignalHost(false) { + t.Fatal("disabled mode must not expose a signal host") + } + + st.AccessMode = AccessUnattended + if !accessPolicyFor(brand, st).allowsSignalHost(true) { + t.Fatal("approved unattended headless mode should expose a signal host") + } + + brand.Capabilities = &CapabilityFlags{Desktop: boolRef(false)} + if accessPolicyFor(brand, st).allowsSignalHost(false) { + t.Fatal("desktop-disabled branding must not expose a signal host") + } +} + +func TestNewSignalHostSupportsOnlySafeHeadlessMode(t *testing.T) { + st := &AppState{ + DeviceID: "BD-TEST", + AccessMode: AccessUnattended, + AccessPassword: "secret12", + } + brand := Branding{ + ServerAddress: "https://desk.example.test", + AllowUnattended: true, + } + host, reason := newSignalHost(brand, st, true, signalHostCallbacks{}) + if host == nil || reason != "" { + t.Fatalf("safe headless host = %v, reason = %q", host, reason) + } + + st.AccessMode = AccessDisabled + host, reason = newSignalHost(brand, st, true, signalHostCallbacks{}) + if host != nil || reason == "" { + t.Fatalf("disabled headless host = %v, reason = %q", host, reason) + } +} + +func TestIncomingCapabilitiesIncludeAudioAndRestartPolicy(t *testing.T) { + caps := Branding{Capabilities: &CapabilityFlags{ + Audio: boolRef(false), + Restart: boolRef(false), + }}.incomingCapabilities() + if caps.Audio || caps.Restart { + t.Fatalf("audio=%v restart=%v, want both disabled", caps.Audio, caps.Restart) + } +} diff --git a/betterdesk-support-agent/apihttp.go b/betterdesk-support-agent/apihttp.go index 58cd99ae..18127d7b 100644 --- a/betterdesk-support-agent/apihttp.go +++ b/betterdesk-support-agent/apihttp.go @@ -17,7 +17,10 @@ import ( const defaultAPIPort = 21114 func tlsInsecureEnabled() bool { - return os.Getenv("BETTERDESK_AGENT_INSECURE_TLS") == "1" + // A release binary must never allow an environment variable to downgrade + // certificate verification. Development builds may opt in for local + // self-signed test servers only. + return !isReleaseBuild() && os.Getenv("BETTERDESK_AGENT_INSECURE_TLS") == "1" } // apiHTTPClient returns an HTTP client for BetterDesk API calls. @@ -50,6 +53,9 @@ func apiBaseURL(b Branding) string { // apiJSON performs a JSON HTTP request against the BetterDesk API. func apiJSON(method, apiURL string, body any, out any) (int, error) { + if err := validateAPIEndpoint(apiURL); err != nil { + return 0, err + } var reader io.Reader if body != nil { data, err := json.Marshal(body) @@ -106,6 +112,20 @@ func apiJSON(method, apiURL string, body any, out any) (int, error) { return resp.StatusCode, nil } +func validateAPIEndpoint(apiURL string) error { + u, err := url.Parse(apiURL) + if err != nil || u.Host == "" { + return fmt.Errorf("invalid BetterDesk API endpoint") + } + if isReleaseBuild() && !strings.EqualFold(u.Scheme, "https") { + return fmt.Errorf("release build refuses non-HTTPS BetterDesk API endpoint") + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("unsupported BetterDesk API endpoint scheme") + } + return nil +} + // normalizeServerOrigin stores a canonical https?://host:port origin in branding address. func normalizeServerOrigin(addr string) string { addr = strings.TrimSpace(addr) diff --git a/betterdesk-support-agent/apihttp_test.go b/betterdesk-support-agent/apihttp_test.go new file mode 100644 index 00000000..442caccc --- /dev/null +++ b/betterdesk-support-agent/apihttp_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "net/http" + "testing" + "time" +) + +func TestInsecureTLSIsDevelopmentOnly(t *testing.T) { + t.Setenv("BETTERDESK_AGENT_INSECURE_TLS", "1") + if got, want := tlsInsecureEnabled(), !isReleaseBuild(); got != want { + t.Fatalf("tlsInsecureEnabled() = %v, want %v (release=%v)", got, want, isReleaseBuild()) + } +} + +func TestAPIHTTPClientDoesNotDisableVerificationInRelease(t *testing.T) { + t.Setenv("BETTERDESK_AGENT_INSECURE_TLS", "1") + client := apiHTTPClient(time.Second) + transport, ok := client.Transport.(*http.Transport) + + if isReleaseBuild() { + if ok && transport.TLSClientConfig != nil && transport.TLSClientConfig.InsecureSkipVerify { + t.Fatal("release HTTP client disabled TLS verification") + } + return + } + if !ok || transport.TLSClientConfig == nil || !transport.TLSClientConfig.InsecureSkipVerify { + t.Fatal("development opt-in did not configure insecure TLS") + } +} + +func TestHTTPGetKeepsVerificationEnabledUnlessDevelopmentOptIn(t *testing.T) { + t.Setenv("BETTERDESK_AGENT_INSECURE_TLS", "1") + client := healthHTTPClient("https://desk.example.test/health") + transport, ok := client.Transport.(*http.Transport) + if isReleaseBuild() && ok && transport.TLSClientConfig != nil && transport.TLSClientConfig.InsecureSkipVerify { + t.Fatal("release health probe would disable TLS verification") + } + if !isReleaseBuild() && (!ok || transport.TLSClientConfig == nil || !transport.TLSClientConfig.InsecureSkipVerify) { + t.Fatal("development health probe did not configure insecure TLS") + } +} diff --git a/betterdesk-support-agent/app.go b/betterdesk-support-agent/app.go index 8167fe9d..385479ae 100644 --- a/betterdesk-support-agent/app.go +++ b/betterdesk-support-agent/app.go @@ -2,6 +2,7 @@ package main import ( "log" + "sync" "time" "fyne.io/fyne/v2" @@ -18,20 +19,21 @@ import ( // ui holds the long-lived application objects shared across the window. type ui struct { - app fyne.App - win fyne.Window - brand Branding - state *AppState - engine *Engine - overlay *sessionOverlay - pwShown bool - pwValueLbl *widget.Label - pwBox fyne.CanvasObject - statusLbl *widget.Label - statusDot *canvas.Rectangle + app fyne.App + win fyne.Window + brand Branding + state *AppState + engine *Engine + overlay *sessionOverlay + pwShown bool + pwValueLbl *widget.Label + pwBox fyne.CanvasObject + statusLbl *widget.Label + statusDot *canvas.Rectangle consentCh chan consentRequest chatMessages []string chatWindow fyne.Window + signalHostMu sync.Mutex signalHost *signalhost.Host } @@ -64,7 +66,7 @@ func run() { engine: NewEngine(version), consentCh: make(chan consentRequest, 1), } - u.overlay = newSessionOverlay(a, brand.ProductName) + u.overlay = newSessionOverlay(a, brand.ProductName, u.disconnectActiveSessions) u.engine.SetCallbacks(u.handleConsent, u.handleSessionStart, u.handleSessionEnd) u.engine.SetChatHandler(u.handleChatMessage) @@ -94,15 +96,15 @@ func (u *ui) bootstrapConnection() { go func() { // #region agent log debugLog("H1", "app.go:bootstrapConnection", "branding connection profile", map[string]any{ - "server_address": u.brand.ServerAddress, - "api_base": apiBaseURL(u.brand), - "cdap_health": u.brand.CDAPHealthURL(), - "cdap_ws": u.brand.CDAPWebSocketURL(), - "use_https": u.brand.UseHTTPS, - "sends_token": false, + "server_address": u.brand.ServerAddress, + "api_base": apiBaseURL(u.brand), + "cdap_health": u.brand.CDAPHealthURL(), + "cdap_ws": u.brand.CDAPWebSocketURL(), + "use_https": u.brand.UseHTTPS, + "sends_token": false, "branding_embed_has_token": brandingEmbedHasLegacyToken(), - "bundle_id": u.brand.BundleID, - "device_id": u.state.DeviceID, + "bundle_id": u.brand.BundleID, + "device_id": u.state.DeviceID, }) // #endregion res, err := EnsureEnrolled(u.brand, u.state, version) @@ -121,13 +123,7 @@ func (u *ui) bootstrapConnection() { }) // #endregion u.onEnrollmentUpdate(res) - if res.Status == EnrollmentApproved { - if err := u.engine.Start(u.state); err != nil { - log.Printf("[support-agent] engine start: %v", err) - } - _ = SyncAccessPassword(u.brand, u.state) - u.startSignalHost() - } else if res.Status == EnrollmentPending { + if res.Status == EnrollmentPending { StartEnrollmentPoll(u.brand, u.state, version, 5*time.Second, u.onEnrollmentUpdate) } u.startStatusLoop() @@ -146,24 +142,40 @@ func (u *ui) onEnrollmentUpdate(res EnrollmentStatus) { } else { u.applyStatus(statusKindPending, t("disconnected")) } + if err := SyncAccessPassword(u.brand, u.state); err != nil { + log.Printf("[support-agent] access password sync: %v", err) + } if !u.engine.Running() { - _ = u.engine.Start(u.state) - _ = SyncAccessPassword(u.brand, u.state) + if err := u.engine.Start(u.state); err != nil { + log.Printf("[support-agent] engine start: %v", err) + return + } + } + if u.engine.Running() { u.startSignalHost() } case EnrollmentPending: + u.stopRemoteAccessForEnrollmentState() msg := t("enrollment_pending") if res.Message != "" { msg = res.Message } u.applyStatus(statusKindPending, msg) case EnrollmentRejected: + u.stopRemoteAccessForEnrollmentState() u.applyStatus(statusKindError, t("enrollment_rejected")) default: u.applyStatus(statusKindPending, t("disconnected")) } } +func (u *ui) stopRemoteAccessForEnrollmentState() { + if u.engine != nil { + u.engine.Stop() + } + u.stopSignalHost() +} + func (u *ui) handleConsent(sessionID, operator string) bool { resp := make(chan bool, 1) req := consentRequest{sessionID: sessionID, operator: operator, response: resp} @@ -216,12 +228,22 @@ func (u *ui) handleSessionEnd(sessionID string) { u.overlay.hide() } +// disconnectActiveSessions is invoked by the local session overlay. The shared +// CDAP engine does not expose a per-session disconnect API, so stopping it is +// the only reliable way to terminate its active local session. The signal host +// can close relay connections without unregistering itself. +func (u *ui) disconnectActiveSessions() { + if u.engine != nil { + u.engine.Stop() + } + u.disconnectSignalSessions() +} + func (u *ui) handleChatMessage(from, text string) { line := from + ": " + text u.chatMessages = append(u.chatMessages, line) } - func (u *ui) buildMainLayout() fyne.CanvasObject { header := u.buildBrandedHeaderBar() body := u.buildContent() @@ -335,8 +357,8 @@ func (u *ui) showConnTest() { go func() { // #region agent log debugLog("H1", "app.go:showConnTest", "probe urls", map[string]any{ - "cdap_health": u.brand.CDAPHealthURL(), - "api_health": u.brand.APIHealthURL(), + "cdap_health": u.brand.CDAPHealthURL(), + "api_health": u.brand.APIHealthURL(), "register_url": apiBaseURL(u.brand) + "/devices/register", }) // #endregion @@ -388,7 +410,7 @@ func (u *ui) showCustomPasswordDialog() { func (u *ui) onModeChange(label string) { mode := modeFromLabel(label) - _, cur, _, custom := u.state.Snapshot() + _, cur, _, _ := u.state.Snapshot() if mode == cur { return } @@ -407,14 +429,22 @@ func (u *ui) onModeChange(label string) { u.pwBox.Hide() } } + // AccessDisabled must immediately remove the signal/relay presence, not + // merely reject a later login attempt. + u.stopSignalHost() if u.brand.HasConnection() && u.state.IsEnrolled() { u.engine.Stop() - if err := u.engine.Start(u.state); err != nil { - log.Printf("[support-agent] engine restart: %v", err) - } - go func() { _ = SyncAccessPassword(u.brand, u.state) }() + go func() { + if err := SyncAccessPassword(u.brand, u.state); err != nil { + log.Printf("[support-agent] access password sync: %v", err) + } + if err := u.engine.Restart(u.state); err != nil { + log.Printf("[support-agent] engine restart: %v", err) + return + } + u.startSignalHost() + }() } - _ = custom } func (u *ui) rebuildMainLayout() { diff --git a/betterdesk-support-agent/applog.go b/betterdesk-support-agent/applog.go index a5cb5287..42e10b4a 100644 --- a/betterdesk-support-agent/applog.go +++ b/betterdesk-support-agent/applog.go @@ -51,6 +51,13 @@ func writeAppLog(level, event, message string, fields map[string]any) { if err != nil { return } + // OpenFile's mode only applies on creation. Repair a pre-existing log so + // release diagnostics containing device/session metadata never remain + // world-readable after an older build or manual file replacement. + if err := f.Chmod(0o600); err != nil { + _ = f.Close() + return + } _, _ = f.Write(line) _ = f.Close() } diff --git a/betterdesk-support-agent/applog_test.go b/betterdesk-support-agent/applog_test.go new file mode 100644 index 00000000..9a4fedc4 --- /dev/null +++ b/betterdesk-support-agent/applog_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestWriteAppLogRepairsExistingFilePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows does not report POSIX file permissions") + } + + dir := t.TempDir() + t.Setenv("BETTERDESK_AGENT_DATA_DIR", dir) + path := filepath.Join(dir, "support-agent.log") + if err := os.WriteFile(path, []byte("old entry\n"), 0o644); err != nil { + t.Fatalf("seed log: %v", err) + } + if err := os.Chmod(path, 0o644); err != nil { + t.Fatalf("make log permissive: %v", err) + } + + writeAppLog("info", "test", "test log", nil) + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat log: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("log permissions = %o, want 0600", got) + } +} diff --git a/betterdesk-support-agent/branding.go b/betterdesk-support-agent/branding.go index c3be7664..8fc2f092 100644 --- a/betterdesk-support-agent/branding.go +++ b/betterdesk-support-agent/branding.go @@ -1,21 +1,29 @@ package main import ( + _ "embed" "encoding/base64" "encoding/json" - _ "embed" + "fmt" "os" "strings" "sync" + "time" + + "github.com/unitronix/betterdesk-support-agent/internal/brandprofile" ) //go:embed resources/branding.json var brandingJSON []byte +//go:embed resources/branding.pub +var brandingPublicKey []byte + type ServerBranding struct { Address string `json:"address"` APIURL string `json:"api_url"` PublicKey string `json:"public_key"` + CertPin string `json:"cert_pin,omitempty"` CDAPPort int `json:"cdap_port,omitempty"` ConsolePort int `json:"console_port,omitempty"` CDAPURL string `json:"cdap_url,omitempty"` @@ -48,21 +56,30 @@ type Branding struct { ServerKey string `json:"server_key"` APIKey string `json:"api_key"` BundleID string `json:"bundle_id"` + ProfileIssuedAt string `json:"profile_issued_at,omitempty"` + ProfileExpiresAt string `json:"profile_expires_at,omitempty"` + AllowedEndpoints []string `json:"allowed_endpoints,omitempty"` UseHTTPS bool `json:"use_https"` Server *ServerBranding `json:"server,omitempty"` - // Incoming capability defaults (RustDesk-style permissions matrix). - // Nil / omitted fields default to true so existing bundles stay permissive. + // Incoming host capability policy. Desktop remains enabled by default for + // existing bundles; all other capabilities require both a profile opt-in + // and a locally enforceable implementation before they can be exposed. Capabilities *CapabilityFlags `json:"capabilities,omitempty"` } // CapabilityFlags gates incoming session features for Support Agent builds. type CapabilityFlags struct { - Desktop *bool `json:"desktop,omitempty"` - Files *bool `json:"files,omitempty"` - Clipboard *bool `json:"clipboard,omitempty"` - Audio *bool `json:"audio,omitempty"` - Terminal *bool `json:"terminal,omitempty"` - Restart *bool `json:"restart,omitempty"` + Desktop *bool `json:"desktop,omitempty"` + Files *bool `json:"files,omitempty"` + Clipboard *bool `json:"clipboard,omitempty"` + Audio *bool `json:"audio,omitempty"` + Terminal *bool `json:"terminal,omitempty"` + Chat *bool `json:"chat,omitempty"` + MultiMonitor *bool `json:"multi_monitor,omitempty"` + PrivacyMode *bool `json:"privacy_mode,omitempty"` + BlockInput *bool `json:"block_input,omitempty"` + Restart *bool `json:"restart,omitempty"` + Recording *bool `json:"recording,omitempty"` } func capEnabled(flag *bool, defaultOn bool) bool { @@ -104,26 +121,122 @@ func GetBranding() Branding { } } } - if isSealedBranding(raw) { - plain, err := unsealBranding(raw) - if err != nil { - // Tampered / wrong seal — refuse to start with empty branding - // rather than fall back to defaults that might phone home wrongly. - brandingVal = brandingDefaults() - brandingVal.ServerAddress = "" - return - } - raw = plain - } - var b Branding - if err := json.Unmarshal(raw, &b); err != nil { - b = brandingDefaults() + b, err := decodeBrandingProfile(raw, brandingPublicKey, isReleaseBuild()) + if err != nil { + // Do not substitute a default endpoint after integrity verification + // fails. A release binary must be signed by its bundle issuer. + brandingVal = rejectedBranding() + return } brandingVal = b.normalize() }) return brandingVal } +func rejectedBranding() Branding { + b := brandingDefaults() + b.ServerAddress = "" + return b +} + +// decodeBrandingProfile validates the profile that is embedded in a binary. +// Release builds accept only authenticated profiles. Development builds may +// deliberately load a plaintext or legacy-sealed local profile to preserve the +// supported developer workflow, but never silently downgrade a signed profile. +func decodeBrandingProfile(raw, publicKeyResource []byte, requireSigned bool) (Branding, error) { + if brandprofile.IsSigned(raw) { + publicKey, err := brandprofile.DecodePublicKey(strings.TrimSpace(string(publicKeyResource))) + if err != nil { + return Branding{}, fmt.Errorf("decode branding public key: %w", err) + } + plain, err := brandprofile.Verify(raw, publicKey) + if err != nil { + return Branding{}, fmt.Errorf("verify branding profile: %w", err) + } + raw = plain + } else { + if requireSigned { + return Branding{}, fmt.Errorf("unsigned branding profile in release build") + } + if isSealedBranding(raw) { + plain, err := unsealBranding(raw) + if err != nil { + return Branding{}, fmt.Errorf("unseal branding profile: %w", err) + } + raw = plain + } + } + + var branding Branding + if err := json.Unmarshal(raw, &branding); err != nil { + return Branding{}, fmt.Errorf("decode branding profile: %w", err) + } + if requireSigned { + if err := branding.validateReleaseProfile(time.Now()); err != nil { + return Branding{}, err + } + } + return branding, nil +} + +func (b Branding) validateReleaseProfile(now time.Time) error { + if strings.TrimSpace(b.BundleID) == "" { + return fmt.Errorf("release branding profile is missing bundle ID") + } + if b.Server == nil || strings.TrimSpace(b.Server.Address) == "" || + strings.TrimSpace(b.Server.APIURL) == "" || strings.TrimSpace(b.Server.CDAPURL) == "" { + return fmt.Errorf("release branding profile has incomplete server endpoints") + } + if strings.TrimSpace(b.ServerKey) == "" && strings.TrimSpace(b.Server.PublicKey) == "" { + return fmt.Errorf("release branding profile is missing server signing key") + } + if strings.TrimSpace(b.ProfileExpiresAt) == "" { + return fmt.Errorf("release branding profile is missing expiry") + } + if strings.TrimSpace(b.ProfileIssuedAt) == "" { + return fmt.Errorf("release branding profile is missing issue time") + } + issuedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(b.ProfileIssuedAt)) + if err != nil { + return fmt.Errorf("parse release branding profile issue time: %w", err) + } + expiresAt, err := time.Parse(time.RFC3339, strings.TrimSpace(b.ProfileExpiresAt)) + if err != nil { + return fmt.Errorf("parse release branding profile expiry: %w", err) + } + if issuedAt.After(now.UTC().Add(5*time.Minute)) || !expiresAt.After(issuedAt) { + return fmt.Errorf("release branding profile has invalid validity period") + } + if !expiresAt.After(now.UTC()) { + return fmt.Errorf("release branding profile has expired") + } + if !allSecureAndAllowed(b.AllowedEndpoints, b.Server.Address, b.Server.APIURL, b.Server.CDAPURL) { + return fmt.Errorf("release branding profile has unauthorized endpoint") + } + return nil +} + +func allSecureAndAllowed(allowed []string, endpoints ...string) bool { + if len(allowed) == 0 { + return false + } + allowedSet := make(map[string]struct{}, len(allowed)) + for _, endpoint := range allowed { + endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/") + if strings.HasPrefix(strings.ToLower(endpoint), "https://") || + strings.HasPrefix(strings.ToLower(endpoint), "wss://") { + allowedSet[endpoint] = struct{}{} + } + } + for _, endpoint := range endpoints { + endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/") + if _, ok := allowedSet[endpoint]; !ok { + return false + } + } + return true +} + func isReleaseBuild() bool { return releaseBuild } diff --git a/betterdesk-support-agent/branding_profile_test.go b/betterdesk-support-agent/branding_profile_test.go new file mode 100644 index 00000000..028900e4 --- /dev/null +++ b/betterdesk-support-agent/branding_profile_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "fmt" + "testing" + + "github.com/unitronix/betterdesk-support-agent/internal/brandprofile" +) + +func TestReleaseBrandingRequiresValidSignedProfile(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + publicKeyResource, err := brandprofile.EncodePublicKey(publicKey) + if err != nil { + t.Fatal(err) + } + profile := []byte(fmt.Sprintf(`{ + "product_name":"Acme Support", + "bundle_id":"bundle-1", + "profile_issued_at":"2026-01-01T00:00:00Z", + "profile_expires_at":"2099-01-01T00:00:00Z", + "allowed_endpoints":[ + "https://support.example.test", + "https://support.example.test/api", + "wss://support.example.test:21122/cdap" + ], + "server":{ + "address":"https://support.example.test", + "api_url":"https://support.example.test/api", + "public_key":"%s", + "cdap_url":"wss://support.example.test:21122/cdap" + } + }`, publicKeyResource)) + signed, err := brandprofile.Sign(profile, privateKey) + if err != nil { + t.Fatal(err) + } + branding, err := decodeBrandingProfile(signed, []byte(publicKeyResource), true) + if err != nil { + t.Fatal(err) + } + if branding.ProductName != "Acme Support" || branding.Server == nil || + branding.Server.Address != "https://support.example.test" { + t.Fatalf("unexpected branding: %+v", branding) + } + + if _, err := decodeBrandingProfile(profile, []byte(publicKeyResource), true); err == nil { + t.Fatal("release profile unexpectedly accepted unsigned branding") + } + missingExpiry, err := brandprofile.Sign([]byte(`{ + "bundle_id":"bundle-1", + "profile_issued_at":"2026-01-01T00:00:00Z", + "allowed_endpoints":["https://support.example.test"], + "server":{"address":"https://support.example.test","api_url":"https://support.example.test/api","cdap_url":"wss://support.example.test:21122/cdap"} + }`), privateKey) + if err != nil { + t.Fatal(err) + } + if _, err := decodeBrandingProfile(missingExpiry, []byte(publicKeyResource), true); err == nil { + t.Fatal("release profile unexpectedly accepted a profile without expiry") + } + + tampered := append([]byte(nil), signed...) + tampered[len(tampered)-1] ^= 1 + if _, err := decodeBrandingProfile(tampered, []byte(publicKeyResource), true); err == nil { + t.Fatal("release profile unexpectedly accepted tampered branding") + } +} + +func TestDevelopmentBrandingCanUsePlaintextProfile(t *testing.T) { + branding, err := decodeBrandingProfile( + []byte(`{"product_name":"Local Dev","server_address":"http://127.0.0.1:21114"}`), + nil, + false, + ) + if err != nil { + t.Fatal(err) + } + if branding.ProductName != "Local Dev" { + t.Fatalf("unexpected branding: %+v", branding) + } +} diff --git a/betterdesk-support-agent/build.sh b/betterdesk-support-agent/build.sh index 87a37de8..94bc81c6 100755 --- a/betterdesk-support-agent/build.sh +++ b/betterdesk-support-agent/build.sh @@ -41,6 +41,25 @@ if [ -z "$TARGET_OS" ]; then TARGET_OS="$($GO env GOOS 2>/dev/null || uname -s | tr '[:upper:]' '[:lower:]')" fi +SIGNING_KEY_FILE="${BETTERDESK_BUNDLE_SIGNING_KEY_FILE:-}" +if [ -z "$SIGNING_KEY_FILE" ]; then + echo "ERROR: release builds require BETTERDESK_BUNDLE_SIGNING_KEY_FILE" >&2 + exit 1 +fi +if [ ! -f "$SIGNING_KEY_FILE" ]; then + echo "ERROR: BETTERDESK_BUNDLE_SIGNING_KEY_FILE does not exist: $SIGNING_KEY_FILE" >&2 + exit 1 +fi + +seal_branding() { + local args + args=(-in resources/branding.json -out resources/branding.json) + if [ -n "$SIGNING_KEY_FILE" ]; then + args+=(-signing-key-file "$SIGNING_KEY_FILE" -public-key-out resources/branding.pub) + fi + "$GO" run ./cmd/sealbranding "${args[@]}" +} + # Bake branding (Console generator overwrites this before invoking build). if [ -n "$BRANDING" ]; then if [ ! -f "$BRANDING" ]; then @@ -78,7 +97,7 @@ fi WIN_LDFLAGS="-s -w -H=windowsgui" linux_dual_build() { - local out_dir launcher x11_bin wl_bin bak + local out_dir launcher x11_bin wl_bin bak pub_bak out_dir="$(dirname "$OUTPUT")" mkdir -p "$out_dir" x11_bin="${out_dir}/betterdesk-support-x11" @@ -86,8 +105,18 @@ linux_dual_build() { launcher="${out_dir}/betterdesk-support" bak="$(mktemp)" + pub_bak="$(mktemp)" cp resources/branding.json "$bak" - "$GO" run ./cmd/sealbranding -in resources/branding.json -out resources/branding.json || cp "$bak" resources/branding.json + cp resources/branding.pub "$pub_bak" + if ! seal_branding; then + cp "$bak" resources/branding.json + cp "$pub_bak" resources/branding.pub + if [ -n "$SIGNING_KEY_FILE" ]; then + echo "ERROR: signed branding profile could not be created" >&2 + rm -f "$bak" "$pub_bak" + return 1 + fi + fi echo "Building Linux X11 UI → $x11_bin ..." GOOS=linux CGO_ENABLED=1 "$GO" build -trimpath -tags release -ldflags "-s -w" -o "$x11_bin" . @@ -96,7 +125,9 @@ linux_dual_build() { GOOS=linux CGO_ENABLED=1 "$GO" build -trimpath -tags "release,wayland" -ldflags "-s -w" -o "$wl_bin" . cp "$bak" resources/branding.json + cp "$pub_bak" resources/branding.pub rm -f "$bak" + rm -f "$pub_bak" cp "$SCRIPT_DIR/scripts/betterdesk-support-launcher.sh" "$launcher" chmod +x "$launcher" "$x11_bin" "$wl_bin" @@ -116,14 +147,19 @@ fi # Seal branding for release embeds (plaintext restored after build). BRANDING_PLAIN_BAK="" +BRANDING_PUB_BAK="" if [ -f resources/branding.json ]; then BRANDING_PLAIN_BAK="$(mktemp)" + BRANDING_PUB_BAK="$(mktemp)" cp resources/branding.json "$BRANDING_PLAIN_BAK" - if "$GO" run ./cmd/sealbranding -in resources/branding.json -out resources/branding.json; then - echo "Sealed branding for release embed" + cp resources/branding.pub "$BRANDING_PUB_BAK" + if seal_branding; then + echo "Signed branding profile for release embed" else - echo "WARN: branding seal failed — embedding plaintext" >&2 + echo "ERROR: branding signing failed; refusing to embed plaintext" >&2 cp "$BRANDING_PLAIN_BAK" resources/branding.json + cp "$BRANDING_PUB_BAK" resources/branding.pub + exit 1 fi fi restore_branding() { @@ -131,6 +167,10 @@ restore_branding() { cp "$BRANDING_PLAIN_BAK" resources/branding.json rm -f "$BRANDING_PLAIN_BAK" fi + if [ -n "$BRANDING_PUB_BAK" ] && [ -f "$BRANDING_PUB_BAK" ]; then + cp "$BRANDING_PUB_BAK" resources/branding.pub + rm -f "$BRANDING_PUB_BAK" + fi } trap restore_branding EXIT diff --git a/betterdesk-support-agent/cmd/sealbranding/main.go b/betterdesk-support-agent/cmd/sealbranding/main.go index fb8d202a..73d8ed7b 100644 --- a/betterdesk-support-agent/cmd/sealbranding/main.go +++ b/betterdesk-support-agent/cmd/sealbranding/main.go @@ -2,17 +2,25 @@ package main import ( + "crypto/ed25519" "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/pem" "flag" "fmt" "os" + "strings" + "github.com/unitronix/betterdesk-support-agent/internal/brandprofile" "github.com/unitronix/betterdesk-support-agent/internal/brandseal" ) func main() { in := flag.String("in", "resources/branding.json", "input branding JSON") out := flag.String("out", "", "output path (default: overwrite -in)") + signingKeyFile := flag.String("signing-key-file", "", "Ed25519 PKCS#8 PEM or base64 private key file") + publicKeyOut := flag.String("public-key-out", "resources/branding.pub", "output file for the signing public key") flag.Parse() if *out == "" { *out = *in @@ -22,10 +30,37 @@ func main() { fmt.Fprintf(os.Stderr, "read: %v\n", err) os.Exit(1) } - if brandseal.IsSealed(plain) { + if brandprofile.IsSigned(plain) || brandseal.IsSealed(plain) { fmt.Println("branding already sealed; skipping") return } + if *signingKeyFile != "" { + privateKey, err := loadPrivateKey(*signingKeyFile) + if err != nil { + fmt.Fprintf(os.Stderr, "signing key: %v\n", err) + os.Exit(1) + } + signed, err := brandprofile.Sign(plain, privateKey) + if err != nil { + fmt.Fprintf(os.Stderr, "sign branding: %v\n", err) + os.Exit(1) + } + encodedPublic, err := brandprofile.EncodePublicKey(privateKey.Public().(ed25519.PublicKey)) + if err != nil { + fmt.Fprintf(os.Stderr, "encode signing public key: %v\n", err) + os.Exit(1) + } + if err := os.WriteFile(*publicKeyOut, []byte(encodedPublic+"\n"), 0o644); err != nil { + fmt.Fprintf(os.Stderr, "write signing public key: %v\n", err) + os.Exit(1) + } + if err := os.WriteFile(*out, signed, 0o644); err != nil { + fmt.Fprintf(os.Stderr, "write: %v\n", err) + os.Exit(1) + } + fmt.Printf("signed branding → %s (%d bytes)\n", *out, len(signed)) + return + } salt := make([]byte, 32) if _, err := rand.Read(salt); err != nil { fmt.Fprintf(os.Stderr, "salt: %v\n", err) @@ -42,3 +77,29 @@ func main() { } fmt.Printf("sealed branding → %s (%d bytes)\n", *out, len(sealed)) } + +func loadPrivateKey(path string) (ed25519.PrivateKey, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + if block, _ := pem.Decode(data); block != nil { + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse PKCS#8 PEM: %w", err) + } + key, ok := parsed.(ed25519.PrivateKey) + if !ok { + return nil, fmt.Errorf("PEM key is not Ed25519") + } + return key, nil + } + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(data))) + if err != nil { + return nil, fmt.Errorf("decode base64 private key: %w", err) + } + if len(raw) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("invalid Ed25519 private key length") + } + return ed25519.PrivateKey(raw), nil +} diff --git a/betterdesk-support-agent/cmd/sealbranding/main_test.go b/betterdesk-support-agent/cmd/sealbranding/main_test.go new file mode 100644 index 00000000..8090dc72 --- /dev/null +++ b/betterdesk-support-agent/cmd/sealbranding/main_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "os" + "path/filepath" + "testing" +) + +func TestLoadPrivateKeyPKCS8PEM(t *testing.T) { + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + der, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "branding.key") + if err := os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600); err != nil { + t.Fatal(err) + } + got, err := loadPrivateKey(path) + if err != nil { + t.Fatal(err) + } + if !privateKey.Equal(got) { + t.Fatal("loaded key differs from source") + } +} diff --git a/betterdesk-support-agent/debuglog.go b/betterdesk-support-agent/debuglog.go index 1302b983..b0fd1317 100644 --- a/betterdesk-support-agent/debuglog.go +++ b/betterdesk-support-agent/debuglog.go @@ -1,3 +1,5 @@ +//go:build !release + package main import ( @@ -44,10 +46,14 @@ func debugLog(hypothesisID, location, message string, data map[string]any) { if dir := filepath.Dir(p); dir != "" { _ = os.MkdirAll(dir, 0o700) } - f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) if err != nil { continue } + if err := f.Chmod(0o600); err != nil { + _ = f.Close() + continue + } _, _ = f.Write(line) _ = f.Close() } diff --git a/betterdesk-support-agent/debuglog_release.go b/betterdesk-support-agent/debuglog_release.go new file mode 100644 index 00000000..8c63fe6e --- /dev/null +++ b/betterdesk-support-agent/debuglog_release.go @@ -0,0 +1,7 @@ +//go:build release + +package main + +// debugLog is intentionally compiled out of release builds. Diagnostic logs +// can include endpoint and device metadata that is not needed at runtime. +func debugLog(string, string, string, map[string]any) {} diff --git a/betterdesk-support-agent/debuglog_test.go b/betterdesk-support-agent/debuglog_test.go new file mode 100644 index 00000000..a3fdf896 --- /dev/null +++ b/betterdesk-support-agent/debuglog_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestDebugLogIsDisabledInReleaseAndPrivateInDevelopment(t *testing.T) { + dir := t.TempDir() + t.Setenv("BETTERDESK_AGENT_DATA_DIR", dir) + t.Setenv("BETTERDESK_DEBUG_LOG", "") + + debugLog("test", "test", "test", nil) + path := filepath.Join(dir, "debug-7fbd11.log") + info, err := os.Stat(path) + + if isReleaseBuild() { + if !os.IsNotExist(err) { + t.Fatalf("release build wrote debug log: %v", err) + } + return + } + if err != nil { + t.Fatalf("development build did not write debug log: %v", err) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { + t.Fatalf("debug log permissions = %o, want 0600", info.Mode().Perm()) + } +} diff --git a/betterdesk-support-agent/engine.go b/betterdesk-support-agent/engine.go index 9b8cf4f4..fb97adf2 100644 --- a/betterdesk-support-agent/engine.go +++ b/betterdesk-support-agent/engine.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" "log" "os" @@ -11,16 +12,19 @@ import ( bdagent "github.com/unitronix/betterdesk-agent/agent" ) +var errRemoteAccessDisabled = errors.New("remote access is disabled by local policy") + // Engine wraps the shared betterdesk-agent remote-desktop engine. type Engine struct { - mu sync.Mutex - agent *bdagent.Agent - running bool - version string - onConsent func(sessionID, operator string) bool - onSessionStart func(sessionID, operator, mode string) - onSessionEnd func(sessionID string) - onChat func(from, text string) + mu sync.Mutex + agent *bdagent.Agent + running bool + version string + onConsent func(sessionID, operator string) bool + onSessionStart func(sessionID, operator, mode string) + onSessionEnd func(sessionID string) + onChat func(from, text string) + sessionAuthorizer *passiveSessionAuthorizer } // NewEngine creates an engine wrapper. @@ -48,8 +52,14 @@ func buildConfig(b Branding, st *AppState, version string, handlers *Engine) (*b if !st.IsEnrolled() { return nil, fmt.Errorf("device not enrolled") } + policy := accessPolicyFor(b, st) + if policy.mode == AccessDisabled { + // Do not keep a CDAP registration alive merely to reject individual + // requests. AccessDisabled is an explicit local opt-out for both + // incoming transports. + return nil, errRemoteAccessDisabled + } - _, _, _, _ = st.Snapshot() st.mu.Lock() token := st.DeviceToken deviceID := st.DeviceID @@ -76,51 +86,54 @@ func buildConfig(b Branding, st *AppState, version string, handlers *Engine) (*b cfg.Tags = append(cfg.Tags, "bundle:"+b.BundleID) } - caps := b.Capabilities - cfg.Screenshot = capEnabled(nil, true) - cfg.Terminal = capEnabled(nil, true) - cfg.Clipboard = capEnabled(nil, true) - cfg.FileBrowser = capEnabled(nil, true) - if caps != nil { - cfg.Screenshot = capEnabled(caps.Desktop, true) - cfg.Terminal = capEnabled(caps.Terminal, true) - cfg.Clipboard = capEnabled(caps.Clipboard, true) - cfg.FileBrowser = capEnabled(caps.Files, true) - } + caps := policy.capabilities + cfg.Screenshot = caps.Desktop + cfg.Terminal = caps.Terminal + cfg.Clipboard = caps.Clipboard + cfg.FileBrowser = caps.Files if home, err := os.UserHomeDir(); err == nil && home != "" { cfg.FileRoot = home } - _, mode, _, _ := st.Snapshot() - switch mode { - case AccessUnattended: - cfg.RequireConsent = false - case AccessDisabled: - cfg.RequireConsent = true - cfg.Screenshot = false - cfg.Terminal = false - cfg.FileBrowser = false - cfg.Clipboard = false - default: - cfg.RequireConsent = true - } + cfg.RequireConsent = policy.requiresConsent() if strings.HasPrefix(strings.TrimSpace(b.ServerAddress), "https://") || b.useTLS() { cfg.EnforceTLS = true } + if b.Server != nil { + cfg.ServerCertPin = strings.TrimSpace(b.Server.CertPin) + } if tlsInsecureEnabled() && cfg.ServerCertPin == "" { cfg.TLSInsecureSkipVerify = true } if handlers != nil { + authorizer := handlers.sessionAuthorizer if handlers.onConsent != nil { - cfg.ConsentHandler = handlers.onConsent + cfg.ConsentHandler = func(sessionID, operator string) bool { + granted := handlers.onConsent(sessionID, operator) + if authorizer != nil { + authorizer.ResolveConsent(sessionID, granted) + } + return granted + } + } + if authorizer != nil { + requireConsent := policy.requiresConsent() + cfg.SessionAuthorizeHandler = func(sessionID, operator, transport string, capabilities []string, grant string) error { + return authorizer.Authorize(sessionID, operator, transport, capabilities, grant, requireConsent) + } } if handlers.onSessionStart != nil { cfg.SessionStartHandler = handlers.onSessionStart } if handlers.onSessionEnd != nil { - cfg.SessionEndHandler = handlers.onSessionEnd + cfg.SessionEndHandler = func(sessionID string) { + if authorizer != nil { + authorizer.End(sessionID) + } + handlers.onSessionEnd(sessionID) + } } if handlers.onChat != nil { cfg.ChatMessageHandler = handlers.onChat @@ -148,6 +161,13 @@ func (e *Engine) Start(st *AppState) error { if !st.IsEnrolled() { return fmt.Errorf("enrollment required before starting engine") } + if e.sessionAuthorizer == nil { + authorizer, err := newPassiveSessionAuthorizer(GetBranding(), st) + if err != nil { + return err + } + e.sessionAuthorizer = authorizer + } cfg, err := buildConfig(GetBranding(), st, e.version, e) if err != nil { @@ -179,6 +199,29 @@ func (e *Engine) Stop() { } } +// Restart applies a changed local access policy after the active agent exits. +// Waiting for the old Run loop avoids racing a fresh configuration against an +// agent that is still connected with the previous permissions. +func (e *Engine) Restart(st *AppState) error { + e.Stop() + + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for e.Running() { + select { + case <-deadline.C: + return fmt.Errorf("remote engine did not stop after access policy change") + case <-ticker.C: + } + } + e.mu.Lock() + e.sessionAuthorizer = nil + e.mu.Unlock() + return e.Start(st) +} + // Running reports whether the engine goroutine is active. func (e *Engine) Running() bool { e.mu.Lock() diff --git a/betterdesk-support-agent/engine_policy_test.go b/betterdesk-support-agent/engine_policy_test.go new file mode 100644 index 00000000..1182b4f8 --- /dev/null +++ b/betterdesk-support-agent/engine_policy_test.go @@ -0,0 +1,24 @@ +package main + +import ( + "errors" + "testing" +) + +func TestBuildConfigDoesNotRegisterWhenAccessIsDisabled(t *testing.T) { + st := &AppState{ + DeviceID: "BD-TEST", + DeviceToken: "device-token", + EnrollmentStatus: EnrollmentApproved, + AccessMode: AccessDisabled, + AccessPassword: "secret12", + } + + cfg, err := buildConfig(Branding{ServerAddress: "https://127.0.0.1:1"}, st, "test", nil) + if !errors.Is(err, errRemoteAccessDisabled) { + t.Fatalf("buildConfig error = %v, want disabled access error", err) + } + if cfg != nil { + t.Fatalf("buildConfig config = %#v, want nil when access is disabled", cfg) + } +} diff --git a/betterdesk-support-agent/enrollment.go b/betterdesk-support-agent/enrollment.go index c4f943a8..87823ee5 100644 --- a/betterdesk-support-agent/enrollment.go +++ b/betterdesk-support-agent/enrollment.go @@ -3,6 +3,7 @@ package main import ( "fmt" "net/http" + "net/url" "os" "runtime" "strings" @@ -49,6 +50,7 @@ func EnsureEnrolled(b Branding, st *AppState, version string) (EnrollmentStatus, status := st.EnrollmentStatus token := st.DeviceToken deviceID := st.DeviceID + message := st.EnrollmentMessage st.mu.Unlock() // #region agent log @@ -62,8 +64,9 @@ func EnsureEnrolled(b Branding, st *AppState, version string) (EnrollmentStatus, if token != "" { // Refresh credentials with the server on every startup. Re-register // re-issues a device_token for known peers and fixes stale local state. - if res, err := RegisterDevice(b, st, version); err == nil { - return res, nil + res, err := RegisterDevice(b, st, version) + if err == nil || res.Status == EnrollmentRejected { + return res, err } // #region agent log debugLog("H4", "enrollment.go:EnsureEnrolled", "refresh failed, using cached token", map[string]any{ @@ -83,7 +86,7 @@ func EnsureEnrolled(b Branding, st *AppState, version string) (EnrollmentStatus, return EnrollmentStatus{ Status: EnrollmentRejected, DeviceID: deviceID, - Message: st.EnrollmentMessage, + Message: message, }, nil } @@ -97,6 +100,10 @@ func EnsureEnrolled(b Branding, st *AppState, version string) (EnrollmentStatus, // RegisterDevice POSTs /api/devices/register. func RegisterDevice(b Branding, st *AppState, version string) (EnrollmentStatus, error) { deviceID, _, _, _ := st.Snapshot() + publicKey, proofErr := enrollmentPublicKey(st) + if proofErr != nil { + return EnrollmentStatus{}, fmt.Errorf("create enrollment identity: %w", proofErr) + } hostname, _ := os.Hostname() if hostname == "" { hostname = "unknown" @@ -109,19 +116,21 @@ func RegisterDevice(b Branding, st *AppState, version string) (EnrollmentStatus, "platform": fmt.Sprintf("%s %s", runtime.GOOS, runtime.GOARCH), "version": version, "device_type": "os_agent", + "public_key": publicKey, } if b.BundleID != "" { payload["bundle_id"] = b.BundleID } - // Never send a shared bundle enrollment token — each device registers - // independently and receives a unique device_token after approval. + // A bundle credential is never embedded or sent here. An already-approved + // device may authenticate its own refresh with its per-device bearer token + // (set below), which prevents unnecessary token re-issuance. tags := []string{"support-agent"} if IsPortable() { tags = append(tags, "portable") } else { tags = append(tags, "installed") } - payload["tags"] = tags + payload["tags"] = strings.Join(tags, ",") var resp enrollmentResponse var code int @@ -129,13 +138,21 @@ func RegisterDevice(b Branding, st *AppState, version string) (EnrollmentStatus, var url string for _, base := range CandidateAPIBases(b, st) { url = strings.TrimRight(base, "/") + "/devices/register" + headers, proofErr := enrollmentProofHeaders(http.MethodPost, url, deviceID, st) + if proofErr != nil { + return EnrollmentStatus{}, proofErr + } + _, currentToken, _ := st.EnrollmentSnapshot() + if currentToken != "" { + headers.Set("Authorization", "Bearer "+currentToken) + } // #region agent log debugLog("H1", "enrollment.go:RegisterDevice", "register request", map[string]any{ "url": url, "device_id": deviceID, "sends_token": false, "bundle_id": b.BundleID, "use_https": b.UseHTTPS, }) // #endregion - code, err = apiJSON(http.MethodPost, url, payload, &resp) + code, err = apiJSONWithHeaders(http.MethodPost, url, payload, headers, &resp) if err == nil { st.RememberGoodEndpoints("", base) break @@ -150,6 +167,16 @@ func RegisterDevice(b Branding, st *AppState, version string) (EnrollmentStatus, "token_len": len(strings.TrimSpace(resp.DeviceToken)), "message": resp.Message, }) // #endregion + result := EnrollmentStatus{ + Status: resp.Status, + DeviceID: resp.DeviceID, + DeviceToken: strings.TrimSpace(resp.DeviceToken), + Message: resp.Message, + } + if result.DeviceID == "" { + result.DeviceID = deviceID + } + if code != http.StatusOK && code != http.StatusAccepted { if code == http.StatusConflict && resp.Error == "identity_conflict" { newID := strings.TrimSpace(resp.SuggestedDeviceID) @@ -161,23 +188,27 @@ func RegisterDevice(b Branding, st *AppState, version string) (EnrollmentStatus, } return RegisterDevice(b, st, version) } - return EnrollmentStatus{}, fmt.Errorf("registration failed (HTTP %d)", code) - } - - result := EnrollmentStatus{ - Status: resp.Status, - DeviceID: resp.DeviceID, - DeviceToken: strings.TrimSpace(resp.DeviceToken), - Message: resp.Message, - } - if result.DeviceID == "" { - result.DeviceID = deviceID + if result.Status == EnrollmentRejected { + if err := st.SetEnrollment(EnrollmentRejected, result.DeviceID, "", result.Message); err != nil { + return result, err + } + return result, nil + } + return result, fmt.Errorf("registration failed (HTTP %d)", code) } switch resp.Status { case EnrollmentApproved: if result.DeviceToken == "" { - return result, fmt.Errorf("registration approved without device_token") + // Ordinary authenticated refreshes intentionally do not re-emit + // the long-lived device credential. Retain the locally encrypted + // token instead of treating a successful refresh as an error. + st.mu.Lock() + result.DeviceToken = strings.TrimSpace(st.DeviceToken) + st.mu.Unlock() + if result.DeviceToken == "" { + return result, fmt.Errorf("registration approved without device_token") + } } if err := st.SetEnrollment(EnrollmentApproved, result.DeviceID, result.DeviceToken, ""); err != nil { return result, err @@ -199,13 +230,29 @@ func RegisterDevice(b Branding, st *AppState, version string) (EnrollmentStatus, // PollEnrollment GETs /api/devices/register/status. func PollEnrollment(b Branding, st *AppState, version string) (EnrollmentStatus, error) { deviceID, _, _, _ := st.Snapshot() - url := fmt.Sprintf("%s/devices/register/status?device_id=%s", apiBaseURL(b), deviceID) - // #region agent log - debugLog("H3", "enrollment.go:PollEnrollment", "poll request", map[string]any{"url": url, "device_id": deviceID}) - // #endregion - var resp enrollmentResponse - code, err := apiJSON(http.MethodGet, url, nil, &resp) + var code int + var err error + var requestURL string + for _, base := range CandidateAPIBases(b, st) { + requestURL = strings.TrimRight(base, "/") + "/devices/register/status?device_id=" + url.QueryEscape(deviceID) + headers, proofErr := enrollmentProofHeaders(http.MethodGet, requestURL, deviceID, st) + if proofErr != nil { + return EnrollmentStatus{}, proofErr + } + _, currentToken, _ := st.EnrollmentSnapshot() + if currentToken != "" { + headers.Set("Authorization", "Bearer "+currentToken) + } + // #region agent log + debugLog("H3", "enrollment.go:PollEnrollment", "poll request", map[string]any{"url": requestURL, "device_id": deviceID}) + // #endregion + code, err = apiJSONWithHeaders(http.MethodGet, requestURL, nil, headers, &resp) + if err == nil { + st.RememberGoodEndpoints("", base) + break + } + } if err != nil { return EnrollmentStatus{}, err } @@ -215,14 +262,6 @@ func PollEnrollment(b Branding, st *AppState, version string) (EnrollmentStatus, "message": resp.Message, }) // #endregion - if code == http.StatusNotFound { - // Lost pending state on server — re-register. - return RegisterDevice(b, st, version) - } - if code != http.StatusOK { - return EnrollmentStatus{}, fmt.Errorf("status poll failed (HTTP %d)", code) - } - result := EnrollmentStatus{ Status: resp.Status, DeviceID: resp.DeviceID, @@ -233,10 +272,55 @@ func PollEnrollment(b Branding, st *AppState, version string) (EnrollmentStatus, result.DeviceID = deviceID } + if code == http.StatusNotFound { + st.mu.Lock() + localStatus := st.EnrollmentStatus + st.mu.Unlock() + if localStatus == EnrollmentPending { + // A pending request may have been pruned before a decision. It has + // no active credential, so retrying registration is safe. + return RegisterDevice(b, st, version) + } + result := EnrollmentStatus{ + Status: EnrollmentRejected, + DeviceID: deviceID, + Message: "device enrollment is no longer recognized by the server", + } + if err := st.SetEnrollment(EnrollmentRejected, result.DeviceID, "", result.Message); err != nil { + return result, err + } + return result, nil + } + if code == http.StatusForbidden && resp.Status == EnrollmentRejected { + result := EnrollmentStatus{ + Status: EnrollmentRejected, + DeviceID: deviceID, + Message: resp.Message, + } + if err := st.SetEnrollment(EnrollmentRejected, result.DeviceID, "", result.Message); err != nil { + return result, err + } + return result, nil + } + if code != http.StatusOK { + if result.Status == EnrollmentRejected { + if err := st.SetEnrollment(EnrollmentRejected, result.DeviceID, "", result.Message); err != nil { + return result, err + } + return result, nil + } + return result, fmt.Errorf("status poll failed (HTTP %d)", code) + } + switch resp.Status { case EnrollmentApproved: if result.DeviceToken == "" { - return RegisterDevice(b, st, version) + st.mu.Lock() + result.DeviceToken = strings.TrimSpace(st.DeviceToken) + st.mu.Unlock() + if result.DeviceToken == "" { + return RegisterDevice(b, st, version) + } } if err := st.SetEnrollment(EnrollmentApproved, result.DeviceID, result.DeviceToken, ""); err != nil { return result, err @@ -273,3 +357,34 @@ func StartEnrollmentPoll(b Branding, st *AppState, version string, interval time } }() } + +// StartEnrollmentRevalidation checks an already-approved device periodically +// so a server-side revoke/disable stops the local transport without requiring +// a restart. Network failures are intentionally retried rather than turning a +// transient outage into an enrollment decision. +func StartEnrollmentRevalidation(b Branding, st *AppState, version string, interval time.Duration, onUpdate func(EnrollmentStatus)) { + if interval <= 0 { + interval = time.Minute + } + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + status, _, _ := st.EnrollmentSnapshot() + if status != EnrollmentApproved { + return + } + result, err := PollEnrollment(b, st, version) + if err != nil { + continue + } + if result.Status == EnrollmentApproved { + continue + } + if onUpdate != nil { + onUpdate(result) + } + return + } + }() +} diff --git a/betterdesk-support-agent/enrollment_proof.go b/betterdesk-support-agent/enrollment_proof.go new file mode 100644 index 00000000..774fa4d0 --- /dev/null +++ b/betterdesk-support-agent/enrollment_proof.go @@ -0,0 +1,148 @@ +package main + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + enrollmentProofTimestampHeader = "X-BD-Enrollment-Timestamp" + enrollmentProofNonceHeader = "X-BD-Enrollment-Nonce" + enrollmentProofSignatureHeader = "X-BD-Enrollment-Signature" +) + +// enrollmentIdentity deterministically derives an Ed25519 key from the +// installation secret, which is encrypted in the local state file. This gives +// an installation a stable proof-of-possession identity without placing a +// second private-key file next to the binary. +func enrollmentIdentity(st *AppState) (ed25519.PublicKey, ed25519.PrivateKey, error) { + if st == nil { + return nil, nil, fmt.Errorf("enrollment state is required") + } + st.mu.Lock() + secret := st.InstallationSecret + st.mu.Unlock() + if secret == "" { + return nil, nil, fmt.Errorf("installation secret is unavailable") + } + decoded, err := hex.DecodeString(secret) + if err != nil { + return nil, nil, fmt.Errorf("decode installation secret: %w", err) + } + seedMaterial := append([]byte("betterdesk-support-enrollment-ed25519-v1|"), decoded...) + seed := sha256.Sum256(seedMaterial) + privateKey := ed25519.NewKeyFromSeed(seed[:]) + return privateKey.Public().(ed25519.PublicKey), privateKey, nil +} + +func enrollmentPublicKey(st *AppState) (string, error) { + publicKey, _, err := enrollmentIdentity(st) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(publicKey), nil +} + +// enrollmentProofHeaders signs the method and path of an enrollment request. +// The signature is intentionally scoped to this endpoint and includes a +// timestamp and nonce so it cannot be reused as a management-channel proof. +func enrollmentProofHeaders(method, rawURL, deviceID string, st *AppState) (http.Header, error) { + if strings.TrimSpace(deviceID) == "" { + return nil, fmt.Errorf("device id is required for enrollment proof") + } + publicKey, privateKey, err := enrollmentIdentity(st) + if err != nil { + return nil, err + } + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Path == "" { + return nil, fmt.Errorf("parse enrollment URL: %w", err) + } + nonceBytes := make([]byte, 24) + if _, err := rand.Read(nonceBytes); err != nil { + return nil, fmt.Errorf("generate enrollment nonce: %w", err) + } + timestamp := time.Now().UTC().Format(time.RFC3339) + nonce := base64.RawURLEncoding.EncodeToString(nonceBytes) + canonicalPublicKey := base64.StdEncoding.EncodeToString(publicKey) + payload := fmt.Sprintf( + "bd-enrollment-v1\n%s\n%s\n%s\n%s\n%s\n%s", + strings.ToUpper(strings.TrimSpace(method)), + parsed.EscapedPath(), + strings.TrimSpace(deviceID), + canonicalPublicKey, + timestamp, + nonce, + ) + signature := ed25519.Sign(privateKey, []byte(payload)) + headers := make(http.Header) + headers.Set(enrollmentProofTimestampHeader, timestamp) + headers.Set(enrollmentProofNonceHeader, nonce) + headers.Set(enrollmentProofSignatureHeader, base64.StdEncoding.EncodeToString(signature)) + + // A device may use the current token to migrate a legacy enrollment that + // predates the public-key binding. It is sent in a header rather than a URL. + if _, token, _ := st.EnrollmentSnapshot(); token != "" { + headers.Set("Authorization", "Bearer "+token) + } + return headers, nil +} + +// apiJSONWithHeaders is the enrollment-only form of apiJSON. It shares the +// hardened HTTP client while allowing proof-of-possession request headers. +func apiJSONWithHeaders(method, apiURL string, body any, headers http.Header, out any) (int, error) { + if err := validateAPIEndpoint(apiURL); err != nil { + return 0, err + } + var reader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return 0, err + } + reader = bytes.NewReader(data) + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, method, apiURL, reader) + if err != nil { + return 0, err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + for key, values := range headers { + for _, value := range values { + req.Header.Add(key, value) + } + } + + resp, err := apiHTTPClient(22 * time.Second).Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return resp.StatusCode, err + } + if out != nil && len(raw) > 0 { + if err := json.Unmarshal(raw, out); err != nil { + return resp.StatusCode, fmt.Errorf("invalid JSON: %w", err) + } + } + return resp.StatusCode, nil +} diff --git a/betterdesk-support-agent/enrollment_proof_test.go b/betterdesk-support-agent/enrollment_proof_test.go new file mode 100644 index 00000000..327e96f4 --- /dev/null +++ b/betterdesk-support-agent/enrollment_proof_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "crypto/ed25519" + "encoding/base64" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func proofTestState() *AppState { + return &AppState{ + DeviceID: "BD-ABC123", + InstallationSecret: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + DeviceToken: "existing-device-token", + } +} + +func TestEnrollmentProofHeadersAreBoundToRequest(t *testing.T) { + st := proofTestState() + headers, err := enrollmentProofHeaders(http.MethodPost, "https://desk.example/api/devices/register", st.DeviceID, st) + if err != nil { + t.Fatal(err) + } + publicKey, _, err := enrollmentIdentity(st) + if err != nil { + t.Fatal(err) + } + timestamp := headers.Get(enrollmentProofTimestampHeader) + nonce := headers.Get(enrollmentProofNonceHeader) + signature, err := base64.StdEncoding.DecodeString(headers.Get(enrollmentProofSignatureHeader)) + if err != nil { + t.Fatal(err) + } + payload := fmt.Sprintf( + "bd-enrollment-v1\n%s\n%s\n%s\n%s\n%s\n%s", + http.MethodPost, + "/api/devices/register", + st.DeviceID, + base64.StdEncoding.EncodeToString(publicKey), + timestamp, + nonce, + ) + if !ed25519.Verify(publicKey, []byte(payload), signature) { + t.Fatal("enrollment proof did not verify") + } + if headers.Get("Authorization") != "Bearer existing-device-token" { + t.Fatalf("unexpected authorization header: %q", headers.Get("Authorization")) + } +} + +func TestEnrollmentPublicKeyIsStablePerInstallation(t *testing.T) { + st := proofTestState() + first, err := enrollmentPublicKey(st) + if err != nil { + t.Fatal(err) + } + second, err := enrollmentPublicKey(st) + if err != nil { + t.Fatal(err) + } + if first != second { + t.Fatalf("identity changed: %q != %q", first, second) + } +} + +func TestAPIJSONWithHeadersForwardsProofWithoutURLSecrets(t *testing.T) { + st := proofTestState() + var gotAuthorization string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuthorization = r.Header.Get("Authorization") + if r.URL.RawQuery != "" { + t.Errorf("unexpected query data: %q", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"approved"}`)) + })) + defer server.Close() + + headers, err := enrollmentProofHeaders(http.MethodPost, server.URL+"/api/devices/register", st.DeviceID, st) + if err != nil { + t.Fatal(err) + } + var response struct { + Status string `json:"status"` + } + code, err := apiJSONWithHeaders(http.MethodPost, server.URL+"/api/devices/register", map[string]string{"device_id": st.DeviceID}, headers, &response) + if err != nil { + t.Fatal(err) + } + if code != http.StatusOK || response.Status != "approved" { + t.Fatalf("unexpected response code=%d payload=%+v", code, response) + } + if gotAuthorization != "Bearer existing-device-token" { + t.Fatalf("authorization header not forwarded: %q", gotAuthorization) + } +} diff --git a/betterdesk-support-agent/enrollment_test.go b/betterdesk-support-agent/enrollment_test.go new file mode 100644 index 00000000..c6976e0a --- /dev/null +++ b/betterdesk-support-agent/enrollment_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func enrollmentTestState(t *testing.T) *AppState { + t.Helper() + t.Setenv("BETTERDESK_AGENT_DATA_DIR", t.TempDir()) + + st, err := LoadState() + if err != nil { + t.Fatal(err) + } + return st +} + +func enrollmentTestBranding(serverURL string) Branding { + return Branding{ + ServerAddress: serverURL, + Server: &ServerBranding{ + Address: serverURL, + APIURL: serverURL + "/api", + }, + } +} + +func TestPollEnrollmentPersistsRejectedServerState(t *testing.T) { + st := enrollmentTestState(t) + if err := st.SetEnrollment(EnrollmentPending, st.DeviceID, "", "Waiting for approval"); err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/devices/register/status" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(enrollmentResponse{ + Status: EnrollmentRejected, + DeviceID: st.DeviceID, + Message: "Device enrollment was rejected", + }) + })) + defer server.Close() + + res, err := PollEnrollment(enrollmentTestBranding(server.URL), st, "test") + if err != nil { + t.Fatal(err) + } + if res.Status != EnrollmentRejected { + t.Fatalf("status = %q, want %q", res.Status, EnrollmentRejected) + } + + status, token, message := st.EnrollmentSnapshot() + if status != EnrollmentRejected || token != "" || message != "Device enrollment was rejected" { + t.Fatalf("persisted enrollment = status=%q token=%q message=%q", status, token, message) + } +} + +func TestEnsureEnrolledDoesNotUseCachedTokenAfterRejection(t *testing.T) { + st := enrollmentTestState(t) + if err := st.SetEnrollment(EnrollmentApproved, st.DeviceID, "cached-device-token", ""); err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/devices/register" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(enrollmentResponse{ + Status: EnrollmentRejected, + DeviceID: st.DeviceID, + Message: "Device is banned", + }) + })) + defer server.Close() + + res, err := EnsureEnrolled(enrollmentTestBranding(server.URL), st, "test") + if err != nil { + t.Fatal(err) + } + if res.Status != EnrollmentRejected || res.Message != "Device is banned" { + t.Fatalf("unexpected enrollment result: %+v", res) + } + + status, token, _ := st.EnrollmentSnapshot() + if status != EnrollmentRejected || token != "" { + t.Fatalf("rejected enrollment retained a cached credential: status=%q token=%q", status, token) + } +} + +func TestPollEnrollmentKeepsExistingTokenOnOrdinaryApprovedRefresh(t *testing.T) { + st := enrollmentTestState(t) + if err := st.SetEnrollment(EnrollmentApproved, st.DeviceID, "existing-device-token", ""); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/devices/register/status" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer existing-device-token" { + t.Fatalf("Authorization = %q, want device bearer token", got) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(enrollmentResponse{ + Status: EnrollmentApproved, + DeviceID: st.DeviceID, + }) + })) + defer server.Close() + + result, err := PollEnrollment(enrollmentTestBranding(server.URL), st, "test") + if err != nil { + t.Fatal(err) + } + if result.DeviceToken != "existing-device-token" { + t.Fatalf("token = %q, want retained local token", result.DeviceToken) + } + status, token, _ := st.EnrollmentSnapshot() + if status != EnrollmentApproved || token != "existing-device-token" { + t.Fatalf("state = status=%q token=%q", status, token) + } +} diff --git a/betterdesk-support-agent/headless.go b/betterdesk-support-agent/headless.go index 51de0bb9..ba271b25 100644 --- a/betterdesk-support-agent/headless.go +++ b/betterdesk-support-agent/headless.go @@ -4,8 +4,11 @@ import ( "log" "os" "os/signal" + "sync" "syscall" "time" + + "github.com/unitronix/betterdesk-support-agent/signalhost" ) // runHeadless starts enrollment and the remote engine without a Fyne window. @@ -24,18 +27,101 @@ func runHeadless() { log.Printf("[support-agent] %s starting headless (device=%s)", version, st.DeviceID) + var hostMu sync.Mutex + var host *signalhost.Host + stopSignalHost := func() { + hostMu.Lock() + activeHost := host + host = nil + hostMu.Unlock() + if activeHost != nil { + activeHost.Stop() + } + } + startSignalHost := func() { + hostMu.Lock() + if host != nil { + hostMu.Unlock() + return + } + hostMu.Unlock() + + candidate, reason := newSignalHost(brand, st, true, signalHostCallbacks{ + consent: func(operator string) bool { + return headlessConsent(brand, st)("signal", operator) + }, + audit: func(policy hostCapabilityPolicy) { + auditHostCapabilityPolicy(hostCapabilityAuditTransportSignal, policy) + }, + }) + if candidate == nil { + log.Printf("[support-agent] headless RustDesk-compatible host disabled: %s", reason) + return + } + if !candidate.Start() { + log.Printf("[support-agent] headless RustDesk-compatible host disabled: access policy changed") + return + } + + hostMu.Lock() + if host == nil { + host = candidate + candidate = nil + } + hostMu.Unlock() + if candidate != nil { + candidate.Stop() + return + } + log.Printf("[support-agent] headless RustDesk-compatible host started") + } + if brand.HasConnection() { - go headlessBootstrap(brand, st, engine) + go headlessBootstrap(brand, st, engine, startSignalHost, stopSignalHost) } sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt, syscall.SIGTERM) <-sig log.Printf("[support-agent] shutting down") + stopSignalHost() engine.Stop() } -func headlessBootstrap(brand Branding, st *AppState, engine *Engine) { +func headlessBootstrap(brand Branding, st *AppState, engine *Engine, onApproved, onRejected func()) { + startApproved := func() { + if err := SyncAccessPassword(brand, st); err != nil { + log.Printf("[support-agent] access password sync: %v", err) + } + policy := accessPolicyFor(brand, st) + if !policy.allowsSignalHost(true) { + // A headless process has no trustworthy local consent surface. + // Fail before registering the CDAP engine so supervised, disabled, + // and passwordless modes cannot advertise an unreachable session. + log.Printf("[support-agent] headless remote access disabled: %s", policy.signalHostDisabledReason(true)) + return + } + if !engine.Running() { + if err := engine.Start(st); err != nil { + log.Printf("[support-agent] engine start: %v", err) + return + } + } + if onApproved != nil { + onApproved() + } + StartEnrollmentRevalidation(brand, st, version, enrollmentRevalidationInterval, func(result EnrollmentStatus) { + if result.Status != EnrollmentRejected { + return + } + log.Printf("[support-agent] enrollment revoked: %s", result.Message) + if onRejected != nil { + onRejected() + } + engine.Stop() + }) + } + res, err := EnsureEnrolled(brand, st, version) if err != nil { log.Printf("[support-agent] enrollment: %v", err) @@ -43,17 +129,12 @@ func headlessBootstrap(brand Branding, st *AppState, engine *Engine) { } switch res.Status { case EnrollmentApproved: - if err := engine.Start(st); err != nil { - log.Printf("[support-agent] engine start: %v", err) - return - } - _ = SyncAccessPassword(brand, st) + startApproved() case EnrollmentPending: log.Printf("[support-agent] enrollment pending: %s", res.Message) StartEnrollmentPoll(brand, st, version, 5*time.Second, func(u EnrollmentStatus) { - if u.Status == EnrollmentApproved && !engine.Running() { - _ = engine.Start(st) - _ = SyncAccessPassword(brand, st) + if u.Status == EnrollmentApproved { + startApproved() } }) case EnrollmentRejected: @@ -63,12 +144,16 @@ func headlessBootstrap(brand Branding, st *AppState, engine *Engine) { func headlessConsent(brand Branding, st *AppState) func(string, string) bool { return func(sessionID, operator string) bool { - mode, _, _, _ := st.Snapshot() - if brand.AllowUnattended || mode == AccessUnattended { + policy := accessPolicyFor(brand, st) + if policy.allowsUnattended() { log.Printf("[support-agent] headless consent auto-allow session=%s operator=%s", sessionID, operator) return true } - log.Printf("[support-agent] headless consent denied (supervised, no UI) session=%s operator=%s", sessionID, operator) + if policy.mode == AccessDisabled { + log.Printf("[support-agent] headless consent denied (access disabled) session=%s operator=%s", sessionID, operator) + return false + } + log.Printf("[support-agent] headless consent denied (no approved unattended policy) session=%s operator=%s", sessionID, operator) return false } } diff --git a/betterdesk-support-agent/host_capability_audit.go b/betterdesk-support-agent/host_capability_audit.go new file mode 100644 index 00000000..038a27cd --- /dev/null +++ b/betterdesk-support-agent/host_capability_audit.go @@ -0,0 +1,25 @@ +package main + +const ( + hostCapabilityAuditTransportCDAP = "cdap" + hostCapabilityAuditTransportSignal = "signal" +) + +// auditHostCapabilityPolicy records the fixed policy outcome only. The +// records cannot contain credentials, grant presentations, clipboard content, +// terminal data, chat text, or arbitrary remote input. +func auditHostCapabilityPolicy(transport string, policy hostCapabilityPolicy) { + appLogInfo("host_capability_policy", "host capability policy evaluated", map[string]any{ + "transport": normalizeHostCapabilityAuditTransport(transport), + "features": policy.auditRecords(), + }) +} + +func normalizeHostCapabilityAuditTransport(transport string) string { + switch transport { + case hostCapabilityAuditTransportCDAP, hostCapabilityAuditTransportSignal: + return transport + default: + return "unknown" + } +} diff --git a/betterdesk-support-agent/host_capability_policy.go b/betterdesk-support-agent/host_capability_policy.go new file mode 100644 index 00000000..eabea00a --- /dev/null +++ b/betterdesk-support-agent/host_capability_policy.go @@ -0,0 +1,208 @@ +package main + +// hostFeature names a remote-host operation. These values intentionally match +// the session-grant vocabulary where it exists, so a future adapter can use +// the same policy result for both CDAP and compatibility transports. +type hostFeature string + +const ( + hostFeatureScreenView hostFeature = "screen_view" + hostFeatureInput hostFeature = "input" + hostFeatureClipboard hostFeature = "clipboard" + hostFeatureFiles hostFeature = "files" + hostFeatureTerminal hostFeature = "terminal" + hostFeatureChat hostFeature = "chat" + hostFeatureAudio hostFeature = "system_audio" + hostFeatureMultiMonitor hostFeature = "multi_monitor" + hostFeaturePrivacyMode hostFeature = "privacy_mode" + hostFeatureBlockInput hostFeature = "block_input" + hostFeatureRestart hostFeature = "restart" + hostFeatureRecording hostFeature = "recording" +) + +var hostFeatureOrder = []hostFeature{ + hostFeatureScreenView, + hostFeatureInput, + hostFeatureClipboard, + hostFeatureFiles, + hostFeatureTerminal, + hostFeatureChat, + hostFeatureAudio, + hostFeatureMultiMonitor, + hostFeaturePrivacyMode, + hostFeatureBlockInput, + hostFeatureRestart, + hostFeatureRecording, +} + +// hostFeatureReason is deliberately a small, fixed vocabulary. It is safe to +// retain in local audit logs because it never contains a credential, grant, +// clipboard content, terminal input, or other request payload. +type hostFeatureReason string + +const ( + hostFeatureEnabled hostFeatureReason = "enabled" + hostFeatureDisabledByBranding hostFeatureReason = "disabled_by_branding" + hostFeatureGrantBindingUnavailable hostFeatureReason = "session_grant_binding_unavailable" + hostFeatureAudioPipelineUnavailable hostFeatureReason = "audio_pipeline_unavailable" + hostFeatureSingleCaptureSource hostFeatureReason = "single_capture_source_only" + hostFeaturePrivacyCurtainUnavailable hostFeatureReason = "privacy_curtain_unavailable" + hostFeatureLocalInputBlockUnavailable hostFeatureReason = "local_input_block_unavailable" + hostFeatureRestartGrantBindingMissing hostFeatureReason = "restart_grant_binding_unavailable" + hostFeatureRecordingPipelineUnavailable hostFeatureReason = "recording_pipeline_unavailable" + hostFeatureUnknown hostFeatureReason = "unknown_feature" +) + +// hostFeatureStatus is the complete local decision for one feature. +type hostFeatureStatus struct { + Feature hostFeature + Allowed bool + Reason hostFeatureReason +} + +// hostCapabilityPolicy is the one conservative source of truth for what the +// Support Agent may expose. A feature is allowed only when branding permits it +// and the current host path can enforce that permission locally. +type hostCapabilityPolicy struct { + statuses map[hostFeature]hostFeatureStatus +} + +func hostCapabilityPolicyFor(branding Branding) hostCapabilityPolicy { + flags := branding.Capabilities + policy := hostCapabilityPolicy{statuses: make(map[hostFeature]hostFeatureStatus, len(hostFeatureOrder))} + + // Desktop capture and remote input share the current CDAP configuration + // switch. They are the only features with an enforceable admission path: + // the CDAP adapter binds them to a signed passive grant, while the relay + // host applies its local password, TOTP, and consent checks. + desktopEnabled := hostFeatureRequested(featureFlag(flags, hostFeatureScreenView), true) + policy.set(hostFeatureScreenView, desktopEnabled, hostFeatureDisabledByBranding) + policy.set(hostFeatureInput, desktopEnabled, hostFeatureDisabledByBranding) + + // These handlers exist in the shared agent, but their independent CDAP + // messages are not bound to the Support Agent's signed session grant or + // active-session state. Advertising them would create a policy bypass. + policy.setUnavailable(hostFeatureClipboard, featureFlag(flags, hostFeatureClipboard), hostFeatureGrantBindingUnavailable) + policy.setUnavailable(hostFeatureFiles, featureFlag(flags, hostFeatureFiles), hostFeatureGrantBindingUnavailable) + policy.setUnavailable(hostFeatureTerminal, featureFlag(flags, hostFeatureTerminal), hostFeatureGrantBindingUnavailable) + policy.setUnavailable(hostFeatureChat, featureFlag(flags, hostFeatureChat), hostFeatureGrantBindingUnavailable) + + // The remaining operations have no enforceable host implementation. They + // must stay denied even if a signed branding profile requests them. + policy.setUnavailable(hostFeatureAudio, featureFlag(flags, hostFeatureAudio), hostFeatureAudioPipelineUnavailable) + policy.setUnavailable(hostFeatureMultiMonitor, featureFlag(flags, hostFeatureMultiMonitor), hostFeatureSingleCaptureSource) + policy.setUnavailable(hostFeaturePrivacyMode, featureFlag(flags, hostFeaturePrivacyMode), hostFeaturePrivacyCurtainUnavailable) + policy.setUnavailable(hostFeatureBlockInput, featureFlag(flags, hostFeatureBlockInput), hostFeatureLocalInputBlockUnavailable) + policy.setUnavailable(hostFeatureRestart, featureFlag(flags, hostFeatureRestart), hostFeatureRestartGrantBindingMissing) + policy.setUnavailable(hostFeatureRecording, featureFlag(flags, hostFeatureRecording), hostFeatureRecordingPipelineUnavailable) + + return policy +} + +func featureFlag(flags *CapabilityFlags, feature hostFeature) *bool { + if flags == nil { + return nil + } + switch feature { + case hostFeatureScreenView, hostFeatureInput: + return flags.Desktop + case hostFeatureClipboard: + return flags.Clipboard + case hostFeatureFiles: + return flags.Files + case hostFeatureTerminal: + return flags.Terminal + case hostFeatureChat: + return flags.Chat + case hostFeatureAudio: + return flags.Audio + case hostFeatureMultiMonitor: + return flags.MultiMonitor + case hostFeaturePrivacyMode: + return flags.PrivacyMode + case hostFeatureBlockInput: + return flags.BlockInput + case hostFeatureRestart: + return flags.Restart + case hostFeatureRecording: + return flags.Recording + default: + return nil + } +} + +func hostFeatureRequested(flag *bool, defaultValue bool) bool { + if flag == nil { + return defaultValue + } + return *flag +} + +func (p hostCapabilityPolicy) set(feature hostFeature, allowed bool, disabledReason hostFeatureReason) { + reason := hostFeatureEnabled + if !allowed { + reason = disabledReason + } + p.statuses[feature] = hostFeatureStatus{ + Feature: feature, + Allowed: allowed, + Reason: reason, + } +} + +func (p hostCapabilityPolicy) setUnavailable(feature hostFeature, profileFlag *bool, unavailableReason hostFeatureReason) { + reason := unavailableReason + if profileFlag != nil && !*profileFlag { + reason = hostFeatureDisabledByBranding + } + p.statuses[feature] = hostFeatureStatus{ + Feature: feature, + Allowed: false, + Reason: reason, + } +} + +func (p hostCapabilityPolicy) allows(feature hostFeature) bool { + status, ok := p.statuses[feature] + return ok && status.Allowed +} + +func (p hostCapabilityPolicy) status(feature hostFeature) hostFeatureStatus { + if status, ok := p.statuses[feature]; ok { + return status + } + return hostFeatureStatus{Feature: feature, Reason: hostFeatureUnknown} +} + +func (p hostCapabilityPolicy) statusesInOrder() []hostFeatureStatus { + statuses := make([]hostFeatureStatus, 0, len(hostFeatureOrder)) + for _, feature := range hostFeatureOrder { + statuses = append(statuses, p.status(feature)) + } + return statuses +} + +// hostFeatureAuditRecord contains only code-defined capability decisions. +// It intentionally has no field that could carry request content or secrets. +type hostFeatureAuditRecord struct { + Feature string `json:"feature"` + Decision string `json:"decision"` + Reason string `json:"reason"` +} + +func (p hostCapabilityPolicy) auditRecords() []hostFeatureAuditRecord { + statuses := p.statusesInOrder() + records := make([]hostFeatureAuditRecord, 0, len(statuses)) + for _, status := range statuses { + decision := "denied" + if status.Allowed { + decision = "allowed" + } + records = append(records, hostFeatureAuditRecord{ + Feature: string(status.Feature), + Decision: decision, + Reason: string(status.Reason), + }) + } + return records +} diff --git a/betterdesk-support-agent/host_capability_policy_test.go b/betterdesk-support-agent/host_capability_policy_test.go new file mode 100644 index 00000000..49b2b8b7 --- /dev/null +++ b/betterdesk-support-agent/host_capability_policy_test.go @@ -0,0 +1,151 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestHostCapabilityPolicyAllowsOnlyGrantBoundDesktopAndInput(t *testing.T) { + policy := hostCapabilityPolicyFor(Branding{}) + + for _, feature := range []hostFeature{hostFeatureScreenView, hostFeatureInput} { + status := policy.status(feature) + if !status.Allowed || status.Reason != hostFeatureEnabled { + t.Fatalf("%s status = %#v, want allowed/enabled", feature, status) + } + } + + for _, feature := range []hostFeature{ + hostFeatureClipboard, + hostFeatureFiles, + hostFeatureTerminal, + hostFeatureChat, + hostFeatureAudio, + hostFeatureMultiMonitor, + hostFeaturePrivacyMode, + hostFeatureBlockInput, + hostFeatureRestart, + hostFeatureRecording, + } { + if status := policy.status(feature); status.Allowed { + t.Fatalf("%s must stay denied until its host path is enforceable: %#v", feature, status) + } + } +} + +func TestHostCapabilityPolicyFailsClosedWhenBrandingRequestsUnavailableFeatures(t *testing.T) { + enabled := true + disabled := false + branding := Branding{Capabilities: &CapabilityFlags{ + Desktop: &disabled, + Files: &enabled, + Clipboard: &enabled, + Audio: &enabled, + Terminal: &enabled, + Chat: &enabled, + MultiMonitor: &enabled, + PrivacyMode: &enabled, + BlockInput: &enabled, + Restart: &enabled, + Recording: &enabled, + }} + + policy := hostCapabilityPolicyFor(branding) + if status := policy.status(hostFeatureScreenView); status.Allowed || status.Reason != hostFeatureDisabledByBranding { + t.Fatalf("screen_view status = %#v, want branding denial", status) + } + if status := policy.status(hostFeatureInput); status.Allowed || status.Reason != hostFeatureDisabledByBranding { + t.Fatalf("input status = %#v, want branding denial", status) + } + + wantReasons := map[hostFeature]hostFeatureReason{ + hostFeatureClipboard: hostFeatureGrantBindingUnavailable, + hostFeatureFiles: hostFeatureGrantBindingUnavailable, + hostFeatureTerminal: hostFeatureGrantBindingUnavailable, + hostFeatureChat: hostFeatureGrantBindingUnavailable, + hostFeatureAudio: hostFeatureAudioPipelineUnavailable, + hostFeatureMultiMonitor: hostFeatureSingleCaptureSource, + hostFeaturePrivacyMode: hostFeaturePrivacyCurtainUnavailable, + hostFeatureBlockInput: hostFeatureLocalInputBlockUnavailable, + hostFeatureRestart: hostFeatureRestartGrantBindingMissing, + hostFeatureRecording: hostFeatureRecordingPipelineUnavailable, + } + for feature, reason := range wantReasons { + status := policy.status(feature) + if status.Allowed || status.Reason != reason { + t.Fatalf("%s status = %#v, want denied/%s", feature, status, reason) + } + } + + caps := branding.incomingCapabilities() + if caps.Desktop || caps.Files || caps.Clipboard || caps.Audio || caps.Terminal || caps.Restart { + t.Fatalf("shared-agent switches must fail closed: %#v", caps) + } +} + +func TestHostCapabilityAuditRecordsContainOnlyFixedPolicyMetadata(t *testing.T) { + records := hostCapabilityPolicyFor(Branding{}).auditRecords() + if len(records) != len(hostFeatureOrder) { + t.Fatalf("got %d audit records, want %d", len(records), len(hostFeatureOrder)) + } + + encoded, err := json.Marshal(records) + if err != nil { + t.Fatalf("marshal audit records: %v", err) + } + for _, forbidden := range []string{"password", "token", "grant_presentation", "clipboard_data", "terminal_data", "chat_text"} { + if strings.Contains(string(encoded), forbidden) { + t.Fatalf("audit records leaked forbidden field %q: %s", forbidden, encoded) + } + } + for _, record := range records { + if record.Feature == "" || record.Decision == "" || record.Reason == "" { + t.Fatalf("incomplete audit record: %#v", record) + } + } +} + +func TestHostCapabilityAuditTransportIsAllowlisted(t *testing.T) { + if got := normalizeHostCapabilityAuditTransport(hostCapabilityAuditTransportCDAP); got != hostCapabilityAuditTransportCDAP { + t.Fatalf("cdap transport = %q", got) + } + if got := normalizeHostCapabilityAuditTransport(hostCapabilityAuditTransportSignal); got != hostCapabilityAuditTransportSignal { + t.Fatalf("signal transport = %q", got) + } + if got := normalizeHostCapabilityAuditTransport("remote-secret-token"); got != "unknown" { + t.Fatalf("unrecognized transport = %q, want unknown", got) + } +} + +func TestSignalHostReceivesOnlyNonSecretCapabilityAudit(t *testing.T) { + st := &AppState{ + DeviceID: "BD-TEST", + AccessMode: AccessUnattended, + AccessPassword: "secret12", + } + var got []hostFeatureAuditRecord + host, reason := newSignalHost( + Branding{ServerAddress: "https://desk.example.test", AllowUnattended: true}, + st, + true, + signalHostCallbacks{ + audit: func(policy hostCapabilityPolicy) { + got = policy.auditRecords() + }, + }, + ) + if host == nil || reason != "" { + t.Fatalf("newSignalHost() = (%v, %q), want host without reason", host, reason) + } + encoded, err := json.Marshal(got) + if err != nil { + t.Fatalf("marshal signal-host audit: %v", err) + } + if strings.Contains(string(encoded), st.AccessPassword) { + t.Fatalf("signal-host audit leaked access password: %s", encoded) + } + if len(got) != len(hostFeatureOrder) { + t.Fatalf("got %d audit records, want %d", len(got), len(hostFeatureOrder)) + } +} diff --git a/betterdesk-support-agent/internal/brandprofile/profile.go b/betterdesk-support-agent/internal/brandprofile/profile.go new file mode 100644 index 00000000..dd2b6ffc --- /dev/null +++ b/betterdesk-support-agent/internal/brandprofile/profile.go @@ -0,0 +1,83 @@ +// Package brandprofile provides authenticated branding envelopes for the +// Support Agent. It deliberately uses a signing key rather than a symmetric +// key derived from data stored alongside the profile. +package brandprofile + +import ( + "crypto/ed25519" + "crypto/subtle" + "encoding/base64" + "fmt" +) + +var magic = []byte("BDBP2\x00") + +const signatureSize = ed25519.SignatureSize + +// Sign returns a versioned envelope containing an Ed25519 signature and the +// original JSON profile. The public key is intentionally not part of the +// envelope: callers embed it in the authenticated application artifact. +func Sign(profile []byte, privateKey ed25519.PrivateKey) ([]byte, error) { + if len(privateKey) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("invalid Ed25519 private key length") + } + if len(profile) == 0 { + return nil, fmt.Errorf("empty branding profile") + } + signature := ed25519.Sign(privateKey, profile) + out := make([]byte, 0, len(magic)+signatureSize+len(profile)) + out = append(out, magic...) + out = append(out, signature...) + out = append(out, profile...) + return out, nil +} + +// Verify validates an authenticated profile envelope and returns its original +// JSON. It rejects unsigned, truncated, and tampered data. +func Verify(envelope []byte, publicKey ed25519.PublicKey) ([]byte, error) { + if len(publicKey) != ed25519.PublicKeySize { + return nil, fmt.Errorf("invalid Ed25519 public key length") + } + if !IsSigned(envelope) { + return nil, fmt.Errorf("not a signed branding profile") + } + offset := len(magic) + if len(envelope) <= offset+signatureSize { + return nil, fmt.Errorf("truncated branding profile") + } + signature := envelope[offset : offset+signatureSize] + profile := envelope[offset+signatureSize:] + if !ed25519.Verify(publicKey, profile, signature) { + return nil, fmt.Errorf("branding profile signature verification failed") + } + return profile, nil +} + +// IsSigned reports whether the blob uses the signed-profile envelope format. +func IsSigned(blob []byte) bool { + if len(blob) < len(magic) { + return false + } + return subtle.ConstantTimeCompare(blob[:len(magic)], magic) == 1 +} + +// EncodePublicKey produces the compact base64 value used in the generated +// public-key resource. +func EncodePublicKey(key ed25519.PublicKey) (string, error) { + if len(key) != ed25519.PublicKeySize { + return "", fmt.Errorf("invalid Ed25519 public key length") + } + return base64.RawStdEncoding.EncodeToString(key), nil +} + +// DecodePublicKey parses the compact public-key resource. +func DecodePublicKey(encoded string) (ed25519.PublicKey, error) { + raw, err := base64.RawStdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("decode branding public key: %w", err) + } + if len(raw) != ed25519.PublicKeySize { + return nil, fmt.Errorf("invalid Ed25519 public key length") + } + return ed25519.PublicKey(raw), nil +} diff --git a/betterdesk-support-agent/internal/brandprofile/profile_test.go b/betterdesk-support-agent/internal/brandprofile/profile_test.go new file mode 100644 index 00000000..fcee2c11 --- /dev/null +++ b/betterdesk-support-agent/internal/brandprofile/profile_test.go @@ -0,0 +1,74 @@ +package brandprofile + +import ( + "crypto/ed25519" + "crypto/rand" + "testing" +) + +func TestSignedProfileRoundTrip(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + profile := []byte(`{"bundle_id":"bundle-1","server":{"address":"https://desk.example"}}`) + envelope, err := Sign(profile, privateKey) + if err != nil { + t.Fatal(err) + } + if !IsSigned(envelope) { + t.Fatal("expected signed envelope") + } + got, err := Verify(envelope, publicKey) + if err != nil { + t.Fatal(err) + } + if string(got) != string(profile) { + t.Fatalf("profile mismatch: got %q want %q", got, profile) + } +} + +func TestSignedProfileRejectsTamperingAndWrongKey(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + envelope, err := Sign([]byte(`{"bundle_id":"bundle-1"}`), privateKey) + if err != nil { + t.Fatal(err) + } + envelope[len(envelope)-1] ^= 1 + if _, err := Verify(envelope, publicKey); err == nil { + t.Fatal("expected tampering rejection") + } + + otherPublic, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + clean, err := Sign([]byte(`{"bundle_id":"bundle-1"}`), privateKey) + if err != nil { + t.Fatal(err) + } + if _, err := Verify(clean, otherPublic); err == nil { + t.Fatal("expected wrong-key rejection") + } +} + +func TestPublicKeyEncoding(t *testing.T) { + publicKey, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + encoded, err := EncodePublicKey(publicKey) + if err != nil { + t.Fatal(err) + } + decoded, err := DecodePublicKey(encoded) + if err != nil { + t.Fatal(err) + } + if !publicKey.Equal(decoded) { + t.Fatal("public key did not round-trip") + } +} diff --git a/betterdesk-support-agent/internal/interoperability/boundary.go b/betterdesk-support-agent/internal/interoperability/boundary.go new file mode 100644 index 00000000..8db0ea19 --- /dev/null +++ b/betterdesk-support-agent/internal/interoperability/boundary.go @@ -0,0 +1,201 @@ +package interoperability + +import ( + "context" + "errors" + "io" + "strings" +) + +// SpecificationRevision identifies the first BetterDesk-owned observed-wire +// specification family. It is a revision label, not a claim that any external +// client's complete protocol has been implemented. +const SpecificationRevision = "bd-desktop-wire-observation/v1" + +// Feature identifies a target-side operation that an adapter may request after +// inbound session authorization. Feature declarations are not evidence that a +// client version or platform supports the operation. +type Feature string + +const ( + FeatureDesktop Feature = "desktop" + FeatureInput Feature = "input" + FeatureAudio Feature = "system_audio" + FeatureClipboard Feature = "clipboard" + FeatureFiles Feature = "files" + FeatureTerminal Feature = "terminal" + FeatureMonitor Feature = "multi_monitor" + FeatureRestart Feature = "restart" +) + +// Descriptor declares the narrow, reviewable scope of a future BetterDesk +// adapter. SupportedFeatures must be backed by a reviewed vector and manual +// lab result before it is exposed in a release. +type Descriptor struct { + ID string + SpecificationRevision string + SupportedFeatures []Feature +} + +// Validate rejects ambiguous adapter declarations before they are registered. +func (d Descriptor) Validate() error { + if strings.TrimSpace(d.ID) == "" || strings.TrimSpace(d.SpecificationRevision) == "" { + return ErrInvalidDescriptor + } + if len(d.SupportedFeatures) == 0 { + return ErrInvalidDescriptor + } + + seen := make(map[Feature]struct{}, len(d.SupportedFeatures)) + for _, feature := range d.SupportedFeatures { + if !knownFeature(feature) { + return ErrInvalidDescriptor + } + if _, duplicate := seen[feature]; duplicate { + return ErrInvalidDescriptor + } + seen[feature] = struct{}{} + } + return nil +} + +// InboundTransport is a transport that has already been accepted by the +// surrounding relay or listener. Its shape intentionally does not expose a +// dialer, listener, or controller-side connection workflow. +type InboundTransport interface { + io.ReadWriteCloser + + // PeerAddress is diagnostic metadata for the accepted peer. Implementations + // must avoid placing credentials, grant material, or packet contents here. + PeerAddress() string +} + +// Adapter is the only wire-level entry point into the native Support Agent +// session path. ServeInbound must fail closed when authorization fails and must +// not retain or log grant presentation material. +// +// Implementations belong in this package tree and must not import the legacy +// signalhost/proto surface as an implementation shortcut. +type Adapter interface { + Descriptor() Descriptor + ServeInbound(context.Context, InboundTransport, SessionAuthorizer) error +} + +// Admission is the transient request an adapter presents to the native passive +// session path after it has identified an inbound peer. GrantPresentation is +// opaque and is intentionally excluded from all result and audit types. +type Admission struct { + SessionID string + OperatorID string + Transport string + RequestedFeatures []Feature + GrantPresentation string +} + +// Validate applies the minimum boundary checks before an authorizer evaluates +// the server grant and local policy. +func (a Admission) Validate() error { + if strings.TrimSpace(a.SessionID) == "" || + strings.TrimSpace(a.OperatorID) == "" || + strings.TrimSpace(a.Transport) == "" || + strings.TrimSpace(a.GrantPresentation) == "" || + len(a.RequestedFeatures) == 0 { + return ErrInvalidAdmission + } + + seen := make(map[Feature]struct{}, len(a.RequestedFeatures)) + for _, feature := range a.RequestedFeatures { + if !knownFeature(feature) { + return ErrInvalidAdmission + } + if _, duplicate := seen[feature]; duplicate { + return ErrInvalidAdmission + } + seen[feature] = struct{}{} + } + return nil +} + +// SessionAuthorizer is implemented by the native passive-session core bridge. +// It owns grant verification, local policy, consent, and session lifecycle; +// the wire adapter only asks to begin an inbound session. +type SessionAuthorizer interface { + AuthorizeInbound(context.Context, Admission) (AuthorizedSession, error) +} + +// AuthorizedSession is the non-secret, target-side result of successful +// authorization. It exposes neither grant material nor a way to initiate a +// connection to another peer. +type AuthorizedSession interface { + ID() string + Allows(Feature) bool + End(context.Context, EndReason) error +} + +// EndReason describes why an inbound session ends without exposing wire +// payloads, credentials, or implementation diagnostics. +type EndReason string + +const ( + EndReasonPeerDisconnected EndReason = "peer_disconnected" + EndReasonLocalDisconnect EndReason = "local_disconnect" + EndReasonPolicyRevoked EndReason = "policy_revoked" + EndReasonTransportError EndReason = "transport_error" +) + +var ( + // ErrInvalidDescriptor indicates an adapter declaration that cannot be + // reviewed against a specific BetterDesk specification revision. + ErrInvalidDescriptor = errors.New("interoperability: invalid adapter descriptor") + // ErrInvalidAdmission indicates an incomplete or ambiguous inbound request. + ErrInvalidAdmission = errors.New("interoperability: invalid inbound admission") +) + +// AuditedSurface identifies a pre-existing compatibility dependency that is +// intentionally outside the independently-owned adapter boundary. +type AuditedSurface struct { + Path string + Role string + Status string +} + +const temporaryAuditedStatus = "temporary_audited" + +// TemporaryAuditedSurfaces returns a fresh inventory of the current legacy +// wire path. Inclusion is an audit marker, not provenance approval or a reason +// for a future adapter to import these packages. +func TemporaryAuditedSurfaces() []AuditedSurface { + return []AuditedSurface{ + { + Path: "betterdesk-support-agent/signalhost", + Role: "legacy desktop-client relay host", + Status: temporaryAuditedStatus, + }, + { + Path: "betterdesk-server/codec", + Role: "legacy wire framing dependency", + Status: temporaryAuditedStatus, + }, + { + Path: "betterdesk-server/proto", + Role: "legacy generated message dependency", + Status: temporaryAuditedStatus, + }, + } +} + +func knownFeature(feature Feature) bool { + switch feature { + case FeatureDesktop, + FeatureInput, + FeatureAudio, + FeatureClipboard, + FeatureFiles, + FeatureTerminal, + FeatureMonitor, + FeatureRestart: + return true + default: + return false + } +} diff --git a/betterdesk-support-agent/internal/interoperability/boundary_test.go b/betterdesk-support-agent/internal/interoperability/boundary_test.go new file mode 100644 index 00000000..8bdf4a80 --- /dev/null +++ b/betterdesk-support-agent/internal/interoperability/boundary_test.go @@ -0,0 +1,79 @@ +package interoperability + +import ( + "context" + "errors" + "testing" +) + +func TestDescriptorAndAdmissionValidation(t *testing.T) { + descriptor := Descriptor{ + ID: "betterdesk-observed-wire", + SpecificationRevision: SpecificationRevision, + SupportedFeatures: []Feature{FeatureDesktop, FeatureInput}, + } + if err := descriptor.Validate(); err != nil { + t.Fatalf("valid descriptor: %v", err) + } + + descriptor.SupportedFeatures = append(descriptor.SupportedFeatures, FeatureInput) + if err := descriptor.Validate(); !errors.Is(err, ErrInvalidDescriptor) { + t.Fatalf("duplicate feature error = %v, want invalid descriptor", err) + } + + admission := Admission{ + SessionID: "session-42", + OperatorID: "operator-42", + Transport: "relay", + RequestedFeatures: []Feature{FeatureDesktop, FeatureInput}, + GrantPresentation: "opaque-grant-presentation", + } + if err := admission.Validate(); err != nil { + t.Fatalf("valid admission: %v", err) + } + + admission.RequestedFeatures = []Feature{"session_initiate"} + if err := admission.Validate(); !errors.Is(err, ErrInvalidAdmission) { + t.Fatalf("outbound feature error = %v, want invalid admission", err) + } +} + +func TestTemporaryAuditedSurfacesAreIndependentInventory(t *testing.T) { + surfaces := TemporaryAuditedSurfaces() + if len(surfaces) != 3 { + t.Fatalf("surface count = %d, want 3", len(surfaces)) + } + for _, surface := range surfaces { + if surface.Path == "" || surface.Role == "" || surface.Status != temporaryAuditedStatus { + t.Fatalf("invalid audited surface: %#v", surface) + } + } + + surfaces[0].Path = "mutated" + if got := TemporaryAuditedSurfaces()[0].Path; got == "mutated" { + t.Fatal("audited surface inventory returned a shared slice") + } +} + +func TestAdapterBoundaryIsInboundOnly(t *testing.T) { + var _ Adapter = testAdapter{} + + adapter := testAdapter{} + if err := adapter.Descriptor().Validate(); err != nil { + t.Fatalf("test adapter descriptor: %v", err) + } +} + +type testAdapter struct{} + +func (testAdapter) Descriptor() Descriptor { + return Descriptor{ + ID: "test-inbound-adapter", + SpecificationRevision: SpecificationRevision, + SupportedFeatures: []Feature{FeatureDesktop}, + } +} + +func (testAdapter) ServeInbound(context.Context, InboundTransport, SessionAuthorizer) error { + return nil +} diff --git a/betterdesk-support-agent/internal/interoperability/conformance.go b/betterdesk-support-agent/internal/interoperability/conformance.go new file mode 100644 index 00000000..412ae38d --- /dev/null +++ b/betterdesk-support-agent/internal/interoperability/conformance.go @@ -0,0 +1,397 @@ +package interoperability + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + "sync" +) + +// DefaultMaximumObservedPayloadBytes bounds a single payload considered by the +// laboratory harness. It is an evidence-capture limit, not a production wire +// protocol limit. +const DefaultMaximumObservedPayloadBytes = 4 << 20 + +// Direction records the observed direction of an opaque wire event. +type Direction string + +const ( + DirectionClientToAgent Direction = "client_to_agent" + DirectionAgentToClient Direction = "agent_to_client" +) + +// Phase is a BetterDesk-owned semantic label assigned by the lab driver. It +// deliberately does not encode external message names or schema fields. +type Phase string + +const ( + PhaseTransportOpened Phase = "transport_opened" + PhaseHandshake Phase = "handshake" + PhaseAuthentication Phase = "authentication" + PhaseCapability Phase = "capability" + PhaseDesktop Phase = "desktop" + PhaseInput Phase = "input" + PhaseRejected Phase = "rejected" + PhaseTransportClosed Phase = "transport_closed" +) + +// VectorKind determines whether an expected payload fingerprint is permitted. +// Black-box vectors intentionally retain only metadata; synthetic vectors may +// fingerprint independently generated, non-secret test bytes. +type VectorKind string + +const ( + VectorKindBlackBox VectorKind = "black_box" + VectorKindSynthetic VectorKind = "synthetic" +) + +// ObservationMode selects how ObserveWire handles the payload supplied by a +// lab driver. Neither mode retains the payload itself. +type ObservationMode string + +const ( + // ObservationMetadataOnly stores direction, phase, and byte count only. + // Use it for all stock-client observations, especially authentication and + // nonce-bearing messages. + ObservationMetadataOnly ObservationMode = "metadata_only" + // ObservationSyntheticFingerprint is permitted only for independently + // generated, non-secret test data. It adds a SHA-256 fingerprint. + ObservationSyntheticFingerprint ObservationMode = "synthetic_fingerprint" +) + +// ExpectedObservation is one ordered, lossy assertion in a conformance vector. +// PayloadFingerprint is optional and may be used only by synthetic vectors. +type ExpectedObservation struct { + Direction Direction + Phase Phase + MinimumBytes int + MaximumBytes int + PayloadFingerprint string +} + +// Vector is a BetterDesk-owned test contract for a single observed behavior. +// It never stores a raw frame, decoded external message, credential, nonce, or +// copied schema. +type Vector struct { + ID string + SpecificationRevision string + Kind VectorKind + Steps []ExpectedObservation +} + +// Validate checks a vector using the default evidence-capture bound. +func (v Vector) Validate() error { + return validateVector(v, DefaultMaximumObservedPayloadBytes) +} + +// Observation is a lossy record emitted by a lab driver. Raw payload bytes are +// intentionally absent so a RunResult can be attached to a release record +// without retaining client traffic. +type Observation struct { + Direction Direction + Phase Phase + PayloadBytes int + PayloadFingerprint string +} + +// ObserveWire creates a bounded, lossy observation and immediately discards +// the payload reference. Callers must use ObservationMetadataOnly for any +// stock-client traffic. +func ObserveWire(direction Direction, phase Phase, payload []byte, mode ObservationMode) (Observation, error) { + return observeWire(direction, phase, payload, mode, DefaultMaximumObservedPayloadBytes) +} + +// ObservationSink receives metadata-only observations from a laboratory probe. +// It deliberately offers no method that accepts or returns a persisted raw +// packet. +type ObservationSink interface { + Record(Observation) error +} + +// BlackBoxProbe drives an isolated client-lab scenario. The probe, client +// binary, credentials, and any temporary packet capture remain outside this +// package and repository. +type BlackBoxProbe interface { + Run(context.Context, Vector, ObservationSink) error +} + +// BlackBoxProbeFunc adapts a function into a BlackBoxProbe. +type BlackBoxProbeFunc func(context.Context, Vector, ObservationSink) error + +// Run implements BlackBoxProbe. +func (f BlackBoxProbeFunc) Run(ctx context.Context, vector Vector, sink ObservationSink) error { + if f == nil { + return ErrNilProbe + } + return f(ctx, vector, sink) +} + +// Harness runs one vector against a supplied lab probe. A successful result +// only means that this vector matched this probe run; it is not a broad +// compatibility or provenance assertion. +type Harness struct { + // MaximumObservedPayloadBytes is optional and defaults to + // DefaultMaximumObservedPayloadBytes. It applies to both vectors and + // observations supplied by the probe. + MaximumObservedPayloadBytes int +} + +// RunResult contains only the vector identifier and lossy observations. +type RunResult struct { + VectorID string + Observations []Observation +} + +// Run validates and executes one vector. It rejects out-of-order events, +// payloads beyond the configured evidence bound, and accidental fingerprints +// in a black-box run. +func (h Harness) Run(ctx context.Context, vector Vector, probe BlackBoxProbe) (RunResult, error) { + if probe == nil { + return RunResult{}, ErrNilProbe + } + if ctx == nil { + ctx = context.Background() + } + + maxBytes := h.maximumObservedPayloadBytes() + checked := cloneVector(vector) + if err := validateVector(checked, maxBytes); err != nil { + return RunResult{}, err + } + + recorder := &runRecorder{ + kind: checked.Kind, + expected: cloneExpectedObservations(checked.Steps), + maxBytes: maxBytes, + } + if err := probe.Run(ctx, cloneVector(checked), recorder); err != nil { + return recorder.result(checked.ID), err + } + if err := recorder.complete(); err != nil { + return recorder.result(checked.ID), err + } + return recorder.result(checked.ID), nil +} + +func (h Harness) maximumObservedPayloadBytes() int { + if h.MaximumObservedPayloadBytes > 0 { + return h.MaximumObservedPayloadBytes + } + return DefaultMaximumObservedPayloadBytes +} + +type runRecorder struct { + mu sync.Mutex + + kind VectorKind + expected []ExpectedObservation + maxBytes int + actual []Observation + failure error +} + +func (r *runRecorder) Record(observation Observation) error { + r.mu.Lock() + defer r.mu.Unlock() + + if r.failure != nil { + return r.failure + } + if err := validateObservation(observation, r.maxBytes); err != nil { + r.failure = err + return err + } + if r.kind == VectorKindBlackBox && observation.PayloadFingerprint != "" { + r.failure = fmt.Errorf("%w: black-box observation contains a payload fingerprint", ErrUnsafeObservation) + return r.failure + } + + index := len(r.actual) + r.actual = append(r.actual, observation) + if index >= len(r.expected) { + r.failure = fmt.Errorf("%w: unexpected %s %s observation", ErrConformanceMismatch, observation.Direction, observation.Phase) + return r.failure + } + if err := r.expected[index].matches(observation); err != nil { + r.failure = err + return err + } + return nil +} + +func (r *runRecorder) complete() error { + r.mu.Lock() + defer r.mu.Unlock() + + if r.failure != nil { + return r.failure + } + if len(r.actual) != len(r.expected) { + return fmt.Errorf("%w: observed %d steps, expected %d", ErrConformanceMismatch, len(r.actual), len(r.expected)) + } + return nil +} + +func (r *runRecorder) result(vectorID string) RunResult { + r.mu.Lock() + defer r.mu.Unlock() + return RunResult{ + VectorID: vectorID, + Observations: append([]Observation(nil), r.actual...), + } +} + +func (step ExpectedObservation) matches(observation Observation) error { + if step.Direction != observation.Direction || step.Phase != observation.Phase { + return fmt.Errorf("%w: expected %s %s, got %s %s", + ErrConformanceMismatch, + step.Direction, + step.Phase, + observation.Direction, + observation.Phase, + ) + } + if observation.PayloadBytes < step.MinimumBytes || observation.PayloadBytes > step.MaximumBytes { + return fmt.Errorf("%w: %s %s payload size %d is outside [%d,%d]", + ErrConformanceMismatch, + step.Direction, + step.Phase, + observation.PayloadBytes, + step.MinimumBytes, + step.MaximumBytes, + ) + } + if step.PayloadFingerprint != "" && step.PayloadFingerprint != observation.PayloadFingerprint { + return fmt.Errorf("%w: synthetic payload fingerprint mismatch", ErrConformanceMismatch) + } + return nil +} + +func observeWire(direction Direction, phase Phase, payload []byte, mode ObservationMode, maxBytes int) (Observation, error) { + if len(payload) > maxBytes { + return Observation{}, fmt.Errorf("%w: payload size %d exceeds %d bytes", ErrUnsafeObservation, len(payload), maxBytes) + } + + observation := Observation{ + Direction: direction, + Phase: phase, + PayloadBytes: len(payload), + } + switch mode { + case ObservationMetadataOnly: + case ObservationSyntheticFingerprint: + sum := sha256.Sum256(payload) + observation.PayloadFingerprint = hex.EncodeToString(sum[:]) + default: + return Observation{}, fmt.Errorf("%w: unknown observation mode %q", ErrUnsafeObservation, mode) + } + if err := validateObservation(observation, maxBytes); err != nil { + return Observation{}, err + } + return observation, nil +} + +func validateVector(vector Vector, maxBytes int) error { + if strings.TrimSpace(vector.ID) == "" || + strings.TrimSpace(vector.SpecificationRevision) == "" || + !knownVectorKind(vector.Kind) || + len(vector.Steps) == 0 || + maxBytes <= 0 { + return ErrInvalidVector + } + + for _, step := range vector.Steps { + if err := validateExpectedObservation(step, maxBytes); err != nil { + return err + } + if vector.Kind == VectorKindBlackBox && step.PayloadFingerprint != "" { + return fmt.Errorf("%w: black-box vectors cannot pin a payload fingerprint", ErrUnsafeObservation) + } + } + return nil +} + +func validateExpectedObservation(step ExpectedObservation, maxBytes int) error { + if !knownDirection(step.Direction) || + !knownPhase(step.Phase) || + step.MinimumBytes < 0 || + step.MaximumBytes < step.MinimumBytes || + step.MaximumBytes > maxBytes { + return ErrInvalidVector + } + if step.PayloadFingerprint != "" { + if !validFingerprint(step.PayloadFingerprint) || step.MinimumBytes != step.MaximumBytes { + return ErrInvalidVector + } + } + return nil +} + +func validateObservation(observation Observation, maxBytes int) error { + if !knownDirection(observation.Direction) || + !knownPhase(observation.Phase) || + observation.PayloadBytes < 0 || + observation.PayloadBytes > maxBytes || + (observation.PayloadFingerprint != "" && !validFingerprint(observation.PayloadFingerprint)) { + return ErrUnsafeObservation + } + return nil +} + +func cloneVector(vector Vector) Vector { + vector.Steps = cloneExpectedObservations(vector.Steps) + return vector +} + +func cloneExpectedObservations(steps []ExpectedObservation) []ExpectedObservation { + return append([]ExpectedObservation(nil), steps...) +} + +func knownDirection(direction Direction) bool { + return direction == DirectionClientToAgent || direction == DirectionAgentToClient +} + +func knownPhase(phase Phase) bool { + switch phase { + case PhaseTransportOpened, + PhaseHandshake, + PhaseAuthentication, + PhaseCapability, + PhaseDesktop, + PhaseInput, + PhaseRejected, + PhaseTransportClosed: + return true + default: + return false + } +} + +func knownVectorKind(kind VectorKind) bool { + return kind == VectorKindBlackBox || kind == VectorKindSynthetic +} + +func validFingerprint(fingerprint string) bool { + if len(fingerprint) != sha256.Size*2 || fingerprint != strings.ToLower(fingerprint) { + return false + } + _, err := hex.DecodeString(fingerprint) + return err == nil +} + +var ( + // ErrInvalidVector indicates a vector that cannot be safely used as + // BetterDesk-owned conformance evidence. + ErrInvalidVector = errors.New("interoperability: invalid conformance vector") + // ErrUnsafeObservation indicates an observation that would exceed the + // evidence boundary or retain unsafe black-box detail. + ErrUnsafeObservation = errors.New("interoperability: unsafe wire observation") + // ErrConformanceMismatch indicates a probe result that differs from its + // declared vector without exposing raw wire payloads. + ErrConformanceMismatch = errors.New("interoperability: conformance mismatch") + // ErrNilProbe indicates a harness run without a lab driver. + ErrNilProbe = errors.New("interoperability: missing black-box probe") +) diff --git a/betterdesk-support-agent/internal/interoperability/conformance_test.go b/betterdesk-support-agent/internal/interoperability/conformance_test.go new file mode 100644 index 00000000..6bd3d860 --- /dev/null +++ b/betterdesk-support-agent/internal/interoperability/conformance_test.go @@ -0,0 +1,176 @@ +package interoperability + +import ( + "context" + "crypto/sha256" + "errors" + "testing" +) + +func TestObserveWireKeepsStockClientObservationsMetadataOnly(t *testing.T) { + payload := []byte("credential-bearing-test-payload") + observation, err := ObserveWire( + DirectionClientToAgent, + PhaseAuthentication, + payload, + ObservationMetadataOnly, + ) + if err != nil { + t.Fatalf("observe metadata: %v", err) + } + if observation.PayloadBytes != len(payload) { + t.Fatalf("payload bytes = %d, want %d", observation.PayloadBytes, len(payload)) + } + if observation.PayloadFingerprint != "" { + t.Fatalf("metadata-only observation retained fingerprint %q", observation.PayloadFingerprint) + } + + synthetic, err := ObserveWire( + DirectionAgentToClient, + PhaseHandshake, + []byte("independently-generated"), + ObservationSyntheticFingerprint, + ) + if err != nil { + t.Fatalf("observe synthetic: %v", err) + } + want := sha256.Sum256([]byte("independently-generated")) + if synthetic.PayloadFingerprint != stringLowerHex(want[:]) { + t.Fatalf("synthetic fingerprint = %q, want SHA-256", synthetic.PayloadFingerprint) + } +} + +func TestHarnessMatchesSyntheticVectorWithoutLeakingPayload(t *testing.T) { + payload := []byte("synthetic-handshake") + observed, err := ObserveWire( + DirectionClientToAgent, + PhaseHandshake, + payload, + ObservationSyntheticFingerprint, + ) + if err != nil { + t.Fatal(err) + } + vector := Vector{ + ID: "synthetic-inbound-handshake", + SpecificationRevision: SpecificationRevision, + Kind: VectorKindSynthetic, + Steps: []ExpectedObservation{{ + Direction: DirectionClientToAgent, + Phase: PhaseHandshake, + MinimumBytes: len(payload), + MaximumBytes: len(payload), + PayloadFingerprint: observed.PayloadFingerprint, + }}, + } + + result, err := Harness{MaximumObservedPayloadBytes: 256}.Run( + context.Background(), + vector, + BlackBoxProbeFunc(func(_ context.Context, supplied Vector, sink ObservationSink) error { + // The probe receives a copy and cannot rewrite the harness's + // expected sequence after validation. + supplied.Steps[0].Phase = PhaseInput + return sink.Record(observed) + }), + ) + if err != nil { + t.Fatalf("run synthetic vector: %v", err) + } + if result.VectorID != vector.ID || len(result.Observations) != 1 { + t.Fatalf("unexpected result: %#v", result) + } + if result.Observations[0].PayloadFingerprint != observed.PayloadFingerprint { + t.Fatal("result did not retain the synthetic test fingerprint") + } +} + +func TestHarnessRejectsUnsafeOrMismatchedBlackBoxEvidence(t *testing.T) { + vector := Vector{ + ID: "black-box-auth-rejection", + SpecificationRevision: SpecificationRevision, + Kind: VectorKindBlackBox, + Steps: []ExpectedObservation{{ + Direction: DirectionClientToAgent, + Phase: PhaseAuthentication, + MinimumBytes: 1, + MaximumBytes: 128, + }}, + } + + _, err := Harness{}.Run(context.Background(), vector, BlackBoxProbeFunc( + func(_ context.Context, _ Vector, sink ObservationSink) error { + observation, observeErr := ObserveWire( + DirectionClientToAgent, + PhaseHandshake, + []byte{1}, + ObservationMetadataOnly, + ) + if observeErr != nil { + return observeErr + } + return sink.Record(observation) + }, + )) + if !errors.Is(err, ErrConformanceMismatch) { + t.Fatalf("mismatched phase error = %v, want conformance mismatch", err) + } + + _, err = Harness{}.Run(context.Background(), vector, BlackBoxProbeFunc( + func(_ context.Context, _ Vector, sink ObservationSink) error { + observation, observeErr := ObserveWire( + DirectionClientToAgent, + PhaseAuthentication, + []byte{1}, + ObservationSyntheticFingerprint, + ) + if observeErr != nil { + return observeErr + } + return sink.Record(observation) + }, + )) + if !errors.Is(err, ErrUnsafeObservation) { + t.Fatalf("black-box fingerprint error = %v, want unsafe observation", err) + } +} + +func TestBlackBoxVectorsCannotPinPayloadFingerprint(t *testing.T) { + vector := Vector{ + ID: "unsafe-black-box-vector", + SpecificationRevision: SpecificationRevision, + Kind: VectorKindBlackBox, + Steps: []ExpectedObservation{{ + Direction: DirectionClientToAgent, + Phase: PhaseHandshake, + MinimumBytes: 1, + MaximumBytes: 1, + PayloadFingerprint: stringLowerHex(make([]byte, sha256.Size)), + }}, + } + if err := vector.Validate(); !errors.Is(err, ErrUnsafeObservation) { + t.Fatalf("black-box fingerprint vector error = %v, want unsafe observation", err) + } +} + +func TestObserveWireRejectsOversizedEvidence(t *testing.T) { + _, err := ObserveWire( + DirectionClientToAgent, + PhaseHandshake, + make([]byte, DefaultMaximumObservedPayloadBytes+1), + ObservationMetadataOnly, + ) + if !errors.Is(err, ErrUnsafeObservation) { + t.Fatalf("oversized evidence error = %v, want unsafe observation", err) + } +} + +func stringLowerHex(value []byte) string { + const hex = "0123456789abcdef" + result := make([]byte, len(value)*2) + for index, current := range value { + result[index*2] = hex[current>>4] + result[index*2+1] = hex[current&0x0f] + } + return string(result) +} diff --git a/betterdesk-support-agent/internal/interoperability/doc.go b/betterdesk-support-agent/internal/interoperability/doc.go new file mode 100644 index 00000000..125e55d8 --- /dev/null +++ b/betterdesk-support-agent/internal/interoperability/doc.go @@ -0,0 +1,18 @@ +// Package interoperability defines the BetterDesk-owned boundary for inbound +// desktop-client wire adapters and their black-box conformance evidence. +// +// It deliberately has no imports from signalhost, betterdesk-server/codec, +// betterdesk-server/proto, or any external desktop-client schema. A future +// adapter implementation receives an already-accepted inbound transport and +// must delegate admission to SessionAuthorizer before it can use target-side +// services. The boundary exposes no dialer or controller-side workflow. +// +// No independently implemented wire adapter is registered by this package. +// The existing signalhost and protocol dependencies remain temporary audited +// compatibility surfaces; TemporaryAuditedSurfaces records their scope without +// making them dependencies of new code. +// +// The conformance harness retains only bounded, lossy wire observations. It is +// suitable for an isolated stock-client lab, not evidence of complete +// compatibility or clean-room provenance on its own. +package interoperability diff --git a/betterdesk-support-agent/internal/sessioncore/core.go b/betterdesk-support-agent/internal/sessioncore/core.go new file mode 100644 index 00000000..58282cf9 --- /dev/null +++ b/betterdesk-support-agent/internal/sessioncore/core.go @@ -0,0 +1,440 @@ +package sessioncore + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" +) + +// Core is a concurrency-safe, single-use passive-session state machine. +// Create a new Core for each enrollment and session attempt. +type Core struct { + mu sync.RWMutex + + audience string + deviceID string + allowedCapabilities []Capability + verifier GrantVerifier + clock func() time.Time + eventSink func(Event) + + state State + operatorID string + sessionID string + capabilities []Capability + events []Event +} + +// New creates a Core in the registered state. +func New(cfg Config) (*Core, error) { + if strings.TrimSpace(cfg.Audience) == "" || strings.TrimSpace(cfg.DeviceID) == "" || cfg.Verifier == nil { + return nil, ErrInvalidConfiguration + } + + allowed, err := validatePassiveCapabilities(cfg.AllowedCapabilities) + if err != nil { + return nil, fmt.Errorf("%w: allowed capabilities", ErrInvalidConfiguration) + } + + clock := cfg.Clock + if clock == nil { + clock = time.Now + } + + c := &Core{ + audience: cfg.Audience, + deviceID: cfg.DeviceID, + allowedCapabilities: allowed, + verifier: cfg.Verifier, + clock: clock, + eventSink: cfg.EventSink, + state: StateRegistered, + } + + c.mu.Lock() + event := c.stateEventLocked(EventRegistered, "", StateRegistered, "") + c.mu.Unlock() + c.emit(event) + + return c, nil +} + +// State returns the current lifecycle state. +func (c *Core) State() State { + c.mu.RLock() + defer c.mu.RUnlock() + return c.state +} + +// Snapshot returns a copy of the current state without grant presentation data. +func (c *Core) Snapshot() Snapshot { + c.mu.RLock() + defer c.mu.RUnlock() + return c.snapshotLocked() +} + +// Events returns copies of all non-secret audit events in chronological order. +func (c *Core) Events() []Event { + c.mu.RLock() + defer c.mu.RUnlock() + + events := make([]Event, len(c.events)) + for i, event := range c.events { + events[i] = cloneEvent(event) + } + return events +} + +// BeginEnrollment marks this registered device as awaiting server approval. +func (c *Core) BeginEnrollment() error { + return c.transition(StateRegistered, StatePending, EventEnrollmentPending) +} + +// ApproveEnrollment records server-side enrollment approval. +func (c *Core) ApproveEnrollment() error { + return c.transition(StatePending, StateApproved, EventEnrollmentApproved) +} + +// Authorize verifies a server grant and, on success, records the effective +// passive capabilities. The opaque grant presentation is never retained. +func (c *Core) Authorize(ctx context.Context, request AdmissionRequest) (Snapshot, error) { + if ctx == nil { + ctx = context.Background() + } + request.RequestedCapabilities = cloneCapabilities(request.RequestedCapabilities) + + if strings.TrimSpace(request.OperatorID) == "" { + return c.rejectAuthorization("invalid_operator", ErrGrantOperator) + } + if strings.TrimSpace(request.SessionID) == "" || strings.TrimSpace(request.Transport) == "" { + return c.rejectAuthorization("invalid_session_binding", ErrGrantVerification) + } + if _, err := validatePassiveCapabilities(request.RequestedCapabilities); err != nil { + return c.rejectAuthorization(authorizationReason(err), err) + } + + c.mu.RLock() + state := c.state + verifier := c.verifier + c.mu.RUnlock() + if state != StateApproved { + return c.rejectAuthorization("invalid_transition", invalidTransition(state, StateAuthorized)) + } + + grant, err := verifier.VerifySessionGrant(ctx, request.GrantPresentation) + if err != nil { + // Do not preserve or expose verifier errors: adapters may include + // presentation details in their diagnostics. + return c.rejectAuthorization("grant_verification_failed", ErrGrantVerification) + } + + return c.completeAuthorization(request, grant) +} + +// RequestConsent moves an authorized session to the local-consent phase. +func (c *Core) RequestConsent() error { + return c.transition(StateAuthorized, StateConsent, EventConsentRequested) +} + +// GrantConsent activates a session after affirmative local consent. +func (c *Core) GrantConsent() error { + return c.transition(StateConsent, StateActive, EventConsentGranted) +} + +// DenyConsent terminates a session only while it awaits local consent. It +// reports false when consent was not pending or the session was already ended. +func (c *Core) DenyConsent() bool { + return c.terminate(EventConsentDenied, "consent_denied", StateConsent) +} + +// Disconnect terminates any non-terminal session because the local user chose +// to disconnect. It is idempotent. +func (c *Core) Disconnect() bool { + return c.terminate(EventLocalDisconnected, "local_disconnect", "") +} + +// Cancel terminates any non-terminal session because local cancellation was +// requested. It is idempotent. +func (c *Core) Cancel() bool { + return c.terminate(EventLocalCancellation, "local_cancellation", "") +} + +// BindCancellation terminates the session when ctx is canceled. The returned +// function stops watching ctx and is safe to call more than once. +func (c *Core) BindCancellation(ctx context.Context) func() { + if ctx == nil { + return func() {} + } + + done := make(chan struct{}) + var once sync.Once + detach := func() { + once.Do(func() { + close(done) + }) + } + + if ctx.Err() != nil { + c.Cancel() + return detach + } + + go func() { + select { + case <-ctx.Done(): + c.Cancel() + case <-done: + } + }() + + return detach +} + +func (c *Core) completeAuthorization(request AdmissionRequest, grant SessionGrant) (Snapshot, error) { + c.mu.Lock() + if c.state != StateApproved { + event := c.stateEventLocked(EventAuthorizationDenied, c.state, c.state, "invalid_transition") + snapshot := c.snapshotLocked() + c.mu.Unlock() + c.emit(event) + return snapshot, invalidTransition(snapshot.State, StateAuthorized) + } + + effective, err := c.validateGrantLocked(request, grant) + if err != nil { + event := c.stateEventLocked(EventAuthorizationDenied, c.state, c.state, authorizationReason(err)) + snapshot := c.snapshotLocked() + c.mu.Unlock() + c.emit(event) + return snapshot, err + } + + from := c.state + c.state = StateAuthorized + c.operatorID = request.OperatorID + c.sessionID = request.SessionID + c.capabilities = effective + event := c.stateEventLocked(EventAuthorized, from, StateAuthorized, "") + snapshot := c.snapshotLocked() + c.mu.Unlock() + c.emit(event) + return snapshot, nil +} + +func (c *Core) validateGrantLocked(request AdmissionRequest, grant SessionGrant) ([]Capability, error) { + if grant.Audience != c.audience { + return nil, ErrGrantAudience + } + if grant.DeviceID != c.deviceID { + return nil, ErrGrantDevice + } + if grant.OperatorID != request.OperatorID { + return nil, ErrGrantOperator + } + if grant.SessionID != request.SessionID { + return nil, ErrGrantVerification + } + if grant.Transport != request.Transport { + return nil, ErrGrantVerification + } + if !grant.ExpiresAt.After(c.clock()) { + return nil, ErrGrantExpired + } + if grant.Initiator != InitiatorOperator { + return nil, ErrPassiveOnly + } + + granted, err := validatePassiveCapabilities(grant.Capabilities) + if err != nil { + return nil, err + } + effective := intersectCapabilities(c.allowedCapabilities, granted, request.RequestedCapabilities) + if len(effective) == 0 { + return nil, ErrGrantCapabilities + } + return effective, nil +} + +func (c *Core) rejectAuthorization(reason string, err error) (Snapshot, error) { + c.mu.Lock() + event := c.stateEventLocked(EventAuthorizationDenied, c.state, c.state, reason) + snapshot := c.snapshotLocked() + c.mu.Unlock() + c.emit(event) + return snapshot, err +} + +func (c *Core) transition(expected, next State, kind EventKind) error { + c.mu.Lock() + if c.state != expected { + event := c.stateEventLocked(EventTransitionDenied, c.state, c.state, "invalid_transition") + from := c.state + c.mu.Unlock() + c.emit(event) + return invalidTransition(from, next) + } + + from := c.state + c.state = next + event := c.stateEventLocked(kind, from, next, "") + c.mu.Unlock() + c.emit(event) + return nil +} + +func (c *Core) terminate(kind EventKind, reason string, required State) bool { + c.mu.Lock() + if c.state == StateTerminated { + c.mu.Unlock() + return false + } + if required != "" && c.state != required { + event := c.stateEventLocked(EventTransitionDenied, c.state, c.state, "invalid_transition") + c.mu.Unlock() + c.emit(event) + return false + } + + from := c.state + c.state = StateTerminated + event := c.stateEventLocked(kind, from, StateTerminated, reason) + c.mu.Unlock() + c.emit(event) + return true +} + +func (c *Core) snapshotLocked() Snapshot { + return Snapshot{ + State: c.state, + DeviceID: c.deviceID, + OperatorID: c.operatorID, + SessionID: c.sessionID, + Capabilities: cloneCapabilities(c.capabilities), + } +} + +func (c *Core) stateEventLocked(kind EventKind, from, to State, reason string) Event { + return c.recordLocked(Event{ + Kind: kind, + From: from, + To: to, + DeviceID: c.deviceID, + OperatorID: c.operatorID, + SessionID: c.sessionID, + Capabilities: c.capabilities, + Reason: reason, + }) +} + +func (c *Core) recordLocked(event Event) Event { + event.At = c.clock().UTC() + event.Capabilities = cloneCapabilities(event.Capabilities) + c.events = append(c.events, event) + return cloneEvent(event) +} + +func (c *Core) emit(event Event) { + if c.eventSink != nil { + c.eventSink(cloneEvent(event)) + } +} + +func validatePassiveCapabilities(capabilities []Capability) ([]Capability, error) { + if len(capabilities) == 0 { + return nil, ErrGrantCapabilities + } + + seen := make(map[Capability]struct{}, len(capabilities)) + result := make([]Capability, 0, len(capabilities)) + for _, capability := range capabilities { + if !isPassiveCapability(capability) { + return nil, ErrPassiveOnly + } + if _, duplicate := seen[capability]; duplicate { + return nil, ErrGrantCapabilities + } + seen[capability] = struct{}{} + result = append(result, capability) + } + return result, nil +} + +func isPassiveCapability(capability Capability) bool { + switch capability { + case CapabilityScreenView, + CapabilityInput, + CapabilitySystemAudio, + CapabilityClipboard, + CapabilityFiles, + CapabilityTerminal, + CapabilityChat, + CapabilityMultiMonitor, + CapabilityPrivacyMode, + CapabilityBlockInput, + CapabilityRestart, + CapabilityRecording: + return true + default: + return false + } +} + +func intersectCapabilities(allowed, granted, requested []Capability) []Capability { + grantedSet := make(map[Capability]struct{}, len(granted)) + for _, capability := range granted { + grantedSet[capability] = struct{}{} + } + requestedSet := make(map[Capability]struct{}, len(requested)) + for _, capability := range requested { + requestedSet[capability] = struct{}{} + } + + effective := make([]Capability, 0, len(allowed)) + for _, capability := range allowed { + if _, grantAllows := grantedSet[capability]; !grantAllows { + continue + } + if _, requestedByOperator := requestedSet[capability]; !requestedByOperator { + continue + } + effective = append(effective, capability) + } + return effective +} + +func cloneCapabilities(capabilities []Capability) []Capability { + return append([]Capability(nil), capabilities...) +} + +func cloneEvent(event Event) Event { + event.Capabilities = cloneCapabilities(event.Capabilities) + return event +} + +func invalidTransition(from, to State) error { + return fmt.Errorf("%w: %s -> %s", ErrInvalidTransition, from, to) +} + +func authorizationReason(err error) string { + switch { + case errors.Is(err, ErrGrantVerification): + return "grant_verification_failed" + case errors.Is(err, ErrGrantAudience): + return "grant_audience_mismatch" + case errors.Is(err, ErrGrantDevice): + return "grant_device_mismatch" + case errors.Is(err, ErrGrantOperator): + return "grant_operator_mismatch" + case errors.Is(err, ErrGrantExpired): + return "grant_expired" + case errors.Is(err, ErrPassiveOnly): + return "passive_policy_violation" + case errors.Is(err, ErrGrantCapabilities): + return "grant_capability_mismatch" + default: + return "authorization_denied" + } +} diff --git a/betterdesk-support-agent/internal/sessioncore/core_test.go b/betterdesk-support-agent/internal/sessioncore/core_test.go new file mode 100644 index 00000000..79bc76bb --- /dev/null +++ b/betterdesk-support-agent/internal/sessioncore/core_test.go @@ -0,0 +1,475 @@ +package sessioncore + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "reflect" + "strings" + "sync" + "testing" + "time" +) + +var fixedNow = time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) + +func TestLifecycleTransitionsCapabilityIntersectionAndAudit(t *testing.T) { + const presentation = "opaque-grant-that-must-not-be-audited" + var verifierPresentation string + core := newCore(t, GrantVerifierFunc(func(_ context.Context, got string) (SessionGrant, error) { + verifierPresentation = got + return validGrant(), nil + })) + + if got := core.State(); got != StateRegistered { + t.Fatalf("initial state = %q, want %q", got, StateRegistered) + } + if err := core.BeginEnrollment(); err != nil { + t.Fatalf("begin enrollment: %v", err) + } + if err := core.ApproveEnrollment(); err != nil { + t.Fatalf("approve enrollment: %v", err) + } + + snapshot, err := core.Authorize(context.Background(), AdmissionRequest{ + OperatorID: "operator-42", + SessionID: "session-1", + Transport: "relay", + RequestedCapabilities: []Capability{CapabilityScreenView}, + GrantPresentation: presentation, + }) + if err != nil { + t.Fatalf("authorize: %v", err) + } + if verifierPresentation != presentation { + t.Fatalf("verifier received %q, want presentation", verifierPresentation) + } + if snapshot.State != StateAuthorized { + t.Fatalf("authorized state = %q, want %q", snapshot.State, StateAuthorized) + } + if want := []Capability{CapabilityScreenView}; !reflect.DeepEqual(snapshot.Capabilities, want) { + t.Fatalf("effective capabilities = %v, want %v", snapshot.Capabilities, want) + } + + if err := core.RequestConsent(); err != nil { + t.Fatalf("request consent: %v", err) + } + if err := core.GrantConsent(); err != nil { + t.Fatalf("grant consent: %v", err) + } + if !core.Disconnect() { + t.Fatal("first disconnect should terminate the session") + } + if core.Disconnect() { + t.Fatal("second disconnect should be idempotent") + } + if got := core.State(); got != StateTerminated { + t.Fatalf("final state = %q, want %q", got, StateTerminated) + } + + events := core.Events() + if len(events) != 7 { + t.Fatalf("events = %d, want 7", len(events)) + } + last := events[len(events)-1] + if last.Kind != EventLocalDisconnected || last.From != StateActive || last.To != StateTerminated { + t.Fatalf("last event = %#v, want active local disconnect", last) + } + + encoded, err := json.Marshal(events) + if err != nil { + t.Fatalf("marshal events: %v", err) + } + if strings.Contains(string(encoded), presentation) { + t.Fatal("audit events contain grant presentation") + } +} + +func TestAuthorizeRejectsInvalidGrants(t *testing.T) { + tests := []struct { + name string + grant SessionGrant + err error + reason string + }{ + { + name: "wrong audience", + grant: func() SessionGrant { + grant := validGrant() + grant.Audience = "other-audience" + return grant + }(), + err: ErrGrantAudience, + reason: "grant_audience_mismatch", + }, + { + name: "wrong device", + grant: func() SessionGrant { + grant := validGrant() + grant.DeviceID = "other-device" + return grant + }(), + err: ErrGrantDevice, + reason: "grant_device_mismatch", + }, + { + name: "wrong operator", + grant: func() SessionGrant { + grant := validGrant() + grant.OperatorID = "other-operator" + return grant + }(), + err: ErrGrantOperator, + reason: "grant_operator_mismatch", + }, + { + name: "expired", + grant: func() SessionGrant { + grant := validGrant() + grant.ExpiresAt = fixedNow + return grant + }(), + err: ErrGrantExpired, + reason: "grant_expired", + }, + { + name: "non-operator initiator", + grant: func() SessionGrant { + grant := validGrant() + grant.Initiator = SessionInitiator("support_agent") + return grant + }(), + err: ErrPassiveOnly, + reason: "passive_policy_violation", + }, + { + name: "unknown capability", + grant: func() SessionGrant { + grant := validGrant() + grant.Capabilities = []Capability{"session_initiate"} + return grant + }(), + err: ErrPassiveOnly, + reason: "passive_policy_violation", + }, + { + name: "no capability intersection", + grant: func() SessionGrant { + grant := validGrant() + grant.Capabilities = []Capability{CapabilitySystemAudio} + return grant + }(), + err: ErrGrantCapabilities, + reason: "grant_capability_mismatch", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + core := approvedCore(t, GrantVerifierFunc(func(context.Context, string) (SessionGrant, error) { + return test.grant, nil + })) + + _, err := core.Authorize(context.Background(), AdmissionRequest{ + OperatorID: "operator-42", + SessionID: "session-1", + Transport: "relay", + RequestedCapabilities: []Capability{CapabilityScreenView}, + GrantPresentation: "opaque-presentation", + }) + if !errors.Is(err, test.err) { + t.Fatalf("authorize error = %v, want %v", err, test.err) + } + if got := core.State(); got != StateApproved { + t.Fatalf("state after failed authorization = %q, want %q", got, StateApproved) + } + + events := core.Events() + last := events[len(events)-1] + if last.Kind != EventAuthorizationDenied || last.Reason != test.reason { + t.Fatalf("last event = %#v, want authorization denial %q", last, test.reason) + } + }) + } +} + +func TestInboundGrantCanAuthorizeRemoteControlCapabilities(t *testing.T) { + grant := validGrant() + grant.Capabilities = []Capability{CapabilityScreenView, CapabilityInput, CapabilityFiles} + core, err := New(Config{ + Audience: "betterdesk-support", + DeviceID: "BD-DEVICE-1", + AllowedCapabilities: []Capability{CapabilityScreenView, CapabilityInput, CapabilityFiles}, + Verifier: GrantVerifierFunc(func(context.Context, string) (SessionGrant, error) { + return grant, nil + }), + Clock: func() time.Time { return fixedNow }, + }) + if err != nil { + t.Fatal(err) + } + if err := core.BeginEnrollment(); err != nil { + t.Fatal(err) + } + if err := core.ApproveEnrollment(); err != nil { + t.Fatal(err) + } + snapshot, err := core.Authorize(context.Background(), AdmissionRequest{ + OperatorID: "operator-42", + SessionID: "session-1", + Transport: "relay", + RequestedCapabilities: []Capability{CapabilityScreenView, CapabilityInput, CapabilityFiles}, + GrantPresentation: "opaque-presentation", + }) + if err != nil { + t.Fatal(err) + } + want := []Capability{CapabilityScreenView, CapabilityInput, CapabilityFiles} + if !reflect.DeepEqual(snapshot.Capabilities, want) { + t.Fatalf("effective capabilities = %v, want %v", snapshot.Capabilities, want) + } +} + +func TestEd25519GrantVerifierRejectsAlteredOrOutboundGrants(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + verifier, err := NewEd25519GrantVerifier(base64.StdEncoding.EncodeToString(publicKey)) + if err != nil { + t.Fatal(err) + } + verifier.now = func() time.Time { return fixedNow } + + claims := signedGrantClaims{ + Version: 1, + Audience: "betterdesk-support", + DeviceID: "BD-DEVICE-1", + OperatorID: "operator-42", + SessionID: "session-1", + Transport: "relay", + Initiator: "operator", + Capabilities: []string{"screen_view", "input"}, + IssuedAt: fixedNow.Unix(), + ExpiresAt: fixedNow.Add(time.Minute).Unix(), + } + token := signTestGrant(t, privateKey, claims) + grant, err := verifier.VerifySessionGrant(context.Background(), token) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(grant.Capabilities, []Capability{CapabilityScreenView, CapabilityInput}) { + t.Fatalf("decoded capabilities = %#v", grant.Capabilities) + } + + claims.Initiator = "support_agent" + if _, err := verifier.VerifySessionGrant(context.Background(), signTestGrant(t, privateKey, claims)); !errors.Is(err, ErrGrantVerification) { + t.Fatalf("outbound initiator error = %v, want verification error", err) + } + + parts := strings.Split(token, ".") + replacement := byte('A') + if parts[1][0] == replacement { + replacement = 'B' + } + parts[1] = string(replacement) + parts[1][1:] + tampered := strings.Join(parts, ".") + if _, err := verifier.VerifySessionGrant(context.Background(), tampered); !errors.Is(err, ErrGrantVerification) { + t.Fatalf("tampered grant error = %v, want verification error", err) + } +} + +func TestAuthorizeDoesNotExposeVerifierDetails(t *testing.T) { + const secret = "grant-presentation-must-not-leak" + core := approvedCore(t, GrantVerifierFunc(func(context.Context, string) (SessionGrant, error) { + return SessionGrant{}, errors.New(secret) + })) + + _, err := core.Authorize(context.Background(), AdmissionRequest{ + OperatorID: "operator-42", + SessionID: "session-1", + Transport: "relay", + RequestedCapabilities: []Capability{CapabilityScreenView}, + GrantPresentation: secret, + }) + if !errors.Is(err, ErrGrantVerification) { + t.Fatalf("authorize error = %v, want %v", err, ErrGrantVerification) + } + if strings.Contains(err.Error(), secret) { + t.Fatal("authorization error exposes verifier detail") + } + + encoded, marshalErr := json.Marshal(core.Events()) + if marshalErr != nil { + t.Fatalf("marshal events: %v", marshalErr) + } + if strings.Contains(string(encoded), secret) { + t.Fatal("audit events expose verifier detail or presentation") + } +} + +func TestStateMachineRejectsSkippedTransitions(t *testing.T) { + core := newCore(t, GrantVerifierFunc(func(context.Context, string) (SessionGrant, error) { + return validGrant(), nil + })) + + if err := core.ApproveEnrollment(); !errors.Is(err, ErrInvalidTransition) { + t.Fatalf("approve before pending error = %v, want %v", err, ErrInvalidTransition) + } + if _, err := core.Authorize(context.Background(), AdmissionRequest{ + OperatorID: "operator-42", + SessionID: "session-1", + Transport: "relay", + RequestedCapabilities: []Capability{CapabilityScreenView}, + GrantPresentation: "opaque-presentation", + }); !errors.Is(err, ErrInvalidTransition) { + t.Fatalf("authorize before approval error = %v, want %v", err, ErrInvalidTransition) + } +} + +func TestDisconnectIsConcurrencySafeAndIdempotent(t *testing.T) { + core := activeCore(t) + + const callers = 32 + results := make(chan bool, callers) + start := make(chan struct{}) + var wait sync.WaitGroup + for range callers { + wait.Add(1) + go func() { + defer wait.Done() + <-start + results <- core.Disconnect() + }() + } + close(start) + wait.Wait() + close(results) + + terminated := 0 + for result := range results { + if result { + terminated++ + } + } + if terminated != 1 { + t.Fatalf("successful disconnects = %d, want 1", terminated) + } + if got := core.State(); got != StateTerminated { + t.Fatalf("state = %q, want %q", got, StateTerminated) + } + + disconnectEvents := 0 + for _, event := range core.Events() { + if event.Kind == EventLocalDisconnected { + disconnectEvents++ + } + } + if disconnectEvents != 1 { + t.Fatalf("disconnect events = %d, want 1", disconnectEvents) + } +} + +func TestBindCancellationTerminatesActiveSession(t *testing.T) { + core := activeCore(t) + ctx, cancel := context.WithCancel(context.Background()) + detach := core.BindCancellation(ctx) + defer detach() + + cancel() + deadline := time.Now().Add(time.Second) + for core.State() != StateTerminated && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if got := core.State(); got != StateTerminated { + t.Fatalf("state after cancellation = %q, want %q", got, StateTerminated) + } + + events := core.Events() + last := events[len(events)-1] + if last.Kind != EventLocalCancellation || last.Reason != "local_cancellation" { + t.Fatalf("last event = %#v, want local cancellation", last) + } +} + +func newCore(t *testing.T, verifier GrantVerifier) *Core { + t.Helper() + core, err := New(Config{ + Audience: "betterdesk-support", + DeviceID: "BD-DEVICE-1", + AllowedCapabilities: []Capability{CapabilityScreenView, CapabilitySystemAudio}, + Verifier: verifier, + Clock: func() time.Time { + return fixedNow + }, + }) + if err != nil { + t.Fatalf("new core: %v", err) + } + return core +} + +func approvedCore(t *testing.T, verifier GrantVerifier) *Core { + t.Helper() + core := newCore(t, verifier) + if err := core.BeginEnrollment(); err != nil { + t.Fatalf("begin enrollment: %v", err) + } + if err := core.ApproveEnrollment(); err != nil { + t.Fatalf("approve enrollment: %v", err) + } + return core +} + +func activeCore(t *testing.T) *Core { + t.Helper() + core := approvedCore(t, GrantVerifierFunc(func(context.Context, string) (SessionGrant, error) { + return validGrant(), nil + })) + if _, err := core.Authorize(context.Background(), AdmissionRequest{ + OperatorID: "operator-42", + SessionID: "session-1", + Transport: "relay", + RequestedCapabilities: []Capability{CapabilityScreenView}, + GrantPresentation: "opaque-presentation", + }); err != nil { + t.Fatalf("authorize: %v", err) + } + if err := core.RequestConsent(); err != nil { + t.Fatalf("request consent: %v", err) + } + if err := core.GrantConsent(); err != nil { + t.Fatalf("grant consent: %v", err) + } + return core +} + +func validGrant() SessionGrant { + return SessionGrant{ + Audience: "betterdesk-support", + DeviceID: "BD-DEVICE-1", + OperatorID: "operator-42", + SessionID: "session-1", + Transport: "relay", + Capabilities: []Capability{CapabilityScreenView, CapabilitySystemAudio}, + ExpiresAt: fixedNow.Add(time.Minute), + Initiator: InitiatorOperator, + } +} + +func signTestGrant(t *testing.T, privateKey ed25519.PrivateKey, claims signedGrantClaims) string { + t.Helper() + payload, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + signature := ed25519.Sign(privateKey, payload) + return strings.Join([]string{ + signedGrantVersion, + base64.RawURLEncoding.EncodeToString(payload), + base64.RawURLEncoding.EncodeToString(signature), + }, ".") +} diff --git a/betterdesk-support-agent/internal/sessioncore/doc.go b/betterdesk-support-agent/internal/sessioncore/doc.go new file mode 100644 index 00000000..b07b0f82 --- /dev/null +++ b/betterdesk-support-agent/internal/sessioncore/doc.go @@ -0,0 +1,7 @@ +// Package sessioncore provides the transport-agnostic, passive-session +// lifecycle used by BetterDesk support targets. +// +// A Core represents one enrollment and support-session attempt. It intentionally +// owns policy and state only: callers supply server-grant verification and wire +// approved sessions to their transport separately. +package sessioncore diff --git a/betterdesk-support-agent/internal/sessioncore/signed_grant.go b/betterdesk-support-agent/internal/sessioncore/signed_grant.go new file mode 100644 index 00000000..46af69aa --- /dev/null +++ b/betterdesk-support-agent/internal/sessioncore/signed_grant.go @@ -0,0 +1,112 @@ +package sessioncore + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" +) + +const signedGrantVersion = "v1" +const maxGrantLifetime = 10 * time.Minute + +// Ed25519GrantVerifier validates the compact signed grant envelope emitted by +// BetterDesk Server. It is implemented in the Support Agent without importing +// server packages, so the native session core retains a narrow, auditable +// verification boundary. +type Ed25519GrantVerifier struct { + publicKey ed25519.PublicKey + now func() time.Time +} + +// NewEd25519GrantVerifier parses a base64 Ed25519 public key. Both padded and +// unpadded standard base64 are accepted for operational copy/paste safety. +func NewEd25519GrantVerifier(encodedPublicKey string) (*Ed25519GrantVerifier, error) { + raw, err := decodeEd25519PublicKey(encodedPublicKey) + if err != nil { + return nil, err + } + return &Ed25519GrantVerifier{publicKey: ed25519.PublicKey(raw), now: time.Now}, nil +} + +// VerifySessionGrant implements GrantVerifier. +func (v *Ed25519GrantVerifier) VerifySessionGrant(_ context.Context, presentation string) (SessionGrant, error) { + if v == nil || len(v.publicKey) != ed25519.PublicKeySize { + return SessionGrant{}, ErrGrantVerification + } + parts := strings.Split(presentation, ".") + if len(parts) != 3 || parts[0] != signedGrantVersion { + return SessionGrant{}, ErrGrantVerification + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return SessionGrant{}, ErrGrantVerification + } + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(v.publicKey, payload, signature) { + return SessionGrant{}, ErrGrantVerification + } + + var claims signedGrantClaims + if err := json.Unmarshal(payload, &claims); err != nil { + return SessionGrant{}, ErrGrantVerification + } + if claims.Version != 1 || strings.TrimSpace(claims.Audience) == "" || + strings.TrimSpace(claims.DeviceID) == "" || strings.TrimSpace(claims.OperatorID) == "" || + strings.TrimSpace(claims.SessionID) == "" || strings.TrimSpace(claims.Transport) == "" || + strings.TrimSpace(claims.Initiator) != string(InitiatorOperator) { + return SessionGrant{}, ErrGrantVerification + } + now := v.now().UTC().Unix() + if claims.IssuedAt <= 0 || claims.ExpiresAt <= claims.IssuedAt || + claims.IssuedAt > now+30 || claims.ExpiresAt-claims.IssuedAt > int64(maxGrantLifetime/time.Second) { + return SessionGrant{}, ErrGrantVerification + } + if claims.ExpiresAt <= now { + return SessionGrant{}, ErrGrantExpired + } + capabilities := make([]Capability, 0, len(claims.Capabilities)) + for _, capability := range claims.Capabilities { + capabilities = append(capabilities, Capability(strings.TrimSpace(strings.ToLower(capability)))) + } + if _, err := validatePassiveCapabilities(capabilities); err != nil { + return SessionGrant{}, err + } + return SessionGrant{ + Audience: strings.TrimSpace(claims.Audience), + DeviceID: strings.TrimSpace(claims.DeviceID), + OperatorID: strings.TrimSpace(claims.OperatorID), + SessionID: strings.TrimSpace(claims.SessionID), + Transport: strings.TrimSpace(strings.ToLower(claims.Transport)), + Capabilities: capabilities, + ExpiresAt: time.Unix(claims.ExpiresAt, 0).UTC(), + Initiator: InitiatorOperator, + }, nil +} + +type signedGrantClaims struct { + Version int `json:"v"` + Audience string `json:"aud"` + DeviceID string `json:"device_id"` + OperatorID string `json:"operator_id"` + SessionID string `json:"session_id"` + Transport string `json:"transport"` + Initiator string `json:"initiator"` + Capabilities []string `json:"capabilities"` + IssuedAt int64 `json:"iat"` + ExpiresAt int64 `json:"exp"` +} + +func decodeEd25519PublicKey(encoded string) ([]byte, error) { + encoded = strings.TrimSpace(encoded) + for _, decoder := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding} { + raw, err := decoder.DecodeString(encoded) + if err == nil && len(raw) == ed25519.PublicKeySize { + return raw, nil + } + } + return nil, fmt.Errorf("invalid session-grant Ed25519 public key") +} diff --git a/betterdesk-support-agent/internal/sessioncore/types.go b/betterdesk-support-agent/internal/sessioncore/types.go new file mode 100644 index 00000000..4a41c489 --- /dev/null +++ b/betterdesk-support-agent/internal/sessioncore/types.go @@ -0,0 +1,186 @@ +package sessioncore + +import ( + "context" + "errors" + "time" +) + +// State is the current phase of a passive BetterDesk support session. +type State string + +const ( + // StateRegistered is the initial state for a locally known device. + StateRegistered State = "registered" + // StatePending means the device is awaiting server-side enrollment approval. + StatePending State = "pending" + // StateApproved means device enrollment has been approved. + StateApproved State = "approved" + // StateAuthorized means a verified server grant authorized an operator. + StateAuthorized State = "authorized" + // StateConsent means the locally present user must decide whether to continue. + StateConsent State = "consent" + // StateActive means the passive session may be connected to its transport. + StateActive State = "active" + // StateTerminated is final for a Core instance. + StateTerminated State = "terminated" +) + +// Capability describes an operation allowed in a passive support session. +// "Passive" constrains who may initiate the session; it does not turn an +// approved remote-support session into a view-only stream. +type Capability string + +const ( + // CapabilityScreenView allows the device display to be sent to the operator. + CapabilityScreenView Capability = "screen_view" + // CapabilityInput allows the approved operator to inject desktop input. + CapabilityInput Capability = "input" + // CapabilitySystemAudio allows device audio to be sent to the operator. + CapabilitySystemAudio Capability = "system_audio" + // CapabilityClipboard allows clipboard synchronization for the session. + CapabilityClipboard Capability = "clipboard" + // CapabilityFiles allows file transfer subject to the local file policy. + CapabilityFiles Capability = "files" + // CapabilityTerminal allows terminal sessions subject to local consent. + CapabilityTerminal Capability = "terminal" + // CapabilityChat allows session chat. + CapabilityChat Capability = "chat" + // CapabilityMultiMonitor allows display selection. + CapabilityMultiMonitor Capability = "multi_monitor" + // CapabilityPrivacyMode enables only a platform-supported privacy mode. + CapabilityPrivacyMode Capability = "privacy_mode" + // CapabilityBlockInput enables only a platform-supported local-input block. + CapabilityBlockInput Capability = "block_input" + // CapabilityRestart permits a policy-controlled restart request. + CapabilityRestart Capability = "restart" + // CapabilityRecording permits policy-controlled recording. + CapabilityRecording Capability = "recording" +) + +// SessionInitiator identifies the party permitted to create a session. It +// says nothing about approved input or response traffic inside that session. +type SessionInitiator string + +const ( + // InitiatorOperator means an operator/controller connected to the passive + // Support Agent. Support Agent itself may never be the initiator. + InitiatorOperator SessionInitiator = "operator" +) + +// SessionGrant is the verified, non-secret claim set from a BetterDesk server. +// Grant presentation material is intentionally not retained in this type. +type SessionGrant struct { + Audience string + DeviceID string + OperatorID string + SessionID string + Transport string + Capabilities []Capability + ExpiresAt time.Time + Initiator SessionInitiator +} + +// AdmissionRequest is an operator's request to begin a passive session. +// GrantPresentation is opaque to Core, passed only to GrantVerifier, and never +// stored or included in an Event. +type AdmissionRequest struct { + OperatorID string + SessionID string + Transport string + RequestedCapabilities []Capability + GrantPresentation string +} + +// GrantVerifier validates a server-issued grant and returns its verified claims. +// Core then binds those claims to its configured audience and device, the +// requesting operator, passive capabilities, and the current time. +type GrantVerifier interface { + VerifySessionGrant(context.Context, string) (SessionGrant, error) +} + +// GrantVerifierFunc adapts a function into a GrantVerifier. +type GrantVerifierFunc func(context.Context, string) (SessionGrant, error) + +// VerifySessionGrant implements GrantVerifier. +func (f GrantVerifierFunc) VerifySessionGrant(ctx context.Context, presentation string) (SessionGrant, error) { + if f == nil { + return SessionGrant{}, ErrGrantVerification + } + return f(ctx, presentation) +} + +// EventKind identifies a non-secret audit event emitted by Core. +type EventKind string + +const ( + EventRegistered EventKind = "registered" + EventEnrollmentPending EventKind = "enrollment_pending" + EventEnrollmentApproved EventKind = "enrollment_approved" + EventAuthorized EventKind = "authorized" + EventAuthorizationDenied EventKind = "authorization_denied" + EventConsentRequested EventKind = "consent_requested" + EventConsentGranted EventKind = "consent_granted" + EventConsentDenied EventKind = "consent_denied" + EventLocalDisconnected EventKind = "local_disconnected" + EventLocalCancellation EventKind = "local_cancellation" + EventTransitionDenied EventKind = "transition_denied" +) + +// Event is an auditable state-machine record. It deliberately contains no +// grant presentation, credential, token, or verifier error payload. +type Event struct { + At time.Time + Kind EventKind + From State + To State + DeviceID string + OperatorID string + SessionID string + Capabilities []Capability + Reason string +} + +// Snapshot is a concurrency-safe copy of the current public session state. +type Snapshot struct { + State State + DeviceID string + OperatorID string + SessionID string + Capabilities []Capability +} + +// Config binds a Core to one BetterDesk device and audience. +type Config struct { + Audience string + DeviceID string + AllowedCapabilities []Capability + Verifier GrantVerifier + + // Clock is optional and defaults to time.Now. + Clock func() time.Time + // EventSink is optional. It receives copies of events after Core records them. + EventSink func(Event) +} + +var ( + // ErrInvalidConfiguration indicates a Core configuration that cannot enforce + // passive-session policy. + ErrInvalidConfiguration = errors.New("sessioncore: invalid configuration") + // ErrInvalidTransition indicates an action that is not valid for the current state. + ErrInvalidTransition = errors.New("sessioncore: invalid state transition") + // ErrGrantVerification indicates that the injected verifier rejected a grant. + ErrGrantVerification = errors.New("sessioncore: grant verification failed") + // ErrGrantAudience indicates a grant for another BetterDesk audience. + ErrGrantAudience = errors.New("sessioncore: grant audience mismatch") + // ErrGrantDevice indicates a grant for another device. + ErrGrantDevice = errors.New("sessioncore: grant device mismatch") + // ErrGrantOperator indicates a grant that is not bound to the requesting operator. + ErrGrantOperator = errors.New("sessioncore: grant operator mismatch") + // ErrGrantExpired indicates an expired or otherwise unusable grant. + ErrGrantExpired = errors.New("sessioncore: grant expired") + // ErrGrantCapabilities indicates a grant or request with no permitted overlap. + ErrGrantCapabilities = errors.New("sessioncore: grant capability mismatch") + // ErrPassiveOnly indicates a direction or capability that violates passive mode. + ErrPassiveOnly = errors.New("sessioncore: passive sessions are one-way only") +) diff --git a/betterdesk-support-agent/internal/totp/totp.go b/betterdesk-support-agent/internal/totp/totp.go new file mode 100644 index 00000000..60055126 --- /dev/null +++ b/betterdesk-support-agent/internal/totp/totp.go @@ -0,0 +1,63 @@ +// Package totp implements the small RFC 6238 verifier needed by the Support +// Agent. It intentionally has no dependency on BetterDesk server packages. +package totp + +import ( + "crypto/hmac" + "crypto/sha1" // RFC 6238's required SHA-1 interoperability profile. + "crypto/subtle" + "encoding/base32" + "encoding/binary" + "fmt" + "strings" + "time" +) + +const ( + digits = 6 + period = 30 * time.Second +) + +// Validate accepts a six-digit TOTP code in the current 30-second window, +// with one adjacent window tolerated for normal clock drift. +func Validate(secret, code string, now time.Time) bool { + if len(code) != digits { + return false + } + for offset := -1; offset <= 1; offset++ { + expected, err := codeAt(secret, now.Add(time.Duration(offset)*period)) + if err != nil { + continue + } + if subtle.ConstantTimeCompare([]byte(expected), []byte(code)) == 1 { + return true + } + } + return false +} + +func codeAt(secret string, at time.Time) (string, error) { + key, err := decodeSecret(secret) + if err != nil { + return "", err + } + var counter [8]byte + binary.BigEndian.PutUint64(counter[:], uint64(at.Unix()/int64(period/time.Second))) + mac := hmac.New(sha1.New, key) + _, _ = mac.Write(counter[:]) + sum := mac.Sum(nil) + offset := int(sum[len(sum)-1] & 0x0f) + if offset+4 > len(sum) { + return "", fmt.Errorf("invalid TOTP digest") + } + value := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7fffffff + return fmt.Sprintf("%06d", value%1_000_000), nil +} + +func decodeSecret(secret string) ([]byte, error) { + normalized := strings.TrimRight(strings.ToUpper(strings.TrimSpace(secret)), "=") + if normalized == "" { + return nil, fmt.Errorf("empty TOTP secret") + } + return base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(normalized) +} diff --git a/betterdesk-support-agent/internal/totp/totp_test.go b/betterdesk-support-agent/internal/totp/totp_test.go new file mode 100644 index 00000000..6913078f --- /dev/null +++ b/betterdesk-support-agent/internal/totp/totp_test.go @@ -0,0 +1,19 @@ +package totp + +import ( + "testing" + "time" +) + +func TestValidateRFC6238SHA1SixDigitProfile(t *testing.T) { + // RFC 6238 Appendix B: SHA-1 secret at T=59 produces 94287082. The + // BetterDesk Support Agent uses its last six digits, 287082. + const secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + at := time.Unix(59, 0) + if !Validate(secret, "287082", at) { + t.Fatal("valid RFC 6238 code was rejected") + } + if Validate(secret, "287083", at) { + t.Fatal("invalid code was accepted") + } +} diff --git a/betterdesk-support-agent/netcheck.go b/betterdesk-support-agent/netcheck.go index 1637c1c3..f706fe98 100644 --- a/betterdesk-support-agent/netcheck.go +++ b/betterdesk-support-agent/netcheck.go @@ -109,12 +109,7 @@ func httpGet(endpoint string) ([]byte, time.Duration, error) { return nil, 0, err } - client := &http.Client{Timeout: 8 * time.Second} - if strings.HasPrefix(endpoint, "https://") { - client.Transport = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: tlsInsecureEnabled()}, //nolint:gosec - } - } + client := healthHTTPClient(endpoint) start := time.Now() resp, err := client.Do(req) @@ -138,3 +133,13 @@ func httpGet(endpoint string) ([]byte, time.Duration, error) { } return buf, latency, nil } + +func healthHTTPClient(endpoint string) *http.Client { + client := &http.Client{Timeout: 8 * time.Second} + if strings.HasPrefix(endpoint, "https://") && tlsInsecureEnabled() { + client.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // opt-in dev only + } + } + return client +} diff --git a/betterdesk-support-agent/passive_session.go b/betterdesk-support-agent/passive_session.go new file mode 100644 index 00000000..f8caf3c6 --- /dev/null +++ b/betterdesk-support-agent/passive_session.go @@ -0,0 +1,182 @@ +package main + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/unitronix/betterdesk-support-agent/internal/sessioncore" +) + +const supportSessionGrantAudience = "betterdesk-support-agent" + +// passiveSessionAuthorizer bridges the generic embedded CDAP engine to the +// independently-owned Support Agent session core. It retains no grant token: +// grants are verified once, scoped to one session, and represented afterward +// only by non-secret audit events. +type passiveSessionAuthorizer struct { + mu sync.Mutex + deviceID string + allowedCapabilities []sessioncore.Capability + verifier sessioncore.GrantVerifier + sessions map[string]*sessioncore.Core +} + +func newPassiveSessionAuthorizer(brand Branding, st *AppState) (*passiveSessionAuthorizer, error) { + if st == nil { + return nil, fmt.Errorf("missing application state") + } + st.mu.Lock() + deviceID := strings.TrimSpace(st.DeviceID) + st.mu.Unlock() + publicKey := strings.TrimSpace(brand.ServerKey) + if publicKey == "" && brand.Server != nil { + publicKey = strings.TrimSpace(brand.Server.PublicKey) + } + verifier, err := sessioncore.NewEd25519GrantVerifier(publicKey) + if err != nil { + return nil, fmt.Errorf("configure session-grant verifier: %w", err) + } + capabilities := policySessionCapabilities(accessPolicyFor(brand, st)) + if len(capabilities) == 0 { + return nil, fmt.Errorf("desktop capability is disabled") + } + return &passiveSessionAuthorizer{ + deviceID: deviceID, + allowedCapabilities: capabilities, + verifier: verifier, + sessions: make(map[string]*sessioncore.Core), + }, nil +} + +func policySessionCapabilities(policy incomingAccessPolicy) []sessioncore.Capability { + capabilities := make([]sessioncore.Capability, 0, 8) + if policy.capabilities.Desktop { + capabilities = append(capabilities, sessioncore.CapabilityScreenView, sessioncore.CapabilityInput) + } + if policy.capabilities.Audio { + capabilities = append(capabilities, sessioncore.CapabilitySystemAudio) + } + if policy.capabilities.Clipboard { + capabilities = append(capabilities, sessioncore.CapabilityClipboard) + } + if policy.capabilities.Files { + capabilities = append(capabilities, sessioncore.CapabilityFiles) + } + if policy.capabilities.Terminal { + capabilities = append(capabilities, sessioncore.CapabilityTerminal) + } + if policy.capabilities.Restart { + capabilities = append(capabilities, sessioncore.CapabilityRestart) + } + return capabilities +} + +func (a *passiveSessionAuthorizer) Authorize( + sessionID, operatorID, transport string, + requested []string, + grant string, + requireConsent bool, +) error { + if a == nil { + return fmt.Errorf("passive session authorizer is unavailable") + } + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return fmt.Errorf("missing session ID") + } + requestedCapabilities := make([]sessioncore.Capability, 0, len(requested)) + for _, capability := range requested { + requestedCapabilities = append(requestedCapabilities, sessioncore.Capability(strings.TrimSpace(capability))) + } + + core, err := sessioncore.New(sessioncore.Config{ + Audience: supportSessionGrantAudience, + DeviceID: a.deviceID, + AllowedCapabilities: a.allowedCapabilities, + Verifier: a.verifier, + EventSink: logPassiveSessionEvent, + }) + if err != nil { + return err + } + if err := core.BeginEnrollment(); err != nil { + return err + } + if err := core.ApproveEnrollment(); err != nil { + return err + } + if _, err := core.Authorize(context.Background(), sessioncore.AdmissionRequest{ + OperatorID: operatorID, + SessionID: sessionID, + Transport: transport, + RequestedCapabilities: requestedCapabilities, + GrantPresentation: grant, + }); err != nil { + return err + } + if err := core.RequestConsent(); err != nil { + return err + } + if !requireConsent { + if err := core.GrantConsent(); err != nil { + return err + } + } + + a.mu.Lock() + if existing := a.sessions[sessionID]; existing != nil { + existing.Disconnect() + } + a.sessions[sessionID] = core + a.mu.Unlock() + return nil +} + +func (a *passiveSessionAuthorizer) ResolveConsent(sessionID string, granted bool) { + core := a.lookup(sessionID) + if core == nil { + return + } + if granted { + _ = core.GrantConsent() + return + } + core.DenyConsent() + a.remove(sessionID, core) +} + +func (a *passiveSessionAuthorizer) End(sessionID string) { + core := a.lookup(sessionID) + if core == nil { + return + } + core.Disconnect() + a.remove(sessionID, core) +} + +func (a *passiveSessionAuthorizer) lookup(sessionID string) *sessioncore.Core { + a.mu.Lock() + defer a.mu.Unlock() + return a.sessions[sessionID] +} + +func (a *passiveSessionAuthorizer) remove(sessionID string, expected *sessioncore.Core) { + a.mu.Lock() + defer a.mu.Unlock() + if a.sessions[sessionID] == expected { + delete(a.sessions, sessionID) + } +} + +func logPassiveSessionEvent(event sessioncore.Event) { + appLogInfo("passive_session", string(event.Kind), map[string]any{ + "session_id": event.SessionID, + "device_id": event.DeviceID, + "operator_id": event.OperatorID, + "from": string(event.From), + "to": string(event.To), + "reason": event.Reason, + }) +} diff --git a/betterdesk-support-agent/password_sync.go b/betterdesk-support-agent/password_sync.go index 2a9f26e7..933a9418 100644 --- a/betterdesk-support-agent/password_sync.go +++ b/betterdesk-support-agent/password_sync.go @@ -5,10 +5,11 @@ import ( "net/http" ) -// SyncAccessPassword pushes the local access password to the server so -// operators can connect in unattended mode via rdclient/rustdesk. +// SyncAccessPassword publishes only the local password-policy state. The +// unattended secret is deliberately verified by the Support Agent and never +// sent to, stored by, or logged by the server. func SyncAccessPassword(b Branding, st *AppState) error { - deviceID, mode, password, _ := st.Snapshot() + deviceID, _, _, _ := st.Snapshot() st.mu.Lock() token := st.DeviceToken st.mu.Unlock() @@ -16,12 +17,13 @@ func SyncAccessPassword(b Branding, st *AppState) error { if token == "" { return fmt.Errorf("device not enrolled") } + policy := accessPolicyFor(b, st) payload := map[string]any{ "device_id": deviceID, "device_token": token, - "password": password, - "unattended_enabled": mode == AccessUnattended, + "password_set": policy.passwordConfigured, + "unattended_enabled": policy.allowsUnattended(), } url := apiBaseURL(b) + "/devices/self/access-policy" code, err := apiJSON(http.MethodPost, url, payload, nil) diff --git a/betterdesk-support-agent/password_sync_test.go b/betterdesk-support-agent/password_sync_test.go new file mode 100644 index 00000000..f88c84ae --- /dev/null +++ b/betterdesk-support-agent/password_sync_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSyncAccessPasswordPublishesEffectiveUnattendedPolicy(t *testing.T) { + if isReleaseBuild() { + t.Skip("HTTP test server is intentionally refused by release transport policy") + } + + var payload map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/devices/self/access-policy" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode policy payload: %v", err) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + st := &AppState{ + DeviceID: "BD-TEST", + DeviceToken: "device-token", + AccessMode: AccessUnattended, + AccessPassword: " \t ", + } + brand := Branding{ + AllowUnattended: true, + Server: &ServerBranding{APIURL: server.URL}, + } + if err := SyncAccessPassword(brand, st); err != nil { + t.Fatalf("SyncAccessPassword: %v", err) + } + + if got, want := payload["password_set"], false; got != want { + t.Fatalf("password_set = %v, want %v", got, want) + } + if got, want := payload["unattended_enabled"], false; got != want { + t.Fatalf("unattended_enabled = %v, want %v", got, want) + } + if _, leaked := payload["password"]; leaked { + t.Fatal("access policy payload must not contain the local password") + } +} + +func TestSyncAccessPasswordNeverSendsLocalSecret(t *testing.T) { + if isReleaseBuild() { + t.Skip("HTTP test server is intentionally refused by release transport policy") + } + + var received map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/devices/self/access-policy" { + t.Fatalf("path = %q", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&received); err != nil { + t.Fatal(err) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + state := &AppState{ + DeviceID: "BD-LOCAL-SECRET", + DeviceToken: "device-token", + AccessPassword: "must-remain-local", + AccessMode: AccessUnattended, + } + brand := Branding{Server: &ServerBranding{APIURL: server.URL}} + if err := SyncAccessPassword(brand, state); err != nil { + t.Fatal(err) + } + if _, found := received["password"]; found { + t.Fatalf("password was sent: %#v", received) + } + if received["password_set"] != true { + t.Fatalf("password_set = %#v", received["password_set"]) + } +} diff --git a/betterdesk-support-agent/session_overlay.go b/betterdesk-support-agent/session_overlay.go index b4a15473..5927bc9b 100644 --- a/betterdesk-support-agent/session_overlay.go +++ b/betterdesk-support-agent/session_overlay.go @@ -11,21 +11,22 @@ import ( // sessionOverlay shows an always-on-top bar during remote sessions. type sessionOverlay struct { - win fyne.Window - label *widget.Label - operator string - start time.Time - ticker *time.Ticker - mu sync.Mutex + win fyne.Window + label *widget.Label + operator string + start time.Time + ticker *time.Ticker + mu sync.Mutex + onDisconnect func() } -func newSessionOverlay(app fyne.App, productName string) *sessionOverlay { - o := &sessionOverlay{start: time.Now()} +func newSessionOverlay(app fyne.App, productName string, onDisconnect func()) *sessionOverlay { + o := &sessionOverlay{start: time.Now(), onDisconnect: onDisconnect} o.win = app.NewWindow(productName + " — " + t("session_active")) o.win.SetFixedSize(true) o.label = widget.NewLabel("") disconnect := widget.NewButton(t("session_disconnect"), func() { - // Operator disconnect is server-side; local user can hide overlay. + o.requestDisconnect() o.hide() }) o.win.SetContent(container.NewVBox(o.label, disconnect)) @@ -33,6 +34,12 @@ func newSessionOverlay(app fyne.App, productName string) *sessionOverlay { return o } +func (o *sessionOverlay) requestDisconnect() { + if o.onDisconnect != nil { + o.onDisconnect() + } +} + func (o *sessionOverlay) show(operator, mode string) { o.mu.Lock() o.operator = operator diff --git a/betterdesk-support-agent/session_overlay_test.go b/betterdesk-support-agent/session_overlay_test.go new file mode 100644 index 00000000..6114f593 --- /dev/null +++ b/betterdesk-support-agent/session_overlay_test.go @@ -0,0 +1,17 @@ +package main + +import "testing" + +func TestSessionOverlayDisconnectInvokesTerminationCallback(t *testing.T) { + calls := 0 + overlay := &sessionOverlay{ + onDisconnect: func() { + calls++ + }, + } + + overlay.requestDisconnect() + if calls != 1 { + t.Fatalf("disconnect callback calls = %d, want 1", calls) + } +} diff --git a/betterdesk-support-agent/signal.go b/betterdesk-support-agent/signal.go index 2170ee3d..d0392414 100644 --- a/betterdesk-support-agent/signal.go +++ b/betterdesk-support-agent/signal.go @@ -2,57 +2,119 @@ package main import ( "encoding/hex" + "time" - "github.com/unitronix/betterdesk-server/auth" + "github.com/unitronix/betterdesk-support-agent/internal/totp" "github.com/unitronix/betterdesk-support-agent/signalhost" ) -func (u *ui) startSignalHost() { - if u.signalHost != nil || !u.brand.HasConnection() { - return +type signalHostCallbacks struct { + consent func(operator string) bool + onSession func(start bool, operator string) + audit func(policy hostCapabilityPolicy) +} + +// newSignalHost builds an outbound-only RustDesk-compatible relay host when +// the current local access policy permits it. The returned reason is intended +// for headless startup logs, where there is no UI to explain why the host is +// unavailable. +func newSignalHost(brand Branding, st *AppState, headless bool, callbacks signalHostCallbacks) (*signalhost.Host, string) { + if !brand.HasConnection() { + return nil, "no server is configured" } - uuidBytes, _ := hex.DecodeString(u.state.GetMachineUUID()) - host := signalhost.New(signalhost.Config{ - SignalAddr: signalAddress(u.brand), - RelayAddr: relayAddress(u.brand), - DeviceID: u.state.DeviceID, + policy := accessPolicyFor(brand, st) + if callbacks.audit != nil { + callbacks.audit(hostCapabilityPolicyFor(brand)) + } + if !policy.allowsSignalHost(headless) { + return nil, policy.signalHostDisabledReason(headless) + } + + deviceID, _, _, _ := st.Snapshot() + uuidBytes, _ := hex.DecodeString(st.GetMachineUUID()) + return signalhost.New(signalhost.Config{ + SignalAddr: signalAddress(brand), + RelayAddr: relayAddress(brand), + DeviceID: deviceID, UUID: uuidBytes, DataDir: stateDir(), Password: func() string { - _, _, pw, _ := u.state.Snapshot() + _, _, pw, _ := st.Snapshot() return pw }, Unattended: func() bool { - _, mode, _, _ := u.state.Snapshot() - return mode == AccessUnattended + return accessPolicyFor(brand, st).allowsUnattended() }, TOTPEnabled: func() bool { - enabled, _ := u.state.TOTPSnapshot() + enabled, _ := st.TOTPSnapshot() return enabled }, TOTPVerify: func(code string) bool { - _, secret := u.state.TOTPSnapshot() - return secret != "" && auth.ValidateTOTP(secret, code) + _, secret := st.TOTPSnapshot() + return secret != "" && totp.Validate(secret, code, time.Now()) }, - Consent: func(operator string) bool { + AccessAllowed: func() bool { + return accessPolicyFor(brand, st).allowsSignalHost(headless) + }, + DesktopEnabled: policy.capabilities.Desktop, + AudioEnabled: policy.capabilities.Audio, + RestartEnabled: policy.capabilities.Restart, + Consent: callbacks.consent, + OnSession: callbacks.onSession, + }), "" +} + +func (u *ui) startSignalHost() { + u.signalHostMu.Lock() + defer u.signalHostMu.Unlock() + if u.signalHost != nil { + return + } + host, _ := newSignalHost(u.brand, u.state, false, signalHostCallbacks{ + consent: func(operator string) bool { return u.handleConsent("signal", operator) }, - OnSession: func(start bool, operator string) { + audit: func(policy hostCapabilityPolicy) { + auditHostCapabilityPolicy(hostCapabilityAuditTransportSignal, policy) + }, + onSession: func(start bool, operator string) { if start { - u.handleSessionStart("signal", operator, "supervised") + _, mode, _, _ := u.state.Snapshot() + u.handleSessionStart("signal", operator, mode) } else { u.handleSessionEnd("signal") } }, }) + if host == nil || !host.Start() { + return + } u.signalHost = host - host.Start() appLogInfo("signal_host", "signal/relay host started", map[string]any{ "signal": signalAddress(u.brand), "relay": relayAddress(u.brand), }) } +func (u *ui) stopSignalHost() { + u.signalHostMu.Lock() + host := u.signalHost + u.signalHost = nil + u.signalHostMu.Unlock() + if host != nil { + host.Stop() + } +} + +func (u *ui) disconnectSignalSessions() { + u.signalHostMu.Lock() + host := u.signalHost + u.signalHostMu.Unlock() + if host != nil { + host.DisconnectSessions() + } +} + func signalAddress(b Branding) string { return hostFromAddr(b.ServerAddress) + ":21116" } diff --git a/betterdesk-support-agent/signalhost/auth_limiter.go b/betterdesk-support-agent/signalhost/auth_limiter.go new file mode 100644 index 00000000..5ff13087 --- /dev/null +++ b/betterdesk-support-agent/signalhost/auth_limiter.go @@ -0,0 +1,127 @@ +package signalhost + +import ( + "strings" + "sync" + "time" +) + +const ( + authFailureWindow = 5 * time.Minute + authLockoutPeriod = 15 * time.Minute + maxAuthFailures = 5 + maxTrackedAuthKeys = 1024 +) + +type authenticationAttempt struct { + failures int + windowStart time.Time + lockedUntil time.Time + lastSeen time.Time +} + +// authenticationLimiter bounds repeated credential failures without retaining +// credentials, TOTP values, or session contents. RustDesk-compatible relay +// handshakes do not expose an end-client address to the host, so keys use the +// claimed operator identity and the map is deliberately bounded. +type authenticationLimiter struct { + mu sync.Mutex + now func() time.Time + attempts map[string]authenticationAttempt +} + +func newAuthenticationLimiter(now func() time.Time) *authenticationLimiter { + if now == nil { + now = time.Now + } + return &authenticationLimiter{ + now: now, + attempts: make(map[string]authenticationAttempt), + } +} + +func (l *authenticationLimiter) allow(operator string) bool { + if l == nil { + return true + } + key := authenticationAttemptKey(operator) + now := l.now() + + l.mu.Lock() + defer l.mu.Unlock() + l.pruneLocked(now) + entry, ok := l.attempts[key] + if !ok { + return true + } + entry.lastSeen = now + l.attempts[key] = entry + return entry.lockedUntil.IsZero() || !now.Before(entry.lockedUntil) +} + +func (l *authenticationLimiter) failure(operator string) { + if l == nil { + return + } + key := authenticationAttemptKey(operator) + now := l.now() + + l.mu.Lock() + defer l.mu.Unlock() + l.pruneLocked(now) + entry := l.attempts[key] + if entry.windowStart.IsZero() || now.Sub(entry.windowStart) >= authFailureWindow { + entry.failures = 0 + entry.windowStart = now + } + entry.failures++ + entry.lastSeen = now + if entry.failures >= maxAuthFailures { + entry.lockedUntil = now.Add(authLockoutPeriod) + entry.failures = 0 + entry.windowStart = now + } + l.attempts[key] = entry +} + +func (l *authenticationLimiter) success(operator string) { + if l == nil { + return + } + l.mu.Lock() + defer l.mu.Unlock() + delete(l.attempts, authenticationAttemptKey(operator)) +} + +func (l *authenticationLimiter) pruneLocked(now time.Time) { + for key, entry := range l.attempts { + if now.Sub(entry.lastSeen) > authLockoutPeriod+authFailureWindow { + delete(l.attempts, key) + } + } + for len(l.attempts) >= maxTrackedAuthKeys { + var oldestKey string + var oldest time.Time + for key, entry := range l.attempts { + if oldestKey == "" || entry.lastSeen.Before(oldest) { + oldestKey = key + oldest = entry.lastSeen + } + } + if oldestKey == "" { + return + } + delete(l.attempts, oldestKey) + } +} + +func authenticationAttemptKey(operator string) string { + operator = strings.TrimSpace(operator) + if operator == "" { + return "anonymous" + } + if len(operator) > 128 { + operator = operator[:128] + } + return operator +} diff --git a/betterdesk-support-agent/signalhost/auth_limiter_test.go b/betterdesk-support-agent/signalhost/auth_limiter_test.go new file mode 100644 index 00000000..e0c8cc0f --- /dev/null +++ b/betterdesk-support-agent/signalhost/auth_limiter_test.go @@ -0,0 +1,62 @@ +package signalhost + +import ( + "testing" + "time" +) + +func TestAuthenticationLimiterLocksRepeatedFailuresWithoutSecrets(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + limiter := newAuthenticationLimiter(func() time.Time { return now }) + + for i := 0; i < maxAuthFailures-1; i++ { + if !limiter.allow("operator-1") { + t.Fatalf("attempt %d was blocked before the threshold", i+1) + } + limiter.failure("operator-1") + } + if !limiter.allow("operator-1") { + t.Fatal("last allowed attempt was blocked before recording its failure") + } + limiter.failure("operator-1") + if limiter.allow("operator-1") { + t.Fatal("repeated failed authentication did not lock the operator") + } + if !limiter.allow("another-operator") { + t.Fatal("one operator's lockout affected another operator") + } + + now = now.Add(authLockoutPeriod) + if !limiter.allow("operator-1") { + t.Fatal("operator remained locked after the lockout period") + } +} + +func TestAuthenticationLimiterSuccessClearsFailureHistory(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + limiter := newAuthenticationLimiter(func() time.Time { return now }) + + for i := 0; i < maxAuthFailures-1; i++ { + limiter.failure("operator-1") + } + limiter.success("operator-1") + for i := 0; i < maxAuthFailures-1; i++ { + limiter.failure("operator-1") + } + if !limiter.allow("operator-1") { + t.Fatal("a successful authentication did not reset failures") + } +} + +func TestAuthenticationAttemptKeyIsBounded(t *testing.T) { + long := make([]byte, 256) + for i := range long { + long[i] = 'a' + } + if got := len(authenticationAttemptKey(string(long))); got != 128 { + t.Fatalf("key length = %d, want 128", got) + } + if got := authenticationAttemptKey(" \t "); got != "anonymous" { + t.Fatalf("blank key = %q, want anonymous", got) + } +} diff --git a/betterdesk-support-agent/signalhost/authorization_test.go b/betterdesk-support-agent/signalhost/authorization_test.go new file mode 100644 index 00000000..2dc0f30a --- /dev/null +++ b/betterdesk-support-agent/signalhost/authorization_test.go @@ -0,0 +1,28 @@ +package signalhost + +import "testing" + +func TestAuthorizeOperatorFailsClosedWithoutSupervisedConsent(t *testing.T) { + host := New(Config{ + Unattended: func() bool { return false }, + }) + if host.authorizesOperator("operator") { + t.Fatal("supervised relay must reject when no consent callback is available") + } +} + +func TestAuthorizeOperatorReevaluatesUnattendedPolicy(t *testing.T) { + unattended := false + host := New(Config{ + Unattended: func() bool { return unattended }, + Consent: func(string) bool { return false }, + }) + if host.authorizesOperator("operator") { + t.Fatal("denied supervised policy must reject the operator") + } + + unattended = true + if !host.authorizesOperator("operator") { + t.Fatal("current unattended policy should allow the operator") + } +} diff --git a/betterdesk-support-agent/signalhost/codec_negotiation.go b/betterdesk-support-agent/signalhost/codec_negotiation.go new file mode 100644 index 00000000..0dc9097b --- /dev/null +++ b/betterdesk-support-agent/signalhost/codec_negotiation.go @@ -0,0 +1,385 @@ +package signalhost + +import ( + "context" + "io" + "os/exec" + "strconv" + "sync" + "time" + + pb "github.com/unitronix/betterdesk-server/proto" +) + +type negotiatedVideoCodec uint8 + +const ( + videoCodecNone negotiatedVideoCodec = iota + videoCodecH264 +) + +const ( + defaultStreamFPS = 15 + minStreamFPS = 2 + maxStreamFPS = 30 + defaultStreamQuality = 65 + minStreamQuality = 30 + maxStreamQuality = 90 + + encoderReconfigureInterval = 2 * time.Second + encoderRecoveryInterval = 6 * time.Second + healthyWriteSamples = 12 +) + +type videoSettings struct { + fps int + quality int +} + +// streamState holds the negotiated H.264 settings for one relay session. +// FFmpeg cannot safely reconfigure an Annex-B encoder in place, so a bounded +// restart is used to apply a change and ensure the next frame is a real IDR. +type streamState struct { + mu sync.Mutex + + codec negotiatedVideoCodec + + targetFPS int + targetQuality int + fps int + quality int + + lastRestart time.Time + healthyWrites int + reconfigure chan struct{} +} + +func newStreamState(codec negotiatedVideoCodec, options *pb.OptionMessage) *streamState { + settings := requestedVideoSettings(options) + return &streamState{ + codec: codec, + targetFPS: settings.fps, + targetQuality: settings.quality, + fps: settings.fps, + quality: settings.quality, + reconfigure: make(chan struct{}, 1), + } +} + +func requestedVideoSettings(options *pb.OptionMessage) videoSettings { + settings := videoSettings{ + fps: defaultStreamFPS, + quality: defaultStreamQuality, + } + if quality, ok := requestedQuality(options); ok { + settings.quality = quality + } + if fps, ok := requestedFPS(options); ok { + settings.fps = fps + } + return settings +} + +func requestedQuality(options *pb.OptionMessage) (int, bool) { + if options == nil { + return 0, false + } + if custom := options.GetCustomImageQuality(); custom > 0 { + return clampStreamQuality(int(custom)), true + } + switch options.GetImageQuality() { + case pb.ImageQuality_Low: + return 40, true + case pb.ImageQuality_Balanced: + return defaultStreamQuality, true + case pb.ImageQuality_Best: + return 85, true + default: + return 0, false + } +} + +func requestedFPS(options *pb.OptionMessage) (int, bool) { + if options == nil || options.GetCustomFps() <= 0 { + return 0, false + } + return clampStreamFPS(int(options.GetCustomFps())), true +} + +func clampStreamQuality(quality int) int { + if quality < minStreamQuality { + return minStreamQuality + } + if quality > maxStreamQuality { + return maxStreamQuality + } + return quality +} + +func clampStreamFPS(fps int) int { + if fps < minStreamFPS { + return minStreamFPS + } + if fps > maxStreamFPS { + return maxStreamFPS + } + return fps +} + +// h264CRF maps the negotiated 0-100 quality scale to libx264's CRF range. +// The exposed quality is deliberately clamped before this conversion. +func h264CRF(quality int) int { + quality = clampStreamQuality(quality) + return 42 - quality*28/100 +} + +// ffmpegStreamArgsForQuality inserts the CRF before the pixel-format output +// option. Platform-specific files retain ownership of their capture inputs. +func ffmpegStreamArgsForQuality(fps, quality int) []string { + args := ffmpegStreamArgs(fps) + if len(args) == 0 { + return nil + } + + out := make([]string, 0, len(args)+2) + inserted := false + for _, arg := range args { + if arg == "-pix_fmt" && !inserted { + out = append(out, "-crf", strconv.Itoa(h264CRF(quality))) + inserted = true + } + out = append(out, arg) + } + if inserted { + return out + } + + // All supported platform arguments include -pix_fmt today. Keep a safe + // fallback if a future capture path does not: insert before the output URL. + if len(out) > 0 { + last := out[len(out)-1] + out = out[:len(out)-1] + out = append(out, "-crf", strconv.Itoa(h264CRF(quality)), last) + } + return out +} + +func frameInterval(fps int) time.Duration { + return time.Second / time.Duration(clampStreamFPS(fps)) +} + +func (s *streamState) settings() videoSettings { + s.mu.Lock() + defer s.mu.Unlock() + return videoSettings{fps: s.fps, quality: s.quality} +} + +func (s *streamState) markEncoderStarted(now time.Time) { + s.mu.Lock() + s.lastRestart = now + s.healthyWrites = 0 + s.mu.Unlock() +} + +func (s *streamState) applyPeerOptions(options *pb.OptionMessage, now time.Time) bool { + quality, hasQuality := requestedQuality(options) + fps, hasFPS := requestedFPS(options) + if !hasQuality && !hasFPS { + return false + } + + s.mu.Lock() + if !s.canReconfigureLocked(now) { + s.mu.Unlock() + return false + } + + next := videoSettings{fps: s.fps, quality: s.quality} + targetFPS, targetQuality := s.targetFPS, s.targetQuality + if hasFPS { + next.fps = fps + targetFPS = fps + } + if hasQuality { + next.quality = quality + targetQuality = quality + } + if next.fps == s.fps && next.quality == s.quality && + targetFPS == s.targetFPS && targetQuality == s.targetQuality { + s.mu.Unlock() + return false + } + + s.fps = next.fps + s.quality = next.quality + s.targetFPS = targetFPS + s.targetQuality = targetQuality + s.lastRestart = now + s.healthyWrites = 0 + s.mu.Unlock() + s.requestReconfigure() + return true +} + +// requestKeyframe starts a fresh H.264 encoder rather than marking an arbitrary +// delta frame as key. The restart rate limit prevents a peer from using refresh +// requests to repeatedly spawn encoders. +func (s *streamState) requestKeyframe(now time.Time) bool { + s.mu.Lock() + if !s.canReconfigureLocked(now) { + s.mu.Unlock() + return false + } + s.lastRestart = now + s.healthyWrites = 0 + s.mu.Unlock() + s.requestReconfigure() + return true +} + +// observeWrite feeds transport backpressure into a bounded controller. It +// changes FPS and quality only by restarting the local encoder, which keeps the +// H.264 stream decodable instead of dropping inter-frame deltas. +func (s *streamState) observeWrite(elapsed time.Duration) { + if s.adjustForWrite(elapsed, time.Now()) { + s.requestReconfigure() + } +} + +func (s *streamState) adjustForWrite(elapsed time.Duration, now time.Time) bool { + s.mu.Lock() + defer s.mu.Unlock() + + interval := frameInterval(s.fps) + slowThreshold := 3 * interval + if slowThreshold < 200*time.Millisecond { + slowThreshold = 200 * time.Millisecond + } + if elapsed >= slowThreshold { + if !s.canReconfigureLocked(now) { + return false + } + nextFPS := s.fps - 2 + if nextFPS < minStreamFPS { + nextFPS = minStreamFPS + } + nextQuality := s.quality - 8 + if nextQuality < minStreamQuality { + nextQuality = minStreamQuality + } + if nextFPS == s.fps && nextQuality == s.quality { + s.healthyWrites = 0 + return false + } + s.fps = nextFPS + s.quality = nextQuality + s.lastRestart = now + s.healthyWrites = 0 + return true + } + + if elapsed <= interval/4 { + s.healthyWrites++ + if s.healthyWrites < healthyWriteSamples || + now.Sub(s.lastRestart) < encoderRecoveryInterval || + !s.canReconfigureLocked(now) { + return false + } + + nextFPS := s.fps + 1 + if nextFPS > s.targetFPS { + nextFPS = s.targetFPS + } + nextQuality := s.quality + 4 + if nextQuality > s.targetQuality { + nextQuality = s.targetQuality + } + if nextFPS == s.fps && nextQuality == s.quality { + return false + } + s.fps = nextFPS + s.quality = nextQuality + s.lastRestart = now + s.healthyWrites = 0 + return true + } + + s.healthyWrites = 0 + return false +} + +func (s *streamState) canReconfigureLocked(now time.Time) bool { + return s.lastRestart.IsZero() || now.Sub(s.lastRestart) >= encoderReconfigureInterval +} + +func (s *streamState) requestReconfigure() { + select { + case s.reconfigure <- struct{}{}: + default: + } +} + +func supportedEncodingForH264(h264 bool) *pb.SupportedEncoding { + if !h264 { + return nil + } + return &pb.SupportedEncoding{H264: true} +} + +// negotiateVideoCodec requires an explicit decoder capability from the peer. +// An absent or zero ability is not treated as an H.264 fallback because doing +// so can send undecodable video to clients that only support another codec. +func negotiateVideoCodec(local *pb.SupportedEncoding, peer *pb.SupportedDecoding) negotiatedVideoCodec { + if local == nil || !local.GetH264() || peer == nil || peer.GetAbilityH264() <= 0 { + return videoCodecNone + } + return videoCodecH264 +} + +func (codec negotiatedVideoCodec) String() string { + switch codec { + case videoCodecH264: + return "h264" + default: + return "none" + } +} + +var h264Probe struct { + once sync.Once + supported bool +} + +func advertisedVideoEncoding() *pb.SupportedEncoding { + return supportedEncodingForH264(h264EncoderSupported()) +} + +// h264EncoderSupported verifies the exact encoder and output format used by +// this host. Merely finding an ffmpeg binary is not enough to advertise H.264. +func h264EncoderSupported() bool { + h264Probe.once.Do(func() { + if len(ffmpegStreamArgs(defaultStreamFPS)) == 0 { + return + } + path, err := exec.LookPath("ffmpeg") + if err != nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, path, + "-hide_banner", "-loglevel", "error", + "-f", "lavfi", "-i", "color=c=black:s=16x16:r=1", + "-frames:v", "1", + "-c:v", "libx264", + "-preset", "ultrafast", + "-tune", "zerolatency", + "-pix_fmt", "yuv420p", + "-f", "h264", "-", + ) + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + h264Probe.supported = cmd.Run() == nil + }) + return h264Probe.supported +} diff --git a/betterdesk-support-agent/signalhost/codec_negotiation_test.go b/betterdesk-support-agent/signalhost/codec_negotiation_test.go new file mode 100644 index 00000000..d566ba65 --- /dev/null +++ b/betterdesk-support-agent/signalhost/codec_negotiation_test.go @@ -0,0 +1,164 @@ +package signalhost + +import ( + "testing" + "time" + + pb "github.com/unitronix/betterdesk-server/proto" +) + +func TestSupportedEncodingOnlyAdvertisesValidatedH264(t *testing.T) { + if got := supportedEncodingForH264(false); got != nil { + t.Fatalf("unsupported encoder advertised as %#v", got) + } + + got := supportedEncodingForH264(true) + if got == nil || !got.GetH264() { + t.Fatalf("H.264 capability = %#v, want H.264", got) + } + if got.GetH265() || got.GetVp8() || got.GetAv1() || got.GetI444() != nil { + t.Fatalf("unexpected unsupported codec advertisement: %#v", got) + } +} + +func TestNegotiateVideoCodecRequiresMutualH264(t *testing.T) { + local := supportedEncodingForH264(true) + + tests := []struct { + name string + local *pb.SupportedEncoding + peer *pb.SupportedDecoding + want negotiatedVideoCodec + }{ + { + name: "missing local capability", + local: nil, + peer: &pb.SupportedDecoding{AbilityH264: 1}, + want: videoCodecNone, + }, + { + name: "missing peer capability", + local: local, + peer: nil, + want: videoCodecNone, + }, + { + name: "preference without ability", + local: local, + peer: &pb.SupportedDecoding{Prefer: pb.SupportedDecoding_H264}, + want: videoCodecNone, + }, + { + name: "mutual h264 despite another preference", + local: local, + peer: &pb.SupportedDecoding{AbilityH264: 1, Prefer: pb.SupportedDecoding_AV1}, + want: videoCodecH264, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := negotiateVideoCodec(tc.local, tc.peer); got != tc.want { + t.Fatalf("negotiateVideoCodec() = %s, want %s", got, tc.want) + } + }) + } +} + +func TestRequestedVideoSettingsAreBounded(t *testing.T) { + got := requestedVideoSettings(&pb.OptionMessage{ + CustomImageQuality: 1000, + CustomFps: 1000, + }) + if got.quality != maxStreamQuality || got.fps != maxStreamFPS { + t.Fatalf("unbounded request resolved to %#v", got) + } + + got = requestedVideoSettings(&pb.OptionMessage{ + CustomImageQuality: 1, + CustomFps: 1, + }) + if got.quality != minStreamQuality || got.fps != minStreamFPS { + t.Fatalf("low request resolved to %#v", got) + } + + got = requestedVideoSettings(&pb.OptionMessage{ + CustomImageQuality: -1, + CustomFps: -1, + ImageQuality: pb.ImageQuality_Low, + }) + if got.quality != 40 || got.fps != defaultStreamFPS { + t.Fatalf("invalid request resolved to %#v", got) + } +} + +func TestPeerOptionChangesAreRateLimitedAndBounded(t *testing.T) { + st := newStreamState(videoCodecH264, &pb.OptionMessage{ + CustomImageQuality: 70, + CustomFps: 20, + }) + start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + st.markEncoderStarted(start) + + if st.applyPeerOptions(&pb.OptionMessage{ + CustomImageQuality: 1000, + CustomFps: 1000, + }, start.Add(time.Second)) { + t.Fatal("settings changed inside the encoder restart cooldown") + } + if got := st.settings(); got.quality != 70 || got.fps != 20 { + t.Fatalf("cooldown changed settings to %#v", got) + } + + if !st.applyPeerOptions(&pb.OptionMessage{ + CustomImageQuality: 1000, + CustomFps: 1000, + }, start.Add(encoderReconfigureInterval)) { + t.Fatal("bounded settings update was not applied") + } + if got := st.settings(); got.quality != maxStreamQuality || got.fps != maxStreamFPS { + t.Fatalf("bounded settings update = %#v", got) + } +} + +func TestCongestionControllerStaysWithinNegotiatedLimits(t *testing.T) { + st := newStreamState(videoCodecH264, &pb.OptionMessage{ + CustomImageQuality: 90, + CustomFps: 30, + }) + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + st.markEncoderStarted(now.Add(-encoderReconfigureInterval)) + + for i := 0; i < 20; i++ { + now = now.Add(encoderReconfigureInterval) + st.adjustForWrite(3*time.Second, now) + } + if got := st.settings(); got.quality != minStreamQuality || got.fps != minStreamFPS { + t.Fatalf("congestion floor = %#v, want quality=%d fps=%d", got, minStreamQuality, minStreamFPS) + } + + now = now.Add(encoderRecoveryInterval) + for i := 0; i < healthyWriteSamples; i++ { + now = now.Add(time.Millisecond) + st.adjustForWrite(time.Millisecond, now) + } + got := st.settings() + if got.quality <= minStreamQuality || got.fps <= minStreamFPS { + t.Fatalf("healthy transport did not recover: %#v", got) + } + if got.quality > maxStreamQuality || got.fps > maxStreamFPS { + t.Fatalf("recovery exceeded negotiated limits: %#v", got) + } +} + +func TestH264HasIDRDoesNotMislabelDeltaFrames(t *testing.T) { + idr := []byte{0, 0, 0, 1, 0x67, 0, 0, 1, 0x65, 1} + if !h264HasIDR(idr) { + t.Fatal("IDR access unit was not recognized") + } + + delta := []byte{0, 0, 0, 1, 0x67, 0, 0, 1, 0x41, 1} + if h264HasIDR(delta) { + t.Fatal("delta access unit was incorrectly marked as key") + } +} diff --git a/betterdesk-support-agent/signalhost/display.go b/betterdesk-support-agent/signalhost/display.go index 9ee6ef00..7041cf3d 100644 --- a/betterdesk-support-agent/signalhost/display.go +++ b/betterdesk-support-agent/signalhost/display.go @@ -9,7 +9,10 @@ import ( pb "github.com/unitronix/betterdesk-server/proto" ) -func buildPeerInfo(deviceID string) (*pb.PeerInfo, uint32, uint32) { +// buildPeerInfo publishes only codecs that were actively probed for this host. +// Features such as audio, file transfer, clipboard, terminal, and multi-monitor +// remain absent until their corresponding host paths exist. +func buildPeerInfo(deviceID string, encoding *pb.SupportedEncoding) (*pb.PeerInfo, uint32, uint32) { displays, w, h := primaryDisplayInfo() username := "user" if u, err := user.Current(); err == nil && u.Username != "" { @@ -26,9 +29,7 @@ func buildPeerInfo(deviceID string) (*pb.PeerInfo, uint32, uint32) { Displays: displays, CurrentDisplay: 0, Version: "1.0", - Encoding: &pb.SupportedEncoding{ - H264: true, - }, + Encoding: encoding, }, w, h } diff --git a/betterdesk-support-agent/signalhost/exchange.go b/betterdesk-support-agent/signalhost/exchange.go index 10775235..c4ba330b 100644 --- a/betterdesk-support-agent/signalhost/exchange.go +++ b/betterdesk-support-agent/signalhost/exchange.go @@ -1,6 +1,7 @@ package signalhost import ( + "crypto/ed25519" "crypto/rand" "fmt" @@ -22,13 +23,20 @@ func generateEphemeralKeyPair() (ephemeralKeyPair, error) { return ephemeralKeyPair{public: *pub, private: *priv}, nil } -func buildSignedID(deviceID string, pub [32]byte) (*pb.Message, error) { +// buildSignedID proves that the ephemeral key belongs to this host identity. +// The peer verifies the first 64 bytes against the Ed25519 key registered with +// the signal server; zero-filled bytes are not a signature. +func buildSignedID(deviceID string, pub [32]byte, signingKey ed25519.PrivateKey) (*pb.Message, error) { + if len(signingKey) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("invalid host signing key") + } idPk := &pb.IdPk{Id: deviceID, Pk: pub[:]} idPkBytes, err := proto.Marshal(idPk) if err != nil { return nil, err } - signed := append(make([]byte, 64), idPkBytes...) + signature := ed25519.Sign(signingKey, idPkBytes) + signed := append(signature, idPkBytes...) return &pb.Message{ Union: &pb.Message_SignedId{SignedId: &pb.SignedId{Id: signed}}, }, nil diff --git a/betterdesk-support-agent/signalhost/exchange_test.go b/betterdesk-support-agent/signalhost/exchange_test.go new file mode 100644 index 00000000..339daea0 --- /dev/null +++ b/betterdesk-support-agent/signalhost/exchange_test.go @@ -0,0 +1,47 @@ +package signalhost + +import ( + "crypto/ed25519" + "crypto/rand" + "testing" + + pb "github.com/unitronix/betterdesk-server/proto" + "google.golang.org/protobuf/proto" +) + +func TestBuildSignedIDUsesHostIdentitySignature(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + var ephemeral [32]byte + if _, err := rand.Read(ephemeral[:]); err != nil { + t.Fatal(err) + } + message, err := buildSignedID("BD-12345", ephemeral, privateKey) + if err != nil { + t.Fatal(err) + } + signed := message.GetSignedId().GetId() + if len(signed) <= ed25519.SignatureSize { + t.Fatalf("signed id length = %d", len(signed)) + } + signature := signed[:ed25519.SignatureSize] + payload := signed[ed25519.SignatureSize:] + if !ed25519.Verify(publicKey, payload, signature) { + t.Fatal("SignedId did not verify with the registered identity") + } + var idPK pb.IdPk + if err := proto.Unmarshal(payload, &idPK); err != nil { + t.Fatal(err) + } + if idPK.GetId() != "BD-12345" || string(idPK.GetPk()) != string(ephemeral[:]) { + t.Fatalf("unexpected signed IdPk: id=%q public_key_len=%d", idPK.GetId(), len(idPK.GetPk())) + } +} + +func TestBuildSignedIDRejectsMissingIdentity(t *testing.T) { + if _, err := buildSignedID("BD-12345", [32]byte{}, nil); err == nil { + t.Fatal("expected missing identity rejection") + } +} diff --git a/betterdesk-support-agent/signalhost/ffmpeg.go b/betterdesk-support-agent/signalhost/ffmpeg.go index 3da0735d..dd280123 100644 --- a/betterdesk-support-agent/signalhost/ffmpeg.go +++ b/betterdesk-support-agent/signalhost/ffmpeg.go @@ -5,6 +5,7 @@ import ( "context" "io" "os/exec" + "strconv" ) func startFFmpegCapture(ctx context.Context, args []string) (*exec.Cmd, io.ReadCloser, error) { @@ -23,7 +24,7 @@ func startFFmpegCapture(ctx context.Context, args []string) (*exec.Cmd, io.ReadC return cmd, stdout, nil } -func encodeJPEGToH264(ctx context.Context, jpeg []byte) ([]byte, bool, error) { +func encodeJPEGToH264(ctx context.Context, jpeg []byte, quality int) ([]byte, bool, error) { path, err := exec.LookPath("ffmpeg") if err != nil { return nil, false, err @@ -34,6 +35,8 @@ func encodeJPEGToH264(ctx context.Context, jpeg []byte) ([]byte, bool, error) { "-frames:v", "1", "-c:v", "libx264", "-preset", "ultrafast", + "-tune", "zerolatency", + "-crf", strconv.Itoa(h264CRF(quality)), "-pix_fmt", "yuv420p", "-f", "h264", "pipe:1", ) @@ -42,5 +45,25 @@ func encodeJPEGToH264(ctx context.Context, jpeg []byte) ([]byte, bool, error) { if err != nil { return nil, false, err } - return out, true, nil + return out, h264HasIDR(out), nil +} + +// h264HasIDR verifies that an encoded access unit actually contains an IDR +// NAL. A fresh fallback encoder is expected to produce one, but the wire key +// flag must describe the payload rather than an assumption about ffmpeg. +func h264HasIDR(data []byte) bool { + for offset := 0; ; { + start := indexStartCode(data, offset) + if start < 0 { + return false + } + startLen := startCodeLen(data, start) + if start+startLen >= len(data) { + return false + } + if data[start+startLen]&0x1F == 5 { + return true + } + offset = start + startLen + } } diff --git a/betterdesk-support-agent/signalhost/hardening_test.go b/betterdesk-support-agent/signalhost/hardening_test.go new file mode 100644 index 00000000..ee2337a8 --- /dev/null +++ b/betterdesk-support-agent/signalhost/hardening_test.go @@ -0,0 +1,61 @@ +package signalhost + +import ( + "net" + "testing" + "time" +) + +func TestValidLoginPasswordRejectsEmptyLocalPassword(t *testing.T) { + salt, challenge := "salt", "challenge" + emptyHash := hashPassword("", salt, challenge) + if validLoginPassword("", salt, challenge, emptyHash[:]) { + t.Fatal("empty local password must never authenticate") + } + + password := "secret12" + expected := hashPassword(password, salt, challenge) + if !validLoginPassword(password, salt, challenge, expected[:]) { + t.Fatal("configured password should authenticate its matching hash") + } +} + +func TestHostDoesNotStartWhenAccessIsDisabled(t *testing.T) { + host := New(Config{ + SignalAddr: "127.0.0.1:21116", + DeviceID: "BD-TEST", + DesktopEnabled: true, + AccessAllowed: func() bool { + return false + }, + }) + if host.Start() { + t.Fatal("host started despite disabled access policy") + } + if host.Running() { + t.Fatal("host reports running despite disabled access policy") + } +} + +func TestDisconnectSessionsClosesActiveRelay(t *testing.T) { + server, client := net.Pipe() + defer client.Close() + + host := New(Config{}) + host.sessions = map[net.Conn]struct{}{server: {}} + host.DisconnectSessions() + + readDone := make(chan error, 1) + go func() { + _, err := client.Read(make([]byte, 1)) + readDone <- err + }() + select { + case err := <-readDone: + if err == nil { + t.Fatal("relay read succeeded after local disconnect") + } + case <-time.After(time.Second): + t.Fatal("relay connection was not closed by local disconnect") + } +} diff --git a/betterdesk-support-agent/signalhost/host.go b/betterdesk-support-agent/signalhost/host.go index c284dcfd..b3fd21b1 100644 --- a/betterdesk-support-agent/signalhost/host.go +++ b/betterdesk-support-agent/signalhost/host.go @@ -2,6 +2,7 @@ package signalhost import ( "context" + "net" "sync" ) @@ -13,41 +14,140 @@ type Config struct { UUID []byte DataDir string - Password func() string - Unattended func() bool + Password func() string + Unattended func() bool TOTPEnabled func() bool TOTPVerify func(code string) bool - Consent func(operator string) bool - OnSession func(start bool, operator string) + // AccessAllowed must return false as soon as local policy disables + // RustDesk-compatible desktop access. + AccessAllowed func() bool + DesktopEnabled bool + AudioEnabled bool + RestartEnabled bool + // Consent must be provided whenever Unattended returns false. A missing + // callback is treated as denial rather than an unattended fallback. + Consent func(operator string) bool + OnSession func(start bool, operator string) } // Host maintains UDP registration with hbbs and accepts incoming relay sessions. type Host struct { - cfg Config - cancel context.CancelFunc - wg sync.WaitGroup + cfg Config + + mu sync.Mutex + cancel context.CancelFunc + running bool + identity *identity + auth *authenticationLimiter + wg sync.WaitGroup + + sessionsMu sync.Mutex + sessions map[net.Conn]struct{} } func New(cfg Config) *Host { - return &Host{cfg: cfg} + return &Host{ + cfg: cfg, + auth: newAuthenticationLimiter(nil), + } } -func (h *Host) Start() { - if h.cfg.SignalAddr == "" || h.cfg.DeviceID == "" { - return +// Start begins outbound rendezvous registration. It never opens a local +// listener; relay sessions are initiated only after the rendezvous service +// requests one. +func (h *Host) Start() bool { + h.mu.Lock() + defer h.mu.Unlock() + if h.running { + return true + } + if h.cfg.SignalAddr == "" || h.cfg.DeviceID == "" || !h.accessAllowed() { + return false } ctx, cancel := context.WithCancel(context.Background()) h.cancel = cancel + h.running = true + h.sessions = make(map[net.Conn]struct{}) h.wg.Add(1) go func() { - defer h.wg.Done() + defer func() { + h.mu.Lock() + h.running = false + h.cancel = nil + h.mu.Unlock() + h.DisconnectSessions() + h.wg.Done() + }() h.runLoop(ctx) }() + return true } func (h *Host) Stop() { - if h.cancel != nil { - h.cancel() + h.mu.Lock() + cancel := h.cancel + h.cancel = nil + h.running = false + h.mu.Unlock() + if cancel != nil { + cancel() } + h.DisconnectSessions() h.wg.Wait() } + +// DisconnectSessions terminates currently active relay sessions without +// unregistering the host. It is used by the local "Disconnect" action. +func (h *Host) DisconnectSessions() { + h.sessionsMu.Lock() + conns := make([]net.Conn, 0, len(h.sessions)) + for conn := range h.sessions { + conns = append(conns, conn) + } + h.sessions = make(map[net.Conn]struct{}) + h.sessionsMu.Unlock() + for _, conn := range conns { + _ = conn.Close() + } +} + +// Running reports whether rendezvous registration is active. +func (h *Host) Running() bool { + h.mu.Lock() + defer h.mu.Unlock() + return h.running +} + +func (h *Host) setIdentity(identity *identity) { + h.mu.Lock() + h.identity = identity + h.mu.Unlock() +} + +func (h *Host) hostIdentity() *identity { + h.mu.Lock() + defer h.mu.Unlock() + return h.identity +} + +func (h *Host) accessAllowed() bool { + return h.cfg.DesktopEnabled && (h.cfg.AccessAllowed == nil || h.cfg.AccessAllowed()) +} + +func (h *Host) trackRelay(conn net.Conn) bool { + h.mu.Lock() + defer h.mu.Unlock() + if !h.running || !h.accessAllowed() { + return false + } + h.sessionsMu.Lock() + h.sessions[conn] = struct{}{} + h.sessionsMu.Unlock() + return true +} + +func (h *Host) untrackRelay(conn net.Conn) { + h.sessionsMu.Lock() + delete(h.sessions, conn) + h.sessionsMu.Unlock() +} diff --git a/betterdesk-support-agent/signalhost/incoming.go b/betterdesk-support-agent/signalhost/incoming.go index 6ef4c1fd..3548c1bf 100644 --- a/betterdesk-support-agent/signalhost/incoming.go +++ b/betterdesk-support-agent/signalhost/incoming.go @@ -1,18 +1,20 @@ package signalhost import ( - "bytes" + "context" "crypto/rand" + "crypto/subtle" "encoding/hex" "fmt" "io" "log" "net" "runtime" + "strings" "time" - pb "github.com/unitronix/betterdesk-server/proto" "github.com/unitronix/betterdesk-server/codec" + pb "github.com/unitronix/betterdesk-server/proto" "google.golang.org/protobuf/proto" ) @@ -21,7 +23,10 @@ const ( publicKeyWait = 1500 * time.Millisecond ) -func (h *Host) handleIncomingRelay(relayServer, uuid string) { +func (h *Host) handleIncomingRelay(ctx context.Context, relayServer, uuid string) { + if ctx.Err() != nil || !h.accessAllowed() { + return + } addr := relayServer if !hasPort(addr) { addr = net.JoinHostPort(addr, "21117") @@ -30,12 +35,18 @@ func (h *Host) handleIncomingRelay(relayServer, uuid string) { addr = h.cfg.RelayAddr } - conn, err := net.DialTimeout("tcp", addr, 10*time.Second) + conn, err := (&net.Dialer{Timeout: 10 * time.Second}).DialContext(ctx, "tcp", addr) if err != nil { - log.Printf("[signalhost] relay dial: %v", err) + if ctx.Err() == nil { + log.Printf("[signalhost] relay dial: %v", err) + } return } defer conn.Close() + if !h.trackRelay(conn) { + return + } + defer h.untrackRelay(conn) req := &pb.RendezvousMessage{ Union: &pb.RendezvousMessage_RequestRelay{ @@ -78,6 +89,13 @@ func hasPort(hostport string) bool { } func (h *Host) runPeerSession(conn net.Conn) error { + if !h.accessAllowed() { + return fmt.Errorf("remote access disabled") + } + identity := h.hostIdentity() + if identity == nil || len(identity.secretKey) == 0 { + return fmt.Errorf("host identity is unavailable") + } ephemeral, err := generateEphemeralKeyPair() if err != nil { return err @@ -85,7 +103,7 @@ func (h *Host) runPeerSession(conn net.Conn) error { ps := newPeerSession(conn) - signedID, err := buildSignedID(h.cfg.DeviceID, ephemeral.public) + signedID, err := buildSignedID(h.cfg.DeviceID, ephemeral.public, identity.secretKey) if err != nil { return err } @@ -94,9 +112,10 @@ func (h *Host) runPeerSession(conn net.Conn) error { } log.Printf("[signalhost] sent SignedId (device=%s)", h.cfg.DeviceID) - // RustDesk initiators send PublicKey; RDClient waits for plaintext Hash. + // A session must establish the authenticated encrypted channel before + // credentials or desktop data are exchanged. There is no plaintext fallback. if err := h.waitPublicKey(ps, ephemeral); err != nil { - log.Printf("[signalhost] plaintext relay mode: %v", err) + return fmt.Errorf("authenticated key exchange: %w", err) } salt := randomToken() @@ -118,21 +137,33 @@ func (h *Host) runPeerSession(conn net.Conn) error { if operator == "" { operator = login.GetMyId() } + if h.auth != nil && !h.auth.allow(operator) { + _ = ps.write(&pb.Message{Union: &pb.Message_LoginResponse{LoginResponse: &pb.LoginResponse{ + Union: &pb.LoginResponse_Error{Error: "Too many authentication attempts"}, + }}}) + return fmt.Errorf("authentication temporarily locked") + } pw := "" if h.cfg.Password != nil { pw = h.cfg.Password() } - unattended := h.cfg.Unattended != nil && h.cfg.Unattended() + pw = strings.TrimSpace(pw) - if pw != "" { - expected := hashPassword(pw, salt, challenge) - if !bytes.Equal(login.GetPassword(), expected[:]) { - _ = ps.write(&pb.Message{Union: &pb.Message_LoginResponse{LoginResponse: &pb.LoginResponse{ - Union: &pb.LoginResponse_Error{Error: "Wrong Password"}, - }}}) - return fmt.Errorf("wrong password") + if pw == "" { + _ = ps.write(&pb.Message{Union: &pb.Message_LoginResponse{LoginResponse: &pb.LoginResponse{ + Union: &pb.LoginResponse_Error{Error: "Access password unavailable"}, + }}}) + return fmt.Errorf("local access password is empty") + } + if !validLoginPassword(pw, salt, challenge, login.GetPassword()) { + if h.auth != nil { + h.auth.failure(operator) } + _ = ps.write(&pb.Message{Union: &pb.Message_LoginResponse{LoginResponse: &pb.LoginResponse{ + Union: &pb.LoginResponse_Error{Error: "Wrong Password"}, + }}}) + return fmt.Errorf("wrong password") } if h.cfg.TOTPEnabled != nil && h.cfg.TOTPEnabled() { @@ -147,20 +178,46 @@ func (h *Host) runPeerSession(conn net.Conn) error { } auth2fa := authFrame.GetAuth_2Fa() if auth2fa == nil || h.cfg.TOTPVerify == nil || !h.cfg.TOTPVerify(auth2fa.GetCode()) { + if h.auth != nil { + h.auth.failure(operator) + } _ = ps.write(&pb.Message{Union: &pb.Message_LoginResponse{LoginResponse: &pb.LoginResponse{ Union: &pb.LoginResponse_Error{Error: "Wrong 2FA code"}, }}}) return fmt.Errorf("wrong 2fa code") } } + if h.auth != nil { + h.auth.success(operator) + } - if !unattended && h.cfg.Consent != nil { - if !h.cfg.Consent(operator) { - _ = ps.write(&pb.Message{Union: &pb.Message_LoginResponse{LoginResponse: &pb.LoginResponse{ - Union: &pb.LoginResponse_Error{Error: "Connection denied"}, - }}}) - return fmt.Errorf("consent denied") - } + if !h.accessAllowed() { + _ = ps.write(&pb.Message{Union: &pb.Message_LoginResponse{LoginResponse: &pb.LoginResponse{ + Union: &pb.LoginResponse_Error{Error: "Remote access disabled"}, + }}}) + return fmt.Errorf("remote access disabled") + } + + if !h.authorizesOperator(operator) { + _ = ps.write(&pb.Message{Union: &pb.Message_LoginResponse{LoginResponse: &pb.LoginResponse{ + Union: &pb.LoginResponse_Error{Error: "Connection denied"}, + }}}) + return fmt.Errorf("consent denied") + } + if !h.accessAllowed() { + _ = ps.write(&pb.Message{Union: &pb.Message_LoginResponse{LoginResponse: &pb.LoginResponse{ + Union: &pb.LoginResponse_Error{Error: "Remote access disabled"}, + }}}) + return fmt.Errorf("remote access disabled") + } + + encoding := advertisedVideoEncoding() + codec := negotiateVideoCodec(encoding, login.GetOption().GetSupportedDecoding()) + if codec == videoCodecNone { + _ = ps.write(&pb.Message{Union: &pb.Message_LoginResponse{LoginResponse: &pb.LoginResponse{ + Union: &pb.LoginResponse_Error{Error: "No mutually supported desktop video codec"}, + }}}) + return fmt.Errorf("no mutually supported desktop video codec") } if h.cfg.OnSession != nil { @@ -168,15 +225,27 @@ func (h *Host) runPeerSession(conn net.Conn) error { defer h.cfg.OnSession(false, operator) } - peerInfo, _, _ := buildPeerInfo(h.cfg.DeviceID) + peerInfo, _, _ := buildPeerInfo(h.cfg.DeviceID, encoding) if err := ps.write(&pb.Message{Union: &pb.Message_LoginResponse{LoginResponse: &pb.LoginResponse{ Union: &pb.LoginResponse_PeerInfo{PeerInfo: peerInfo}, }}}); err != nil { return err } - log.Printf("[signalhost] authenticated operator=%s encrypted=%v platform=%s", operator, ps.encrypted, runtime.GOOS) + log.Printf("[signalhost] authenticated operator=%s encrypted=%v platform=%s codec=%s", operator, ps.encrypted, runtime.GOOS, codec) - return h.streamSession(ps) + return h.streamSession(ps, codec, login.GetOption()) +} + +// authorizesOperator evaluates the current local policy immediately before a +// session starts. A non-unattended host must fail closed when no local consent +// callback is available; accepting the password alone would bypass supervised +// mode. Re-evaluating Unattended here also prevents a policy change during the +// login exchange from retaining an earlier unattended decision. +func (h *Host) authorizesOperator(operator string) bool { + if h.cfg.Unattended != nil && h.cfg.Unattended() { + return true + } + return h.cfg.Consent != nil && h.cfg.Consent(operator) } func (h *Host) waitPublicKey(ps *peerSession, ephemeral ephemeralKeyPair) error { @@ -212,3 +281,12 @@ func randomToken() string { _, _ = rand.Read(b) return hex.EncodeToString(b) } + +func validLoginPassword(password, salt, challenge string, provided []byte) bool { + password = strings.TrimSpace(password) + if password == "" { + return false + } + expected := hashPassword(password, salt, challenge) + return subtle.ConstantTimeCompare(provided, expected[:]) == 1 +} diff --git a/betterdesk-support-agent/signalhost/peer_codec_fuzz_test.go b/betterdesk-support-agent/signalhost/peer_codec_fuzz_test.go new file mode 100644 index 00000000..a40d76bb --- /dev/null +++ b/betterdesk-support-agent/signalhost/peer_codec_fuzz_test.go @@ -0,0 +1,69 @@ +package signalhost + +import ( + "bytes" + "net" + "testing" + "time" +) + +const maxPeerFrameFuzzInput = 64 * 1024 + +type fuzzPeerConn struct { + reader *bytes.Reader +} + +func newFuzzPeerConn(data []byte) *fuzzPeerConn { + return &fuzzPeerConn{reader: bytes.NewReader(data)} +} + +func (c *fuzzPeerConn) Read(p []byte) (int, error) { return c.reader.Read(p) } +func (c *fuzzPeerConn) Write(p []byte) (int, error) { return len(p), nil } +func (c *fuzzPeerConn) Close() error { return nil } +func (c *fuzzPeerConn) LocalAddr() net.Addr { return fuzzPeerAddr("local") } +func (c *fuzzPeerConn) RemoteAddr() net.Addr { return fuzzPeerAddr("remote") } +func (c *fuzzPeerConn) SetDeadline(time.Time) error { return nil } +func (c *fuzzPeerConn) SetReadDeadline(time.Time) error { return nil } +func (c *fuzzPeerConn) SetWriteDeadline(time.Time) error { return nil } + +type fuzzPeerAddr string + +func (a fuzzPeerAddr) Network() string { return "fuzz" } +func (a fuzzPeerAddr) String() string { return string(a) } + +// FuzzPeerFrameFraming exercises both malformed peer headers and bounded, +// well-framed payloads without allowing a fuzz case to request a large buffer. +func FuzzPeerFrameFraming(f *testing.F) { + f.Add([]byte{}) + f.Add([]byte{0x04, 'a'}) + f.Add([]byte{0xff, 0xff, 0xff, 0xff}) + f.Add(append(encodePeerHeader(3), []byte("abc")...)) + + f.Fuzz(func(t *testing.T, raw []byte) { + if len(raw) > maxPeerFrameFuzzInput { + t.Skip() + } + + // Exercise arbitrary headers only when the declared payload is small + // enough for a bounded fuzz run. Oversized headers are still parsed. + if _, payloadLen, err := readPeerHeader(newFuzzPeerConn(raw)); err == nil && payloadLen <= maxPeerFrameFuzzInput { + _, _ = readPeerFrame(newFuzzPeerConn(raw), 0) + } + + // A known-valid framing must round-trip the exact payload. + wire := append(encodePeerHeader(len(raw)), raw...) + got, err := readPeerFrame(newFuzzPeerConn(wire), 0) + if len(raw) == 0 { + if err == nil { + t.Fatal("zero-length peer frame was accepted") + } + return + } + if err != nil { + t.Fatalf("read valid peer frame: %v", err) + } + if !bytes.Equal(got, raw) { + t.Fatalf("peer frame payload mismatch: got %d bytes, want %d", len(got), len(raw)) + } + }) +} diff --git a/betterdesk-support-agent/signalhost/peer_input.go b/betterdesk-support-agent/signalhost/peer_input.go index c38a7b67..8b1f501a 100644 --- a/betterdesk-support-agent/signalhost/peer_input.go +++ b/betterdesk-support-agent/signalhost/peer_input.go @@ -2,26 +2,56 @@ package signalhost import ( "log" + "time" bdagent "github.com/unitronix/betterdesk-agent/agent" pb "github.com/unitronix/betterdesk-server/proto" ) -func handlePeerMessage(msg *pb.Message, st *streamState) { +func (h *Host) handlePeerMessage(msg *pb.Message, st *streamState) { + if !h.accessAllowed() { + return + } if me := msg.GetMouseEvent(); me != nil { - injectMouse(me) + if h.cfg.DesktopEnabled { + injectMouse(me) + } return } if ke := msg.GetKeyEvent(); ke != nil { - injectKey(ke) + if h.cfg.DesktopEnabled { + injectKey(ke) + } + return + } + if msg.GetAudioFrame() != nil { + // Audio capture/playback is not implemented in the support host. Keep + // the policy check explicit so a future bridge cannot bypass branding. + if !h.cfg.AudioEnabled { + return + } return } if misc := msg.GetMisc(); misc != nil { - if misc.GetRefreshVideo() || misc.GetRefreshVideoDisplay() != 0 { - if st != nil { - st.forceKeyframe.Store(true) + if misc.GetRestartRemoteDevice() { + // The host deliberately has no privileged remote-restart handler. + // Consult the policy before ignoring the request so adding one later + // cannot accidentally bypass a disabled restart capability. + if !h.cfg.RestartEnabled { + return + } + return + } + if option := misc.GetOption(); option != nil { + if st != nil && st.applyPeerOptions(option, time.Now()) { + log.Printf("[signalhost] applied bounded video settings update") + } + return + } + if misc.GetRefreshVideo() || misc.GetRefreshVideoDisplay() != 0 { + if st != nil && st.requestKeyframe(time.Now()) { + log.Printf("[signalhost] refresh video requested") } - log.Printf("[signalhost] refresh video requested") } return } diff --git a/betterdesk-support-agent/signalhost/signal.go b/betterdesk-support-agent/signalhost/signal.go index 2e62c123..e216783c 100644 --- a/betterdesk-support-agent/signalhost/signal.go +++ b/betterdesk-support-agent/signalhost/signal.go @@ -2,6 +2,7 @@ package signalhost import ( "context" + "errors" "log" "net" "time" @@ -15,12 +16,20 @@ const ( udpTimeout = 3 * time.Second ) +var errAccessDisabled = errors.New("incoming access disabled") + func (h *Host) runLoop(ctx context.Context) { for { if ctx.Err() != nil { return } + if !h.accessAllowed() { + return + } if err := h.runUDP(ctx); err != nil { + if errors.Is(err, errAccessDisabled) { + return + } log.Printf("[signalhost] loop error: %v", err) } select { @@ -32,16 +41,29 @@ func (h *Host) runLoop(ctx context.Context) { } func (h *Host) runUDP(ctx context.Context) error { + if !h.accessAllowed() { + return errAccessDisabled + } id, err := loadIdentity(h.cfg.DataDir, "") if err != nil { return err } + h.setIdentity(id) conn, err := net.Dial("udp", h.cfg.SignalAddr) if err != nil { return err } defer conn.Close() + closeWatcher := make(chan struct{}) + defer close(closeWatcher) + go func() { + select { + case <-ctx.Done(): + _ = conn.Close() + case <-closeWatcher: + } + }() _ = conn.SetDeadline(time.Time{}) var serial int32 @@ -66,7 +88,7 @@ func (h *Host) runUDP(ctx context.Context) error { _ = conn.SetReadDeadline(time.Now().Add(udpTimeout)) buf := make([]byte, 4096) if n, err := conn.Read(buf); err == nil { - h.handleUDPMessage(conn, id, buf[:n]) + h.handleUDPMessage(ctx, conn, id, buf[:n]) } ticker := time.NewTicker(heartbeatInterval) @@ -77,11 +99,17 @@ func (h *Host) runUDP(ctx context.Context) error { case <-ctx.Done(): return nil case <-ticker.C: + if !h.accessAllowed() { + return errAccessDisabled + } if err := sendRegister(); err != nil { return err } default: } + if !h.accessAllowed() { + return errAccessDisabled + } _ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond)) n, err := conn.Read(buf) @@ -91,11 +119,14 @@ func (h *Host) runUDP(ctx context.Context) error { } return err } - h.handleUDPMessage(conn, id, buf[:n]) + h.handleUDPMessage(ctx, conn, id, buf[:n]) } } -func (h *Host) handleUDPMessage(conn net.Conn, id *identity, data []byte) { +func (h *Host) handleUDPMessage(ctx context.Context, conn net.Conn, id *identity, data []byte) { + if !h.accessAllowed() { + return + } msg, err := codec.DecodeUDP(data) if err != nil { return @@ -108,7 +139,7 @@ func (h *Host) handleUDPMessage(conn net.Conn, id *identity, data []byte) { case *pb.RendezvousMessage_RelayResponse: rr := u.RelayResponse if rr.GetUuid() != "" && rr.GetRelayServer() != "" { - go h.handleIncomingRelay(rr.GetRelayServer(), rr.GetUuid()) + go h.handleIncomingRelay(ctx, rr.GetRelayServer(), rr.GetUuid()) } case *pb.RendezvousMessage_RequestRelay: rr := u.RequestRelay @@ -117,7 +148,7 @@ func (h *Host) handleUDPMessage(conn net.Conn, id *identity, data []byte) { relay = h.cfg.RelayAddr } if rr.GetUuid() != "" { - go h.handleIncomingRelay(relay, rr.GetUuid()) + go h.handleIncomingRelay(ctx, relay, rr.GetUuid()) } } } diff --git a/betterdesk-support-agent/signalhost/stream.go b/betterdesk-support-agent/signalhost/stream.go index 20a601c2..610225eb 100644 --- a/betterdesk-support-agent/signalhost/stream.go +++ b/betterdesk-support-agent/signalhost/stream.go @@ -4,33 +4,32 @@ import ( "context" "io" "log" - "sync" - "sync/atomic" "time" bdagent "github.com/unitronix/betterdesk-agent/agent" pb "github.com/unitronix/betterdesk-server/proto" ) -const streamFPS = 15 - -type streamState struct { - forceKeyframe atomic.Bool -} - // streamSession sends H.264 frames and processes peer input until the connection closes. -func (h *Host) streamSession(ps *peerSession) error { +func (h *Host) streamSession(ps *peerSession, codec negotiatedVideoCodec, options *pb.OptionMessage) error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - st := &streamState{} + st := newStreamState(codec, options) inputDone := make(chan struct{}) go func() { defer close(inputDone) h.readPeerInput(ctx, ps, st) + // A closed relay must also stop the video encoder. Without this, a + // local disconnect closed only the input reader while ffmpeg kept the + // session alive until its next unrelated exit. + cancel() }() - err := h.streamH264(ctx, ps, st) + var err error + if codec == videoCodecH264 { + err = h.streamH264(ctx, ps, st) + } cancel() <-inputDone return err @@ -50,68 +49,120 @@ func (h *Host) readPeerInput(ctx context.Context, ps *peerSession, st *streamSta } return } - handlePeerMessage(frame, st) + h.handlePeerMessage(frame, st) } } func (h *Host) streamH264(ctx context.Context, ps *peerSession, st *streamState) error { - args := ffmpegStreamArgs(streamFPS) - if len(args) == 0 { - return h.streamScreenshotFallback(ctx, ps, st) - } - - cmd, stdout, err := startFFmpegCapture(ctx, args) - if err != nil { - log.Printf("[signalhost] ffmpeg: %v", err) - return h.streamScreenshotFallback(ctx, ps, st) - } - defer func() { _ = cmd.Wait() }() - var pts int64 - var writeMu sync.Mutex - readAnnexBFrames(ctx, stdout, func(au []byte, keyframe bool) { - if st.forceKeyframe.Swap(false) { - keyframe = true + for { + settings := st.settings() + args := ffmpegStreamArgsForQuality(settings.fps, settings.quality) + if len(args) == 0 { + return h.streamScreenshotFallback(ctx, ps, st) } - vf := videoFrameH264(au, keyframe, pts) - writeMu.Lock() - err := ps.write(vf) - writeMu.Unlock() + + encoderCtx, cancelEncoder := context.WithCancel(ctx) + cmd, stdout, err := startFFmpegCapture(encoderCtx, args) if err != nil { - log.Printf("[signalhost] video frame: %v", err) + cancelEncoder() + log.Printf("[signalhost] ffmpeg: %v", err) + return h.streamScreenshotFallback(ctx, ps, st) } - pts++ - }) - return nil + st.markEncoderStarted(time.Now()) + + done := make(chan error, 1) + go func() { + var writeErr error + readAnnexBFrames(encoderCtx, stdout, func(au []byte, keyframe bool) { + if writeErr != nil { + return + } + started := time.Now() + if err := ps.write(videoFrameH264(au, keyframe, pts)); err != nil { + writeErr = err + cancelEncoder() + return + } + pts++ + st.observeWrite(time.Since(started)) + }) + done <- writeErr + }() + + select { + case <-ctx.Done(): + cancelEncoder() + <-done + _ = cmd.Wait() + return nil + case <-st.reconfigure: + cancelEncoder() + writeErr := <-done + _ = cmd.Wait() + if writeErr != nil { + return writeErr + } + if ctx.Err() != nil { + return nil + } + continue + case writeErr := <-done: + cancelEncoder() + waitErr := cmd.Wait() + if writeErr != nil { + return writeErr + } + if ctx.Err() != nil { + return nil + } + if waitErr != nil { + log.Printf("[signalhost] H.264 capture ended: %v", waitErr) + } + return h.streamScreenshotFallback(ctx, ps, st) + } + } } func (h *Host) streamScreenshotFallback(ctx context.Context, ps *peerSession, st *streamState) error { - ticker := time.NewTicker(500 * time.Millisecond) - defer ticker.Stop() - var pts int64 for { + settings := st.settings() + timer := time.NewTimer(frameInterval(settings.fps)) select { case <-ctx.Done(): + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } return nil - case <-ticker.C: - jpeg, err := bdagent.CaptureScreenshotJPEG() - if err != nil || len(jpeg) == 0 { - continue + case <-st.reconfigure: + if !timer.Stop() { + select { + case <-timer.C: + default: + } } - au, key, encErr := encodeJPEGToH264(ctx, jpeg) - if encErr != nil || len(au) == 0 { - continue - } - if st.forceKeyframe.Load() { - key = true - st.forceKeyframe.Store(false) - } - if err := ps.write(videoFrameH264(au, key, pts)); err != nil { - return err - } - pts++ + continue + case <-timer.C: } + + jpeg, err := bdagent.CaptureScreenshotJPEG() + if err != nil || len(jpeg) == 0 { + continue + } + au, key, encErr := encodeJPEGToH264(ctx, jpeg, settings.quality) + if encErr != nil || len(au) == 0 { + continue + } + started := time.Now() + if err := ps.write(videoFrameH264(au, key, pts)); err != nil { + return err + } + pts++ + st.observeWrite(time.Since(started)) } } diff --git a/betterdesk-support-agent/signalhost/stream_fuzz_test.go b/betterdesk-support-agent/signalhost/stream_fuzz_test.go new file mode 100644 index 00000000..998c9093 --- /dev/null +++ b/betterdesk-support-agent/signalhost/stream_fuzz_test.go @@ -0,0 +1,32 @@ +package signalhost + +import ( + "bytes" + "context" + "testing" +) + +const maxAnnexBFuzzInput = 128 * 1024 + +// FuzzAnnexBFraming checks that malformed H.264 Annex-B input cannot panic or +// make the bounded stream splitter emit a frame larger than its input. +func FuzzAnnexBFraming(f *testing.F) { + f.Add([]byte{}) + f.Add([]byte{ + 0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1f, + 0x00, 0x00, 0x01, 0x65, 0x88, 0x84, + 0x00, 0x00, 0x01, 0x61, 0x9a, + }) + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > maxAnnexBFuzzInput { + t.Skip() + } + + readAnnexBFrames(context.Background(), bytes.NewReader(data), func(au []byte, _ bool) { + if len(au) > len(data) { + t.Fatalf("Annex-B frame length = %d, input length = %d", len(au), len(data)) + } + }) + }) +} diff --git a/betterdesk-support-agent/signalhost/stream_linux.go b/betterdesk-support-agent/signalhost/stream_linux.go index 71c519cf..ac5777bb 100644 --- a/betterdesk-support-agent/signalhost/stream_linux.go +++ b/betterdesk-support-agent/signalhost/stream_linux.go @@ -2,14 +2,26 @@ package signalhost -import "fmt" +import ( + "fmt" + "os" + "strings" +) func ffmpegStreamArgs(fps int) []string { + display := linuxX11CaptureDisplay() + if display == "" { + // Do not guess :0.0 from a service or pure-Wayland context. Returning + // nil makes the relay use its screenshot fallback, which can use + // portal-aware tools when available. A real PipeWire portal stream + // needs OpenPipeWireRemote FD handoff and is not claimed here. + return nil + } return []string{ "-hide_banner", "-loglevel", "error", "-f", "x11grab", "-framerate", fmt.Sprintf("%d", fps), - "-i", ":0.0", + "-i", display, "-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency", @@ -18,3 +30,10 @@ func ffmpegStreamArgs(fps int) []string { "-", } } + +// linuxX11CaptureDisplay returns a display only when the agent inherited an +// explicit X11/XWayland session. On a pure Wayland desktop, $DISPLAY is empty +// and ffmpegStreamArgs must not imply that a PipeWire node or X root exists. +func linuxX11CaptureDisplay() string { + return strings.TrimSpace(os.Getenv("DISPLAY")) +} diff --git a/betterdesk-support-agent/signalhost/stream_linux_test.go b/betterdesk-support-agent/signalhost/stream_linux_test.go new file mode 100644 index 00000000..5fbd4138 --- /dev/null +++ b/betterdesk-support-agent/signalhost/stream_linux_test.go @@ -0,0 +1,27 @@ +//go:build linux + +package signalhost + +import "testing" + +func TestLinuxFFmpegStreamUsesInheritedX11Display(t *testing.T) { + t.Setenv("DISPLAY", ":42") + + args := ffmpegStreamArgs(15) + if len(args) == 0 { + t.Fatal("expected X11 capture arguments") + } + if got, want := args[8], ":42"; got != want { + t.Fatalf("capture display = %q, want %q", got, want) + } +} + +func TestLinuxFFmpegStreamDoesNotGuessDisplayOnPureWayland(t *testing.T) { + t.Setenv("WAYLAND_DISPLAY", "wayland-0") + t.Setenv("XDG_SESSION_TYPE", "wayland") + t.Setenv("DISPLAY", "") + + if args := ffmpegStreamArgs(15); args != nil { + t.Fatalf("ffmpegStreamArgs() = %#v, want nil screenshot fallback", args) + } +} diff --git a/betterdesk-support-agent/state.go b/betterdesk-support-agent/state.go index 0682192f..7dff65dc 100644 --- a/betterdesk-support-agent/state.go +++ b/betterdesk-support-agent/state.go @@ -33,18 +33,18 @@ const ( // is still exposed to the local user through the UI, where it is decrypted in // memory on demand. The file is additionally written with 0600 permissions. type AppState struct { - DeviceID string `json:"device_id"` - InstallationSecret string `json:"installation_secret,omitempty"` - MachineUUID string `json:"machine_uuid,omitempty"` - AccessMode string `json:"access_mode"` - AccessPassword string `json:"access_password"` - CustomPassword bool `json:"custom_password"` - Language string `json:"language"` - TOTPEnabled bool `json:"totp_enabled,omitempty"` - TOTPSecret string `json:"totp_secret,omitempty"` - DeviceToken string `json:"device_token,omitempty"` - EnrollmentStatus string `json:"enrollment_status,omitempty"` - EnrollmentMessage string `json:"enrollment_message,omitempty"` + DeviceID string `json:"device_id"` + InstallationSecret string `json:"installation_secret,omitempty"` + MachineUUID string `json:"machine_uuid,omitempty"` + AccessMode string `json:"access_mode"` + AccessPassword string `json:"access_password"` + CustomPassword bool `json:"custom_password"` + Language string `json:"language"` + TOTPEnabled bool `json:"totp_enabled,omitempty"` + TOTPSecret string `json:"totp_secret,omitempty"` + DeviceToken string `json:"device_token,omitempty"` + EnrollmentStatus string `json:"enrollment_status,omitempty"` + EnrollmentMessage string `json:"enrollment_message,omitempty"` // Last-known-good connection endpoints (transport resilience). LastGoodCDAP string `json:"last_good_cdap,omitempty"` LastGoodAPI string `json:"last_good_api,omitempty"` @@ -305,7 +305,12 @@ func (s *AppState) SetEnrollment(status, deviceID, token, message string) error s.DeviceID = deviceID } s.EnrollmentStatus = status - if token != "" { + // Pending and rejected outcomes are authoritative non-approved states. + // Retaining a previous credential here would let later enrollment + // requests keep presenting a token the server has revoked or rejected. + if status == EnrollmentPending || status == EnrollmentRejected { + s.DeviceToken = "" + } else if token != "" { s.DeviceToken = token } s.EnrollmentMessage = message diff --git a/betterdesk-support-agent/status.go b/betterdesk-support-agent/status.go index ed15b33b..e828a9e5 100644 --- a/betterdesk-support-agent/status.go +++ b/betterdesk-support-agent/status.go @@ -4,6 +4,8 @@ import ( "time" ) +const enrollmentRevalidationInterval = time.Minute + // startStatusLoop polls the engine connection state and refreshes the status // label. In Fyne 2.5 widget setters are safe to call from a background // goroutine, so the label is updated directly. @@ -11,12 +13,35 @@ func (u *ui) startStatusLoop() { go func() { ticker := time.NewTicker(3 * time.Second) defer ticker.Stop() + var lastEnrollmentCheck time.Time for range ticker.C { u.updateStatus() + if !lastEnrollmentCheck.IsZero() && + time.Since(lastEnrollmentCheck) < enrollmentRevalidationInterval { + continue + } + status, _, _ := u.state.EnrollmentSnapshot() + if status != EnrollmentApproved || !u.brand.HasConnection() { + continue + } + lastEnrollmentCheck = time.Now() + go u.revalidateEnrollment() } }() } +func (u *ui) revalidateEnrollment() { + result, err := PollEnrollment(u.brand, u.state, version) + if err != nil { + // Retain the currently active enrollment on transient network failures; + // a verified rejected/revoked response is handled below and fails closed. + return + } + if result.Status != EnrollmentApproved { + u.onEnrollmentUpdate(result) + } +} + // updateStatus reflects enrollment and engine state in the status label and dot. func (u *ui) updateStatus() { if u.statusLbl == nil { diff --git a/betterdesk-support-agent/totp_ui.go b/betterdesk-support-agent/totp_ui.go index 49208018..0407731f 100644 --- a/betterdesk-support-agent/totp_ui.go +++ b/betterdesk-support-agent/totp_ui.go @@ -21,9 +21,12 @@ func (u *ui) fetchTOTPStatus() (deviceTOTPStatus, error) { deviceID := st.DeviceID token := st.DeviceToken st.mu.Unlock() - url := fmt.Sprintf("%s/devices/self/totp?device_id=%s&device_token=%s", apiBaseURL(u.brand), deviceID, token) var resp deviceTOTPStatus - code, err := apiJSON(http.MethodGet, url, nil, &resp) + code, err := apiJSON(http.MethodPost, apiBaseURL(u.brand)+"/devices/self/totp", map[string]string{ + "device_id": deviceID, + "device_token": token, + "action": "status", + }, &resp) if err != nil { return resp, err } diff --git a/betterdesk-support-agent/transport_prefs.go b/betterdesk-support-agent/transport_prefs.go index 31f9975c..f7e7ed40 100644 --- a/betterdesk-support-agent/transport_prefs.go +++ b/betterdesk-support-agent/transport_prefs.go @@ -38,7 +38,7 @@ func CandidateCDAPWebSockets(b Branding, st *AppState) []string { seen := map[string]bool{} add := func(u string) { u = strings.TrimRight(strings.TrimSpace(u), "/") - if u == "" || seen[u] { + if u == "" || seen[u] || !b.allowsEndpoint(u) { return } seen[u] = true @@ -52,12 +52,15 @@ func CandidateCDAPWebSockets(b Branding, st *AppState) []string { if b.Server != nil { add(strings.TrimSpace(b.Server.CDAPURL)) } - // Derived fallbacks: swap ws/wss when TLS branding flips. - primary := b.CDAPWebSocketURL() - if strings.HasPrefix(primary, "wss://") { - add("ws://" + strings.TrimPrefix(primary, "wss://")) - } else if strings.HasPrefix(primary, "ws://") { - add("wss://" + strings.TrimPrefix(primary, "ws://")) + // A local developer may deliberately test a scheme change. Distributed + // profiles never derive a plaintext fallback from a signed WSS endpoint. + if !isReleaseBuild() { + primary := b.CDAPWebSocketURL() + if strings.HasPrefix(primary, "wss://") { + add("ws://" + strings.TrimPrefix(primary, "wss://")) + } else if strings.HasPrefix(primary, "ws://") { + add("wss://" + strings.TrimPrefix(primary, "ws://")) + } } return out } @@ -68,7 +71,7 @@ func CandidateAPIBases(b Branding, st *AppState) []string { seen := map[string]bool{} add := func(u string) { u = strings.TrimRight(strings.TrimSpace(u), "/") - if u == "" || seen[u] { + if u == "" || seen[u] || !b.allowsEndpoint(u) { return } seen[u] = true @@ -85,6 +88,23 @@ func CandidateAPIBases(b Branding, st *AppState) []string { return out } +func (b Branding) allowsEndpoint(endpoint string) bool { + if !isReleaseBuild() { + return true + } + endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/") + if !strings.HasPrefix(strings.ToLower(endpoint), "https://") && + !strings.HasPrefix(strings.ToLower(endpoint), "wss://") { + return false + } + for _, allowed := range b.AllowedEndpoints { + if endpoint == strings.TrimRight(strings.TrimSpace(allowed), "/") { + return true + } + } + return false +} + // healthURLFromCDAPWS converts a CDAP websocket URL to its /cdap/health HTTP twin. func healthURLFromCDAPWS(ws string) string { ws = strings.TrimSpace(ws) diff --git a/betterdesk-support-agent/transport_prefs_release_test.go b/betterdesk-support-agent/transport_prefs_release_test.go new file mode 100644 index 00000000..63b43ff2 --- /dev/null +++ b/betterdesk-support-agent/transport_prefs_release_test.go @@ -0,0 +1,28 @@ +//go:build release + +package main + +import "testing" + +func TestReleaseTransportCandidatesRejectDowngradeAndStaleEndpoints(t *testing.T) { + brand := Branding{ + AllowedEndpoints: []string{ + "https://desk.example.test/api", + "wss://desk.example.test:21122/cdap", + }, + Server: &ServerBranding{ + APIURL: "https://desk.example.test/api", + CDAPURL: "wss://desk.example.test:21122/cdap", + }, + } + state := &AppState{ + LastGoodCDAP: "ws://desk.example.test:21122/cdap", + LastGoodAPI: "http://desk.example.test/api", + } + if got := CandidateCDAPWebSockets(brand, state); len(got) != 1 || got[0] != brand.Server.CDAPURL { + t.Fatalf("CDAP candidates = %v", got) + } + if got := CandidateAPIBases(brand, state); len(got) != 1 || got[0] != brand.Server.APIURL { + t.Fatalf("API candidates = %v", got) + } +} diff --git a/betterdesk-support-agent/urls.go b/betterdesk-support-agent/urls.go index 0b47b96a..1446fbd4 100644 --- a/betterdesk-support-agent/urls.go +++ b/betterdesk-support-agent/urls.go @@ -11,6 +11,11 @@ const defaultCDAPPort = 21122 // useTLS reports whether baked branding expects TLS for HTTP/WebSocket calls. func (b Branding) useTLS() bool { + // Distributed binaries have a signed profile and must never allow a + // configuration/environment fallback to plaintext HTTP or WebSocket. + if isReleaseBuild() { + return true + } if b.UseHTTPS { return true } @@ -109,7 +114,11 @@ func (b Branding) APIHealthURL() string { // CDAPWebSocketURL is the remote-session gateway URL passed to the engine. func (b Branding) CDAPWebSocketURL() string { if b.Server != nil && strings.TrimSpace(b.Server.CDAPURL) != "" { - return strings.TrimRight(strings.TrimSpace(b.Server.CDAPURL), "/") + u := strings.TrimRight(strings.TrimSpace(b.Server.CDAPURL), "/") + if isReleaseBuild() && !strings.HasPrefix(strings.ToLower(u), "wss://") { + return "" + } + return u } host := hostFromAddr(b.ServerAddress) if strings.Contains(host, ":") { diff --git a/docs/features/WEB_REMOTE_CLIENT_PLAN.md b/docs/features/WEB_REMOTE_CLIENT_PLAN.md index 362cca4a..5463a14c 100644 --- a/docs/features/WEB_REMOTE_CLIENT_PLAN.md +++ b/docs/features/WEB_REMOTE_CLIENT_PLAN.md @@ -1,4 +1,10 @@ -# BetterDesk Web Remote Client - Phased Implementation Plan +# BetterDesk Web Remote Client - Historical Phased Implementation Plan + +> Provenance notice: this document predates the clean-room compatibility +> process. It is retained for product history, not as an implementation +> specification. New compatibility work must follow +> [`support-agent-provenance.md`](../important/support-agent-provenance.md) +> and a BetterDesk-owned, versioned wire specification. > Browser-based remote desktop client integrated into the BetterDesk web panel. > Users click a device in the device list → connect and control it via the browser. @@ -85,10 +91,12 @@ Establish WebSocket communication with hbbs, implement protobuf serialization, a ### Tasks #### 1.1 Protobuf Generation -- [ ] Copy `message.proto` and `rendezvous.proto` from `hbb_common/protos/` +- [ ] Use the canonical BetterDesk-owned schema and reproducible generation + pipeline; do not copy external source or generated artifacts. - [ ] Set up `protobufjs` build pipeline (pbjs/pbts) - [ ] Generate JavaScript message classes + TypeScript definitions -- [ ] Verify encoding/decoding matches Rust `protobuf` crate output +- [ ] Verify encoding/decoding against independently authored black-box test + vectors #### 1.2 WebSocket Connection Manager - [ ] Create `ConnectionManager` class @@ -470,7 +478,7 @@ web-nodejs/ | NaCl encryption mismatch | Blocker | Test with `tweetnacl-js` vs `sodiumoxide` early | | WebCodecs not available | High | Fallback to `libvpx-wasm` (slower) or MSE | | hbbr WS proxy adds latency | Medium | Move to native hbbr WS (Phase 0 Option A) | -| Protobuf version mismatch | High | Use exact same .proto files from hbb_common | +| Protobuf version mismatch | High | Use the versioned BetterDesk compatibility schema and black-box vectors | | Browser blocks clipboard | Low | Clipboard requires HTTPS — already implemented | | H.265 not supported | Low | Negotiate VP9/H264 instead | diff --git a/docs/important/support-agent-conformance.md b/docs/important/support-agent-conformance.md new file mode 100644 index 00000000..d42e0f9b --- /dev/null +++ b/docs/important/support-agent-conformance.md @@ -0,0 +1,64 @@ +# Support Agent conformance verification + +## Current release status + +**Stock-client black-box conformance: REQUIRED / MANUAL / NOT PASSED.** + +GitHub Actions does not download, run, or authenticate a stock desktop client, +and it does not use a relay, BetterDesk server, signing credential, branded +bundle, or any other external test environment. The automated checks described +below validate only repository-local framing, parser, policy, and build +contracts. A successful CI run is not evidence of full desktop-client +compatibility and must not be used to make a clean-room or compatibility release +claim. + +## Automated CI coverage + +The `Support Agent CI` workflow runs without repository secrets: + +- Linux runs `go vet ./...` and `go test -race -count=1 ./...` for + `betterdesk-support-agent` and its shared `betterdesk-agent` dependency. +- Short, single-worker Go fuzz smoke tests exercise support-agent peer framing, + support-agent Annex-B framing, and the shared agent's image, Annex-B, IVF, + VP9, and AV1 framing/header readers. +- Windows and macOS run the non-race Go unit suites for both agent modules. + Linux additionally runs the X11/Wayland capability-contract tests and Windows + runs its capture-strategy contract. These are API/build checks, not desktop + runtime tests. + +These checks do not require a proprietary client, production credentials, +hardware capture devices, FFmpeg/GStreamer, PipeWire, an X11 display, or a +network-accessible BetterDesk service. + +## Required manual black-box lab + +Before any compatibility release, a release operator must run and record an +isolated black-box lab against each supported stock desktop-client version and +each supported host platform. The following scenarios remain **required and +unpassed** until their results are attached to the release record: + +1. Inbound handshake and relay setup, including malformed, oversized, and + truncated frames. +2. Authentication failure, unattended password, supervised consent, and 2FA + acceptance/rejection behavior. +3. Desktop capture, remote input, disconnect, reconnect, and revocation while + a session is active. +4. Capability negotiation and explicit rejection of unsupported clipboard, + files, terminal, audio, multi-monitor, privacy, and restart operations. +5. Windows capture/input, Linux X11 capture/input, and pure-Wayland portal and + PipeWire behavior; an unavailable capability must be reported as unavailable + rather than silently falling back. + +For every lab run, retain the client version and checksum, host OS and session +type, agent build identifier, server/relay version, tested capability set, +packet/test-vector provenance, pass/fail result, and defects found. Run the lab +with disposable credentials and data only. Do not add stock-client source, +generated artifacts, or copied test fixtures to this repository while carrying +out the black-box work. + +## Release gate + +CI failure blocks the release. CI success leaves the black-box gate in the +**REQUIRED / MANUAL / NOT PASSED** state until the completed lab record and the +provenance evidence required by +[`support-agent-provenance.md`](support-agent-provenance.md) are reviewed. diff --git a/docs/important/support-agent-desktop-wire-interop.md b/docs/important/support-agent-desktop-wire-interop.md new file mode 100644 index 00000000..9bbf5c63 --- /dev/null +++ b/docs/important/support-agent-desktop-wire-interop.md @@ -0,0 +1,143 @@ +# Support Agent desktop-client wire interoperability + +## Status and scope + +`betterdesk-support-agent/internal/interoperability` establishes a +BetterDesk-owned adapter contract and a lossy black-box conformance harness. +It does **not** implement a desktop-client wire protocol, register a production +adapter, or demonstrate compatibility with any stock desktop client. + +The repository's automated tests exercise only independently generated, +synthetic harness inputs. No stock binary, external source, generated schema, +packet capture, credential, or relay environment is used in CI. A passing test +therefore proves only the boundary and harness behavior described here. + +The current release gate remains +[Support Agent conformance verification](support-agent-conformance.md). This +document adds the implementation and lab contract needed before that manual +gate can be satisfied; it does not change its **REQUIRED / MANUAL / NOT +PASSED** status. + +## Independently-owned adapter boundary + +The intended path is: + +```text +accepted inbound desktop transport + -> interoperability.Adapter + -> interoperability.SessionAuthorizer + -> native passive session core + -> target-side platform services +``` + +`Adapter` receives an already accepted `InboundTransport`, not a listener, +dialer, rendezvous configuration, or controller-side connection API. It must +present a transient `Admission` to `SessionAuthorizer` before target-side +services are used. The grant presentation is opaque and must never be retained +in an adapter result, audit event, log entry, or conformance record. + +The current `signalhost`, `betterdesk-server/codec`, and +`betterdesk-server/proto` path is listed by +`TemporaryAuditedSurfaces()` as a **temporary audited compatibility surface**. +That inventory is not provenance approval and must not be used as a dependency +or implementation template for a replacement adapter. New adapter code belongs +under `betterdesk-support-agent/internal/interoperability/` and must be written +from a reviewed BetterDesk specification and the safe observations below. + +No production wiring has been added yet. Wiring a placeholder adapter to the +current relay would bypass the purpose of the boundary because there is no +independently implemented parser or completed server-grant bridge for that wire +path. + +## Safe observed-wire vectors + +The harness models a vector as a sequence of BetterDesk semantic observations: + +- vector ID and BetterDesk specification revision; +- `black_box` or `synthetic` kind; +- direction, generic phase, and bounded payload size for each step; +- an optional SHA-256 fingerprint only for independently generated synthetic + test bytes of fixed length. + +It intentionally does not model external message names, field numbers, +generated types, or raw packet bytes. For stock-client lab traffic, callers +must use `ObservationMetadataOnly`; this retains only direction, phase, and +byte count. `black_box` vectors and runs reject payload fingerprints so a +credential, nonce, or low-entropy value cannot be represented indirectly in a +release record. + +`Harness.Run` checks ordered direction/phase assertions and size bounds through +the `BlackBoxProbe` interface. The resulting `RunResult` contains only the +vector identifier and lossy observations. The harness has no disk writer and +does not persist a raw payload. + +Use a `synthetic` vector only for BetterDesk-generated test bytes. It cannot be +relabelled as evidence of stock-client behavior. Use a `black_box` vector only +after a lab operator has observed the scenario against a stock binary and has +recorded metadata with no client payloads retained in the repository. + +## Manual stock-client lab contract + +Run this contract in an isolated environment before asserting compatibility for +any desktop-client version. + +1. Obtain an unmodified stock desktop client outside this repository. Record + its product/version, download source, SHA-256, and platform. Do not commit + the binary, source, generated artifacts, captured frames, or a copied schema. +2. Use a disposable agent host, relay/server environment, credentials, 2FA + secret, and desktop data. Keep packet captures outside the repository and + delete them according to the lab's retention policy after the metadata + record is prepared. +3. Assign the run a BetterDesk specification revision and a `black_box` vector + ID. The probe may classify each event with the generic phases + `transport_opened`, `handshake`, `authentication`, `capability`, `desktop`, + `input`, `rejected`, and `transport_closed`; it must record stock traffic + with metadata-only observations. +4. Exercise, at minimum, an agent-targeted relay/handshake, failed + authentication, 2FA acceptance and rejection where enabled, supervised + consent, unattended access, malformed/truncated/oversized input, desktop + output and input after authorization, unsupported-capability refusal, + local disconnect, reconnect, and policy revocation. A failed or unavailable + capability is a result to record, not a reason to silently mark it passed. +5. For every run, retain a release-record entry containing the vector ID and + revision, client version/checksum, host OS and session type, agent build, + server/relay version, enabled capabilities, tested scenario, lossy + observation sequence, result, and defects. Do not include passwords, grants, + 2FA values, packet bytes, screenshots, clipboard contents, or private keys. +6. Repeat the applicable scenarios for every supported client version and host + platform. A pass applies only to that exact vector, client build, platform, + and environment; it does not establish feature parity or general + compatibility. + +The release operator must still satisfy the provenance evidence in +[Support Agent compatibility provenance](support-agent-provenance.md) and the +manual release gate in +[Support Agent conformance verification](support-agent-conformance.md). + +## Implementation admission criteria + +Before adding a real adapter implementation: + +- publish and review a BetterDesk-owned observed-wire specification revision; +- add a safe vector and its lab record for each new behavior; +- keep the implementation free of imports from the temporary audited surfaces, + external desktop-client source trees, and copied schemas or fixtures; +- bind every session to server authorization and the native passive-session + policy before capture, input, or another target-side capability is enabled; +- add negative conformance coverage for malformed input, authorization failure, + policy revocation, and capability refusal. + +The first implementation should claim only the specific reviewed vector and +client/platform combinations it has passed. Do not describe it as full +desktop-client compatibility, a clean-room implementation, or independently +licensable code until the separate provenance and release gates are complete. + +## Local verification + +```powershell +cd betterdesk-support-agent +go test ./internal/interoperability/... +``` + +The tests are intentionally local and synthetic. A stock-client lab run is +manual evidence and is not replaced by this command. diff --git a/docs/important/support-agent-provenance.md b/docs/important/support-agent-provenance.md new file mode 100644 index 00000000..26499d4a --- /dev/null +++ b/docs/important/support-agent-provenance.md @@ -0,0 +1,98 @@ +# Support Agent compatibility provenance + +## Status + +**Release gate — verification required.** BetterDesk Support Agent is a +BetterDesk product. Its stock desktop-client compatibility surface must not be +described as a fork, clone, independently licensable component, or +clean-room implementation until this register and its supporting evidence have +been reviewed. + +This is a technical provenance process, not legal advice. Licensing, +trademarks, patents, and contributor rights require qualified legal review. + +## Scope + +The review covers: + +- `betterdesk-support-agent/signalhost/` and its replacement compatibility + adapter; +- `betterdesk-server/protos/`, `web-nodejs/protos/`, and generated artifacts; +- compatibility framing, cryptography, generated client configuration, and + test fixtures; +- branded Support Agent binaries and their third-party notices. + +It explicitly does not treat a protocol name, port number, or observed +interoperability behavior as proof that an implementation is independently +authored. + +## Required evidence + +Before a compatibility release: + +1. Record the author, date, source-of-truth specification, and review status + for every compatibility module and protocol schema. +2. Compare the current four protocol-schema copies with the relevant external + releases and retain the comparison result for counsel. A documented historic + instruction to copy a schema is an audit trigger, not evidence that a + current file is safe to relicence. +3. If provenance cannot be demonstrated, replace the affected code or schema + from a new BetterDesk specification. Do not use cosmetic renaming or + formatting changes as remediation. +4. Publish an SBOM and `THIRD_PARTY_NOTICES` for every distributable artifact, + including bundled codec, UI, and vendor components. +5. Document the result in the release record before making clean-room or + licensing claims. + +## Clean-room workflow + +The implementation uses two deliberately separated inputs: + +1. A specification role records only public standards and black-box observations + made against stock clients: packet captures, externally observable state + transitions, public port layout, and independently written test vectors. +2. An implementation role receives the BetterDesk specification and those + vectors, not external source code, generated artifacts, copied comments, or + source-derived test files. + +The specification is versioned. Every new wire feature identifies the +specification revision and an independently generated test vector. + +## Architecture boundary + +Support Agent has a native BetterDesk session core. Any desktop-client wire +compatibility lives behind an explicitly named compatibility adapter with a +narrow API: + +``` +Compatibility adapter -> Session authorizer -> Passive session core -> Platform services +``` + +The adapter may never create a user-facing external-client UI, an outbound +connection workflow, or a privilege bypass. It receives a server-authorized, +short-lived session grant and can only open an inbound target-side session. + +Until the replacement adapter has passed this gate, `signalhost` is an audited +legacy compatibility surface. It must not become a licensing boundary by +assertion alone. + +## Schema rules + +- `betterdesk-server/protos/` is the current canonical BetterDesk-owned schema + source. `web-nodejs/protos/` is a committed runtime artifact generated by + `npm run protocols:sync`, never a second source. +- Generated Go and browser artifacts are checked for drift in CI. +- Any legacy `hbb` mapping is confined to the protocol edge. New application + packages, APIs, and documentation use BetterDesk names. +- Schema changes require a compatibility test and a provenance entry. + +## Repository safeguards + +`npm run check:provenance` verifies that active design documentation no longer +contains prohibited copy instructions and that this register is present. It is +a guardrail, not a replacement for source review or legal review. + +New code and documentation must not add direct external-source imports, +upstream clone/build instructions, or copied protocol artifacts outside an +explicitly approved compatibility test fixture with attribution and notice. + diff --git a/web-nodejs/config/config.js b/web-nodejs/config/config.js index 94c0211c..df0bc236 100644 --- a/web-nodejs/config/config.js +++ b/web-nodejs/config/config.js @@ -185,6 +185,9 @@ module.exports = { sslKeyPath: process.env.SSL_KEY_PATH || '', sslCaPath: process.env.SSL_CA_PATH || '', httpRedirect: (process.env.HTTP_REDIRECT_HTTPS || 'true').toLowerCase() === 'true', + // Optional SHA-256 certificate pin embedded in signed Support Agent + // bundles. This is public verifier material, never a private key. + agentServerCertPin: String(process.env.BETTERDESK_AGENT_SERVER_CERT_PIN || '').trim(), // Paths dataDir: DATA_DIR, diff --git a/web-nodejs/lib/generatorBuildTypes.js b/web-nodejs/lib/generatorBuildTypes.js new file mode 100644 index 00000000..115747e4 --- /dev/null +++ b/web-nodejs/lib/generatorBuildTypes.js @@ -0,0 +1,55 @@ +'use strict'; + +/** + * Canonical product and queue values shared by generator persistence, routes, + * and build workers. Legacy rows used "agent" for Support Agent bundles. + */ + +const PRODUCT_TYPES = Object.freeze({ + SUPPORT_AGENT: 'support-agent', + AGENT_CLIENT: 'agent-client', + RDCLIENT: 'rdclient', +}); + +const QUEUED_BUILD_STATUSES = new Set(['queued', 'pending']); + +function canonicalProductType(raw) { + const value = String(raw ?? '').trim().toLowerCase(); + if (value === PRODUCT_TYPES.RDCLIENT) return PRODUCT_TYPES.RDCLIENT; + if (value === PRODUCT_TYPES.AGENT_CLIENT || value === 'agent_client') { + return PRODUCT_TYPES.AGENT_CLIENT; + } + if (value === PRODUCT_TYPES.SUPPORT_AGENT || value === 'support_agent' || value === 'agent') { + return PRODUCT_TYPES.SUPPORT_AGENT; + } + return null; +} + +function normalizeProductType(raw, fallback = PRODUCT_TYPES.SUPPORT_AGENT) { + return canonicalProductType(raw) + || canonicalProductType(fallback) + || PRODUCT_TYPES.SUPPORT_AGENT; +} + +function isProductType(raw, expected) { + return normalizeProductType(raw) === expected; +} + +function isQueuedBuildStatus(status) { + return QUEUED_BUILD_STATUSES.has(String(status ?? '').trim().toLowerCase()); +} + +function normalizeBuildStatus(status, fallback = 'queued') { + const value = String(status ?? '').trim().toLowerCase(); + if (isQueuedBuildStatus(value)) return 'queued'; + return value || fallback; +} + +module.exports = { + PRODUCT_TYPES, + QUEUED_BUILD_STATUSES, + normalizeProductType, + isProductType, + isQueuedBuildStatus, + normalizeBuildStatus, +}; diff --git a/web-nodejs/package.json b/web-nodejs/package.json index 1d196c53..84f6920d 100644 --- a/web-nodejs/package.json +++ b/web-nodejs/package.json @@ -9,6 +9,9 @@ "test": "jest --detectOpenHandles --forceExit", "test:ci": "jest --detectOpenHandles --forceExit --ci", "check:frontend": "node scripts/check-frontend.js", + "check:provenance": "node scripts/check-cleanroom-provenance.js", + "protocols:check": "node scripts/sync-protocol-schemas.js", + "protocols:sync": "node scripts/sync-protocol-schemas.js --write", "i18n:check": "node scripts/i18n-check.js --system web-nodejs", "i18n:check:all": "node scripts/i18n-check.js", "i18n:commercialization": "node scripts/patch-commercialization-i18n.js", diff --git a/web-nodejs/protos/message.proto b/web-nodejs/protos/message.proto index 25984287..6b8923fa 100644 --- a/web-nodejs/protos/message.proto +++ b/web-nodejs/protos/message.proto @@ -1,5 +1,5 @@ -// BetterDesk Console — Message Protocol Definitions -// Copyright (c) 2024-2026 UNITRONIX. Licensed under AGPL-3.0. +// BetterDesk Server — Message Protocol Definitions +// Copyright (c) 2025-2026 UNITRONIX. Licensed under AGPL-3.0. // // These protocol definitions describe message formats for interoperability // with RustDesk clients. They are independently authored specifications, @@ -8,6 +8,8 @@ syntax = "proto3"; package hbb; +option go_package = "github.com/unitronix/betterdesk-server/proto"; + message EncodedVideoFrame { bytes data = 1; bool key = 2; diff --git a/web-nodejs/protos/rendezvous.proto b/web-nodejs/protos/rendezvous.proto index 3cb0c9da..61b9a987 100644 --- a/web-nodejs/protos/rendezvous.proto +++ b/web-nodejs/protos/rendezvous.proto @@ -1,5 +1,5 @@ -// BetterDesk Console — Rendezvous Protocol Definitions -// Copyright (c) 2024-2026 UNITRONIX. Licensed under AGPL-3.0. +// BetterDesk Server — Rendezvous Protocol Definitions +// Copyright (c) 2025-2026 UNITRONIX. Licensed under AGPL-3.0. // // These protocol definitions describe message formats for interoperability // with RustDesk clients. They are independently authored specifications, @@ -8,6 +8,8 @@ syntax = "proto3"; package hbb; +option go_package = "github.com/unitronix/betterdesk-server/proto"; + message RegisterPeer { string id = 1; int32 serial = 2; @@ -216,6 +218,25 @@ message HealthCheck { string token = 1; } +message HeaderEntry { + string name = 1; + string value = 2; +} + +message HttpProxyRequest { + string method = 1; + string path = 2; + repeated HeaderEntry headers = 3; + bytes body = 4; +} + +message HttpProxyResponse { + int32 status = 1; + repeated HeaderEntry headers = 2; + bytes body = 3; + string error = 4; +} + message RendezvousMessage { oneof union { RegisterPeer register_peer = 6; @@ -239,5 +260,7 @@ message RendezvousMessage { OnlineResponse online_response = 24; KeyExchange key_exchange = 25; HealthCheck hc = 26; + HttpProxyRequest http_proxy_request = 27; + HttpProxyResponse http_proxy_response = 28; } } diff --git a/web-nodejs/public/js/generator.js b/web-nodejs/public/js/generator.js index 16cbf14a..9a0da5ff 100644 --- a/web-nodejs/public/js/generator.js +++ b/web-nodejs/public/js/generator.js @@ -251,6 +251,7 @@ const map = { ready: t('generator.build_status_ready', 'Ready'), pending: t('generator.build_status_pending', 'Queued'), + queued: t('generator.build_status_pending', 'Queued'), building: t('generator.build_status_building', 'Building'), failed: t('generator.build_status_failed', 'Failed'), }; @@ -260,13 +261,16 @@ function summarizeBuilds(builds) { const counts = { ready: 0, pending: 0, building: 0, failed: 0 }; for (const b of builds || []) { - if (counts[b.status] != null) counts[b.status]++; + const status = b.status === 'queued' ? 'pending' : b.status; + if (counts[status] != null) counts[status]++; } return counts; } function buildsNeedPoll(builds) { - return (builds || []).some(b => b.status === 'pending' || b.status === 'building'); + return (builds || []).some( + (b) => b.status === 'queued' || b.status === 'pending' || b.status === 'building' + ); } function stopBuildsPoll() { @@ -485,7 +489,7 @@ const revokedBadge = bundle.revoked ? `${escapeText(t('generator.revoked', 'Revoked'))}` : ''; - const pt = bundle.product_type || 'agent-client'; + const pt = bundle.product_type || 'support-agent'; const productBadge = pt === 'rdclient' ? `${escapeText(t('generator.product_rdclient', 'RdClient'))}` : pt === 'support-agent' || pt === 'agent' @@ -569,7 +573,7 @@ function setEditorForBundle(bundle) { state.currentId = bundle.bundle_id; state.currentBundle = bundle; - state.productType = bundle.product_type || 'agent-client'; + state.productType = bundle.product_type || 'support-agent'; state.dirty = false; state.slugManual = true; stopBuildsPoll(); diff --git a/web-nodejs/routes/generator.routes.js b/web-nodejs/routes/generator.routes.js index 31a410c0..019c8d03 100644 --- a/web-nodejs/routes/generator.routes.js +++ b/web-nodejs/routes/generator.routes.js @@ -23,6 +23,7 @@ const db = require('../services/database'); const config = require('../config/config'); const brandingService = require('../services/brandingService'); const conn = require('../services/agentBundleConnection'); +const { PRODUCT_TYPES, normalizeProductType } = require('../lib/generatorBuildTypes'); // Branding payloads may carry a base64-encoded logo up to 10 MB; expand the // default 2 MB JSON body limit on the bundle CRUD + preview endpoints only. @@ -54,7 +55,7 @@ function serializeBundle(row) { created_at: row.created_at, updated_at: row.updated_at, download_url: `/d/${publicId}`, - product_type: row.product_type || 'agent', + product_type: normalizeProductType(row.product_type), }; } @@ -91,7 +92,7 @@ function injectServerBranding(input) { function finalizeBundleBrandingSync(input) { const branding = { ...(input || {}) }; const host = branding.server_host || conn.defaultServerHost(); - const useHttps = branding.use_https ?? conn.defaultUseHttps(); + const useHttps = branding.use_https ?? true; const urls = conn.buildServerUrls(host, useHttps); branding.server = { address: urls.address, @@ -106,6 +107,27 @@ function finalizeBundleBrandingSync(input) { return branding; } +function addSupportProfileValidity(branding, now = new Date()) { + const ttlDaysRaw = Number.parseInt(process.env.BETTERDESK_AGENT_PROFILE_TTL_DAYS || '365', 10); + const ttlDays = Number.isFinite(ttlDaysRaw) + ? Math.max(1, Math.min(ttlDaysRaw, 730)) + : 365; + const expiresAt = new Date(now.getTime() + ttlDays * 24 * 60 * 60 * 1000); + const endpoints = [ + branding.server?.address, + branding.server?.api_url, + branding.server?.cdap_url, + ].filter((endpoint, index, all) => { + if (typeof endpoint !== 'string' || !endpoint) return false; + if (!/^https:\/\//i.test(endpoint) && !/^wss:\/\//i.test(endpoint)) return false; + return all.indexOf(endpoint) === index; + }); + branding.profile_issued_at = now.toISOString(); + branding.profile_expires_at = expiresAt.toISOString(); + branding.allowed_endpoints = endpoints; + return branding; +} + /** * Merge operator connection settings and inject server key. * Support-agent bundles do NOT embed a shared enrollment token — each @@ -124,7 +146,7 @@ async function finalizeBundleBranding(input) { delete branding.has_enrollment_token; delete branding.enrollment_token_masked; branding.server_host = input.server_host || conn.defaultServerHost(); - branding.use_https = !!(input.use_https ?? conn.defaultUseHttps()); + branding.use_https = !!(input.use_https ?? true); return branding; } @@ -137,18 +159,10 @@ function publicBrandingView(branding) { return out; } -function normalizeProductType(raw) { - const v = String(raw || 'agent-client').toLowerCase(); - if (v === 'rdclient') return 'rdclient'; - if (v === 'agent-client' || v === 'agent_client') return 'agent-client'; - if (v === 'support-agent' || v === 'support_agent' || v === 'agent') return 'support-agent'; - return 'agent-client'; -} - function resolveBuildWorker(productType) { const pt = normalizeProductType(productType); - if (pt === 'rdclient') return rdclientBuildWorker; - if (pt === 'agent-client') return agentClientBuildWorker; + if (pt === PRODUCT_TYPES.RDCLIENT) return rdclientBuildWorker; + if (pt === PRODUCT_TYPES.AGENT_CLIENT) return agentClientBuildWorker; return buildWorker; } @@ -199,7 +213,7 @@ router.get('/api/generator/defaults', requireAuth, requireAdmin, async (req, res success: true, data: { server_host: conn.defaultServerHost(), - use_https: conn.defaultUseHttps(), + use_https: true, api_port: conn.defaultApiPort(), public_key: (await keyService.resolvePublicKey()) || '', }, @@ -212,8 +226,8 @@ router.post('/api/generator/bundles', requireAuth, requireAdmin, async (req, res if (!name) { return res.status(400).json({ success: false, error: req.t('generator.errors.name_required') }); } - const productType = normalizeProductType(req.body.product_type); - const validateFn = productType === 'rdclient' + const productType = normalizeProductType(req.body.product_type, PRODUCT_TYPES.AGENT_CLIENT); + const validateFn = productType === PRODUCT_TYPES.RDCLIENT ? bundleService.validateRdclientBranding : bundleService.validateBranding; const { valid, errors, normalized: base } = validateFn(req.body.branding || {}); @@ -234,14 +248,17 @@ router.post('/api/generator/bundles', requireAuth, requireAdmin, async (req, res details: [slugResult.error], }); } - const normalized = productType === 'rdclient' + const normalized = productType === PRODUCT_TYPES.RDCLIENT ? { ...base, bundle_id: bundleId, server_url: base.panel_url } : await finalizeBundleBranding(base); - if (productType !== 'rdclient') { + if (productType !== PRODUCT_TYPES.RDCLIENT) { normalized.bundle_id = bundleId; - normalized.product_name = productType === 'agent-client' + normalized.product_name = productType === PRODUCT_TYPES.AGENT_CLIENT ? (normalized.company_name ? `${normalized.company_name} Agent` : 'BetterDesk Agent') : (normalized.company_name || 'BetterDesk Support'); + if (productType === PRODUCT_TYPES.SUPPORT_AGENT) { + addSupportProfileValidity(normalized); + } } const brandingHash = bundleService.hashBranding(normalized); const created = await db.createAgentBundle({ @@ -271,16 +288,27 @@ router.put('/api/generator/bundles/:bundleId', requireAuth, requireAdmin, async if (!name) { return res.status(400).json({ success: false, error: req.t('generator.errors.name_required') }); } + const productType = normalizeProductType(existing.product_type); const existingBranding = parseBranding(existing.branding); - const { valid, errors, normalized: base } = bundleService.validateBranding(req.body.branding || existingBranding); + const validateFn = productType === PRODUCT_TYPES.RDCLIENT + ? bundleService.validateRdclientBranding + : bundleService.validateBranding; + const { valid, errors, normalized: base } = validateFn(req.body.branding || existingBranding); if (!valid) { return res.status(400).json({ success: false, error: req.t('generator.errors.validation_failed'), errors, details: errors }); } - const normalized = await finalizeBundleBranding(base); - normalized.bundle_id = req.params.bundleId; - normalized.product_name = normalizeProductType(existing.product_type) === 'agent-client' - ? (normalized.company_name ? `${normalized.company_name} Agent` : 'BetterDesk Agent') - : (normalized.company_name || 'BetterDesk Support'); + const normalized = productType === PRODUCT_TYPES.RDCLIENT + ? { ...base, bundle_id: req.params.bundleId, server_url: base.panel_url } + : await finalizeBundleBranding(base); + if (productType !== PRODUCT_TYPES.RDCLIENT) { + normalized.bundle_id = req.params.bundleId; + normalized.product_name = productType === PRODUCT_TYPES.AGENT_CLIENT + ? (normalized.company_name ? `${normalized.company_name} Agent` : 'BetterDesk Agent') + : (normalized.company_name || 'BetterDesk Support'); + if (productType === PRODUCT_TYPES.SUPPORT_AGENT) { + addSupportProfileValidity(normalized); + } + } const brandingHash = bundleService.hashBranding(normalized); let slug = existing.slug || ''; if (req.body.slug !== undefined) { diff --git a/web-nodejs/scripts/check-cleanroom-provenance.js b/web-nodejs/scripts/check-cleanroom-provenance.js new file mode 100644 index 00000000..2b35377e --- /dev/null +++ b/web-nodejs/scripts/check-cleanroom-provenance.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Small, dependency-free guardrail for the compatibility provenance process. + * It intentionally validates only repository policy documents. It cannot + * prove independent authorship and is not a substitute for legal review. + */ + +const { existsSync, readFileSync } = require('fs'); +const { join } = require('path'); + +const repoRoot = join(__dirname, '..', '..'); +const register = join(repoRoot, 'docs', 'important', 'support-agent-provenance.md'); +const webRemotePlan = join(repoRoot, 'docs', 'features', 'WEB_REMOTE_CLIENT_PLAN.md'); +const serverContext = join(repoRoot, '.github', 'go-server-context.md'); +const notices = join(repoRoot, 'THIRD_PARTY_NOTICES.md'); + +const failures = []; + +for (const file of [register, webRemotePlan, serverContext, notices]) { + if (!existsSync(file)) failures.push(`Missing provenance policy file: ${file}`); +} + +if (!failures.length) { + const registerText = readFileSync(register, 'utf8'); + const webRemoteText = readFileSync(webRemotePlan, 'utf8'); + const serverContextText = readFileSync(serverContext, 'utf8'); + const noticesText = readFileSync(notices, 'utf8'); + + if (!registerText.includes('Clean-room workflow')) { + failures.push('The provenance register must define the clean-room workflow.'); + } + if (!registerText.includes('Release gate')) { + failures.push('The provenance register must define a release gate.'); + } + if (!noticesText.includes('SBOM')) { + failures.push('Third-party notices must describe the SBOM release requirement.'); + } + if (/Copy `message\.proto` and `rendezvous\.proto` from `hbb_common\/protos`/i.test(webRemoteText)) { + failures.push('The web remote plan still instructs contributors to copy external protocol schemas.'); + } + if (/Use exact same \.proto files from hbb_common/i.test(webRemoteText)) { + failures.push('The web remote plan still requires external protocol schema copies.'); + } + if (/\.proto files .*have \*\*no copyright headers\*\*/i.test(serverContextText)) { + failures.push('The Go server context still makes an unverified provenance claim.'); + } +} + +if (failures.length) { + console.error(`Clean-room provenance check failed:\n- ${failures.join('\n- ')}`); + process.exit(1); +} + +console.log('Clean-room provenance policy check passed.'); diff --git a/web-nodejs/scripts/sync-protocol-schemas.js b/web-nodejs/scripts/sync-protocol-schemas.js new file mode 100644 index 00000000..307a03ca --- /dev/null +++ b/web-nodejs/scripts/sync-protocol-schemas.js @@ -0,0 +1,55 @@ +#!/usr/bin/env node +'use strict'; + +/** + * The Go-server schemas are the single canonical source for the desktop-client + * compatibility edge. The web copies are runtime artifacts fetched by the + * browser protocol loader, so they are deliberately committed and checked for + * byte-for-byte drift rather than hand-maintained. + */ + +const fs = require('fs'); +const fsp = require('fs/promises'); +const path = require('path'); + +const write = process.argv.includes('--write'); +const repoRoot = path.resolve(__dirname, '..', '..'); +const canonicalDir = path.join(repoRoot, 'betterdesk-server', 'protos'); +const runtimeDir = path.join(repoRoot, 'web-nodejs', 'protos'); +const schemaNames = ['message.proto', 'rendezvous.proto']; + +async function main() { + const drift = []; + for (const name of schemaNames) { + const source = path.join(canonicalDir, name); + const output = path.join(runtimeDir, name); + const contents = await fsp.readFile(source); + let current = null; + try { + current = await fsp.readFile(output); + } catch (err) { + if (!err || err.code !== 'ENOENT') throw err; + } + if (current && Buffer.compare(contents, current) === 0) continue; + if (write) { + await fsp.mkdir(runtimeDir, { recursive: true }); + await fsp.writeFile(output, contents); + console.log(`Generated ${path.relative(repoRoot, output)} from ${path.relative(repoRoot, source)}.`); + } else { + drift.push(name); + } + } + if (drift.length) { + console.error( + `Protocol schema drift: ${drift.join(', ')}. ` + + 'Run: node web-nodejs/scripts/sync-protocol-schemas.js --write' + ); + process.exit(1); + } + if (!write) console.log('Protocol runtime schemas match the canonical BetterDesk source.'); +} + +main().catch((err) => { + console.error(`Protocol schema synchronization failed: ${err.message}`); + process.exit(1); +}); diff --git a/web-nodejs/services/agentBuildWorker.js b/web-nodejs/services/agentBuildWorker.js index 84556e3f..4638c2b2 100644 --- a/web-nodejs/services/agentBuildWorker.js +++ b/web-nodejs/services/agentBuildWorker.js @@ -15,7 +15,14 @@ const { spawn } = require('child_process'); const db = require('./database'); const bundleService = require('./agentBundleService'); +const { resolveBundleSigningKeyFile } = require('./bundleSigningKey'); const config = require('../config/config'); +const { readProductVersion } = require('../lib/productVersion'); +const { + PRODUCT_TYPES, + normalizeProductType, + isQueuedBuildStatus, +} = require('../lib/generatorBuildTypes'); try { const envFile = process.env.BETTERDESK_BUILD_ENV_FILE || '/etc/betterdesk/build.env'; @@ -49,6 +56,7 @@ const BUILD_ORDER = (bundleService.PLATFORMS || []).map( (p) => `${p.platform}/${p.arch}/${p.format}` ); const IS_WINDOWS = process.platform === 'win32'; +const PROJECT_ROOT = path.resolve(__dirname, '..', '..'); const VENDORED_GO_BIN = path.join( config.dataDir || path.join(__dirname, '..', 'data'), 'go-toolchain', 'go', 'bin', IS_WINDOWS ? 'go.exe' : 'go' @@ -224,6 +232,55 @@ function _buildOrderIndex(row) { return idx >= 0 ? idx : BUILD_ORDER.length + 1; } +function _isSupportAgentBundle(bundle) { + return normalizeProductType(bundle?.product_type) === PRODUCT_TYPES.SUPPORT_AGENT; +} + +function _getSupportAgentBuildVersion(opts = {}) { + const rootDir = opts.rootDir || PROJECT_ROOT; + return readProductVersion({ + rootDir, + consoleDir: opts.consoleDir || path.join(rootDir, 'web-nodejs'), + fallback: '0.1.0', + }); +} + +function _getAgentSourceStamp() { + try { + return fs.readFileSync(AGENT_SOURCE_STAMP_FILE, 'utf8').trim() || 'unversioned'; + } catch (_) { + return 'unversioned'; + } +} + +function _buildFingerprint( + brandingHash, + version = _getSupportAgentBuildVersion(), + signingKeyFingerprint = '' +) { + return JSON.stringify({ + brandingHash, + sourceStamp: _getAgentSourceStamp(), + version, + signingKeyFingerprint, + }); +} + +async function _injectSupportAgentVersion(workDir, opts = {}) { + const version = opts.version || _getSupportAgentBuildVersion(opts); + const mainPath = path.join(workDir, 'main.go'); + const source = await fsp.readFile(mainPath, 'utf8'); + const next = source.replace( + /^(\s*var\s+version\s*=\s*)"[^"]*"/m, + `$1"${version}"` + ); + if (next === source) { + throw new Error('support-agent version variable not found in build workspace'); + } + await fsp.writeFile(mainPath, next, 'utf8'); + return version; +} + function _compileRoot(brandingHash, osName) { return path.join(WORK_ROOT, brandingHash, osName); } @@ -290,7 +347,7 @@ async function enqueueBuildsForHash(brandingHash, { force = false } = {}) { platform: p.platform, arch: p.arch, format: p.format, - status: 'pending', + status: 'queued', artifactPath: existing?.artifact_path || null, artifactSize: existing?.artifact_size || 0, artifactSha256: existing?.artifact_sha256 || null, @@ -299,11 +356,14 @@ async function enqueueBuildsForHash(brandingHash, { force = false } = {}) { } } -/** Queue rebuilds for every non-revoked generator bundle (e.g. after agent source update). */ +/** Queue Support Agent rebuilds after a Support Agent source update. */ async function requeueAllBundleBuilds() { const bundles = await db.listAgentBundles({ includeRevoked: false }); const hashes = [...new Set( - bundles.filter(b => !b.revoked).map(b => b.branding_hash).filter(Boolean) + bundles + .filter((bundle) => !bundle.revoked && _isSupportAgentBundle(bundle)) + .map((bundle) => bundle.branding_hash) + .filter(Boolean) )]; for (const hash of hashes) { await enqueueBuildsForHash(hash, { force: true }); @@ -315,6 +375,7 @@ async function requeueAllBundleBuilds() { async function rebuildBundleById(bundleId) { const row = await db.getAgentBundle(bundleId); if (!row) return { success: false, error: 'not_found' }; + if (!_isSupportAgentBundle(row)) return { success: false, error: 'not_support_agent' }; if (!row.branding_hash) return { success: false, error: 'missing_hash' }; await enqueueBuildsForHash(row.branding_hash, { force: true }); const platforms = (bundleService.PLATFORMS || []).length; @@ -328,7 +389,7 @@ async function requeueFailedToolchainBuilds() { const bundles = await db.listAgentBundles({ includeRevoked: false }); let requeued = 0; for (const b of bundles) { - if (b.revoked) continue; + if (b.revoked || !_isSupportAgentBundle(b)) continue; const builds = await db.listAgentBundleBuildsForHash(b.branding_hash); for (const row of builds) { const err = String(row.error_message || ''); @@ -339,7 +400,7 @@ async function requeueFailedToolchainBuilds() { platform: row.platform, arch: row.arch, format: row.format, - status: 'pending', + status: 'queued', artifactPath: row.artifact_path || null, artifactSize: row.artifact_size || 0, artifactSha256: row.artifact_sha256 || null, @@ -425,7 +486,7 @@ async function requeuePlatformBuild(brandingHash, platform, arch, format) { platform, arch, format, - status: 'pending', + status: 'queued', artifactPath: null, artifactSize: 0, artifactSha256: null, @@ -457,6 +518,7 @@ function getBuildWorkerStatus() { goHealthy: _goBinaryHealthy(goBin), sourceRoot: SOURCE_ROOT, sourceStamp, + buildVersion: _getSupportAgentBuildVersion(), rebuildPending, mesaDll: _mesaDllPath() || null, msiBuilder: _resolveMsiBuilder(), @@ -554,7 +616,7 @@ async function reconcileAgentSourceDrift() { if (fs.existsSync(REBUILD_FLAG_FILE)) return null; const bundles = await db.listAgentBundles({ includeRevoked: false }); - const active = bundles.filter((b) => !b.revoked); + const active = bundles.filter((bundle) => !bundle.revoked && _isSupportAgentBundle(bundle)); if (active.length === 0) return null; const supportRoot = _agentSourceDirs().supportAgent; @@ -665,7 +727,7 @@ async function _hasBuildInProgress() { if (_activeBuilds > 0) return true; const bundles = await db.listAgentBundles(); for (const b of bundles) { - if (b.revoked) continue; + if (b.revoked || !_isSupportAgentBundle(b)) continue; const builds = await db.listAgentBundleBuildsForHash(b.branding_hash); if (builds.some((r) => r.status === 'building')) return true; } @@ -682,8 +744,7 @@ async function _claimNextBuild() { }); for (const row of candidates) { const bundleRow = await _findBundleForHash(row.branding_hash); - const pt = bundleRow?.product_type || 'support-agent'; - if (pt === 'rdclient' || pt === 'agent-client') continue; + if (!bundleRow || !_isSupportAgentBundle(bundleRow)) continue; const profile = BUILD_PROFILES[`${row.platform}/${row.arch}/${row.format}`]; if (!profile) continue; await db.upsertAgentBundleBuild({ @@ -706,12 +767,10 @@ async function _listPendingBuilds(limit) { const bundles = await db.listAgentBundles(); const out = []; for (const b of bundles) { - if (b.revoked) continue; - const pt = b.product_type || 'support-agent'; - if (pt === 'rdclient' || pt === 'agent-client') continue; + if (b.revoked || !_isSupportAgentBundle(b)) continue; const builds = await db.listAgentBundleBuildsForHash(b.branding_hash); for (const r of builds) { - if (r.status === 'pending') out.push(r); + if (isQueuedBuildStatus(r.status)) out.push(r); if (out.length >= limit) break; } if (out.length >= limit) break; @@ -731,22 +790,32 @@ async function _runOne(buildRow) { if (!bundleRow) throw new Error(`no bundle with hash ${buildRow.branding_hash}`); const branding = JSON.parse(bundleRow.branding || '{}'); + _assertReleaseSupportProfile(branding); const compileDir = _compileRoot(buildRow.branding_hash, profile.os); const brandingFile = path.join(compileDir, 'resources', 'branding.json'); const binaryName = profile.os === 'windows' ? 'betterdesk-support.exe' : 'betterdesk-support'; const binaryPath = path.join(compileDir, 'dist', binaryName); + const buildVersion = _getSupportAgentBuildVersion(); + const signingKeyFile = await resolveBundleSigningKeyFile({ keysPath: config.keysPath }); + const signingKeyFingerprint = await _sha256OfFile(signingKeyFile); + const buildFingerprint = _buildFingerprint( + buildRow.branding_hash, + buildVersion, + signingKeyFingerprint + ); const shouldCompile = await _needsCompile( - compileDir, buildRow.branding_hash, binaryPath, profile.os + compileDir, buildFingerprint, binaryPath, profile.os ); await _materialiseWorkspace(compileDir, branding, { refreshSources: shouldCompile }); await _ensureGoToolchain(); if (shouldCompile) { - await _runGoBuild(compileDir, brandingFile, binaryPath, profile.os); + await _injectSupportAgentVersion(compileDir, { version: buildVersion }); + await _runGoBuild(compileDir, brandingFile, binaryPath, profile.os, signingKeyFile); await fsp.writeFile( path.join(compileDir, '.built_for'), - buildRow.branding_hash, + buildFingerprint, 'utf8' ); } else { @@ -798,15 +867,37 @@ async function _runOne(buildRow) { } } +function _assertReleaseSupportProfile(branding) { + const issuedAt = Date.parse(String(branding?.profile_issued_at || '')); + const expiresAt = Date.parse(String(branding?.profile_expires_at || '')); + const endpoints = Array.isArray(branding?.allowed_endpoints) ? branding.allowed_endpoints : []; + const required = [ + branding?.bundle_id, + branding?.server?.address, + branding?.server?.api_url, + branding?.server?.cdap_url, + ]; + if (required.some((value) => !String(value || '').trim()) + || !Number.isFinite(issuedAt) + || !Number.isFinite(expiresAt) + || expiresAt <= Math.max(issuedAt, Date.now()) + || endpoints.length < 3 + || endpoints.some((endpoint) => !/^https:\/\//i.test(endpoint) && !/^wss:\/\//i.test(endpoint))) { + throw new Error( + 'Support Agent bundle profile is incomplete or expired; save the bundle again to issue a signed HTTPS profile' + ); + } +} + async function _findBundleForHash(hash) { const all = await db.listAgentBundles(); return all.find(b => b.branding_hash === hash) || null; } -async function _needsCompile(workDir, brandingHash, binaryPath, targetOS) { +async function _needsCompile(workDir, buildFingerprint, binaryPath, targetOS) { try { const stamp = (await fsp.readFile(path.join(workDir, '.built_for'), 'utf8')).trim(); - if (stamp !== brandingHash) return true; + if (stamp !== buildFingerprint) return true; await fsp.access(binaryPath, fs.constants.R_OK); if (targetOS === 'linux') { const distDir = path.dirname(binaryPath); @@ -880,7 +971,7 @@ async function _ensureMesaForWindows(workDir) { return true; } -async function _runGoBuild(workDir, brandingPath, outputPath, targetOS) { +async function _runGoBuild(workDir, brandingPath, outputPath, targetOS, signingKeyFile) { await fsp.mkdir(path.dirname(outputPath), { recursive: true }); if (targetOS === 'windows') { await _ensureMesaForWindows(workDir); @@ -890,7 +981,13 @@ async function _runGoBuild(workDir, brandingPath, outputPath, targetOS) { if (targetOS === 'linux') { args.push('-d'); } - await _runProcess('/bin/bash', [buildScript, ...args], { cwd: workDir }); + if (!signingKeyFile) { + throw new Error('Support Agent branding signing key is required'); + } + await _runProcess('/bin/bash', [buildScript, ...args], { + cwd: workDir, + env: { BETTERDESK_BUNDLE_SIGNING_KEY_FILE: signingKeyFile }, + }); await fsp.access(outputPath, fs.constants.R_OK); if (targetOS === 'linux') { const distDir = path.dirname(outputPath); @@ -1196,5 +1293,16 @@ module.exports = { getGoBin, getBuildWorkerStatus, classifyBuildError, - _internals: { BUILD_PROFILES, BUILD_CACHE_DIR, ARTIFACT_ROOT, SOURCE_ROOT }, + _internals: { + BUILD_PROFILES, + BUILD_CACHE_DIR, + ARTIFACT_ROOT, + SOURCE_ROOT, + isSupportAgentBundle: _isSupportAgentBundle, + listPendingBuilds: _listPendingBuilds, + getSupportAgentBuildVersion: _getSupportAgentBuildVersion, + injectSupportAgentVersion: _injectSupportAgentVersion, + buildFingerprint: _buildFingerprint, + assertReleaseSupportProfile: _assertReleaseSupportProfile, + }, }; diff --git a/web-nodejs/services/agentBundleConnection.js b/web-nodejs/services/agentBundleConnection.js index fd124b80..341035de 100644 --- a/web-nodejs/services/agentBundleConnection.js +++ b/web-nodejs/services/agentBundleConnection.js @@ -10,6 +10,7 @@ const config = require('../config/config'); const HOST_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9.-]{0,253}[a-zA-Z0-9])?$/; const IP_V4_RE = /^(?:\d{1,3}\.){3}\d{1,3}$/; +const CERT_PIN_RE = /^[a-f0-9]{64}$/; function clip(s, max) { if (typeof s !== 'string') return ''; @@ -40,6 +41,14 @@ function formatOrigin(scheme, hostPart, port) { return omitPort ? `${scheme}://${hostPart}` : `${scheme}://${hostPart}:${port}`; } +function configuredCertificatePin() { + const normalized = String(config.agentServerCertPin || '') + .replace(/:/g, '') + .trim() + .toLowerCase(); + return CERT_PIN_RE.test(normalized) ? normalized : ''; +} + /** Suggested host prefill from local server config. */ function defaultServerHost() { try { @@ -98,6 +107,7 @@ function buildServerUrls(host, useHttps, apiPort) { return { address: origin, api_url: `${origin}/api`, + cert_pin: configuredCertificatePin(), cdap_port: cdapPort, cdap_url: cdapUrl, }; @@ -131,5 +141,6 @@ module.exports = { defaultUseHttps, normalizeServerHost, buildServerUrls, + configuredCertificatePin, connectionFingerprint, }; diff --git a/web-nodejs/services/agentBundleService.js b/web-nodejs/services/agentBundleService.js index 976252c6..80496b29 100644 --- a/web-nodejs/services/agentBundleService.js +++ b/web-nodejs/services/agentBundleService.js @@ -203,7 +203,13 @@ function validateBranding(input = {}) { } else { errors.push('server_host_required'); } - out.use_https = !!(input.use_https ?? input.useHttps ?? conn.defaultUseHttps()); + out.use_https = !!(input.use_https ?? input.useHttps ?? true); + // Console-generated artifacts are always distributed release builds. A + // local developer can use a non-release `go build` profile, but the + // production generator must never issue an HTTP/WS bundle. + if (!out.use_https) { + errors.push('https_required'); + } // Never accept enrollment_token from the browser — issued by backend only. if (input.server && typeof input.server === 'object') { @@ -359,7 +365,7 @@ function defaultBranding() { }, default_lang: 'en', server_host: '', - use_https: conn.defaultUseHttps(), + use_https: true, server: { address: '', api_url: '', public_key: '' }, }; } diff --git a/web-nodejs/services/agentClientBuildWorker.js b/web-nodejs/services/agentClientBuildWorker.js index 87afface..1b74d931 100644 --- a/web-nodejs/services/agentClientBuildWorker.js +++ b/web-nodejs/services/agentClientBuildWorker.js @@ -16,6 +16,11 @@ const { spawn } = require('child_process'); const db = require('./database'); const bundleService = require('./agentBundleService'); const config = require('../config/config'); +const { + PRODUCT_TYPES, + normalizeProductType, + isQueuedBuildStatus, +} = require('../lib/generatorBuildTypes'); try { const envFile = process.env.BETTERDESK_BUILD_ENV_FILE || '/etc/betterdesk/build.env'; @@ -119,6 +124,10 @@ const SIDECAR_NAMES = { 'windows/x64': 'betterdesk-agent-x86_64-pc-windows-msvc.exe', }; +function _isAgentClientBundle(bundle) { + return normalizeProductType(bundle?.product_type) === PRODUCT_TYPES.AGENT_CLIENT; +} + let _pollTimer = null; let _running = false; let _activeBuilds = 0; @@ -338,10 +347,10 @@ async function _listPendingAgentClientBuilds(limit) { const bundles = await db.listAgentBundles(); const out = []; for (const b of bundles) { - if (b.revoked || (b.product_type || 'agent') !== 'agent-client') continue; + if (b.revoked || !_isAgentClientBundle(b)) continue; const builds = await db.listAgentBundleBuildsForHash(b.branding_hash); for (const r of builds) { - if (r.status === 'pending') out.push(r); + if (isQueuedBuildStatus(r.status)) out.push(r); if (out.length >= limit) break; } if (out.length >= limit) break; @@ -353,7 +362,7 @@ async function _hasAgentClientBuildInProgress() { if (_activeBuilds > 0) return true; const bundles = await db.listAgentBundles(); for (const b of bundles) { - if ((b.product_type || 'agent') !== 'agent-client' || b.revoked) continue; + if (!_isAgentClientBundle(b) || b.revoked) continue; const builds = await db.listAgentBundleBuildsForHash(b.branding_hash); if (builds.some((r) => r.status === 'building')) return true; } @@ -374,7 +383,7 @@ async function enqueueBuildsForHash(brandingHash, { force = false } = {}) { platform: p.platform, arch: p.arch, format: p.format, - status: 'pending', + status: 'queued', artifactPath: existing?.artifact_path || null, artifactSize: existing?.artifact_size || 0, artifactSha256: existing?.artifact_sha256 || null, @@ -383,23 +392,64 @@ async function enqueueBuildsForHash(brandingHash, { force = false } = {}) { } } +async function requeueAllBundleBuilds() { + const bundles = await db.listAgentBundles({ includeRevoked: false }); + const hashes = [...new Set( + bundles + .filter((bundle) => !bundle.revoked && _isAgentClientBundle(bundle)) + .map((bundle) => bundle.branding_hash) + .filter(Boolean) + )]; + for (const hash of hashes) { + await enqueueBuildsForHash(hash, { force: true }); + } + return { bundles: hashes.length }; +} + async function rebuildBundleById(bundleId) { const row = await db.getAgentBundle(bundleId); if (!row) return { success: false, error: 'not_found' }; - if ((row.product_type || 'agent') !== 'agent-client') { + if (!_isAgentClientBundle(row)) { return { success: false, error: 'not_agent_client' }; } + if (!row.branding_hash) return { success: false, error: 'missing_hash' }; await enqueueBuildsForHash(row.branding_hash, { force: true }); return { success: true, platforms: (bundleService.PLATFORMS || []).length }; } +async function requeuePlatformBuild(brandingHash, platform, arch, format) { + if (!brandingHash || !platform || !arch || !format) { + return { success: false, error: 'missing_args' }; + } + const allowed = (bundleService.PLATFORMS || []).some( + (p) => p.platform === platform && p.arch === arch && p.format === format + ); + if (!allowed) return { success: false, error: 'unsupported_platform' }; + const bundle = await _findBundleForHash(brandingHash); + if (!bundle || !_isAgentClientBundle(bundle)) { + return { success: false, error: 'not_agent_client' }; + } + await db.upsertAgentBundleBuild({ + brandingHash, + platform, + arch, + format, + status: 'queued', + artifactPath: null, + artifactSize: 0, + artifactSha256: null, + errorMessage: '', + }); + return { success: true }; +} + async function _runOne(buildRow) { const key = `${buildRow.platform}/${buildRow.arch}/${buildRow.format}`; const profile = BUILD_PROFILES[key]; if (!profile) throw new Error(`unsupported profile ${key}`); const bundleRow = await _findBundleForHash(buildRow.branding_hash); - if (!bundleRow || (bundleRow.product_type || 'agent') !== 'agent-client') { + if (!bundleRow || !_isAgentClientBundle(bundleRow)) { throw new Error('not an agent-client bundle'); } @@ -507,5 +557,11 @@ module.exports = { startWorker, stopWorker, enqueueBuildsForHash, + requeueAllBundleBuilds, rebuildBundleById, + requeuePlatformBuild, + _internals: { + isAgentClientBundle: _isAgentClientBundle, + listPendingBuilds: _listPendingAgentClientBuilds, + }, }; diff --git a/web-nodejs/services/bundleSigningKey.js b/web-nodejs/services/bundleSigningKey.js new file mode 100644 index 00000000..5321480d --- /dev/null +++ b/web-nodejs/services/bundleSigningKey.js @@ -0,0 +1,65 @@ +'use strict'; + +/** + * Resolves the private Ed25519 key used to sign Support Agent branding + * profiles. The generated public half is embedded in each signed binary by + * the Go build helper; the private half never belongs in the bundle or the + * artifact cache. + */ + +const crypto = require('crypto'); +const fs = require('fs'); +const fsp = require('fs/promises'); +const path = require('path'); + +const DEFAULT_KEY_FILE = 'support-agent-branding-ed25519.pem'; + +async function validateSigningKeyFile(keyFile) { + const pem = await fsp.readFile(keyFile); + const key = crypto.createPrivateKey(pem); + if (key.asymmetricKeyType !== 'ed25519') { + throw new Error(`Support Agent branding key must be Ed25519, got ${key.asymmetricKeyType || 'unknown'}`); + } +} + +async function resolveBundleSigningKeyFile({ keysPath, env = process.env } = {}) { + const configured = String(env.BETTERDESK_BUNDLE_SIGNING_KEY_FILE || '').trim(); + if (configured) { + await validateSigningKeyFile(configured); + return configured; + } + if (!keysPath) { + throw new Error('keysPath is required to create the Support Agent branding signing key'); + } + + const keyFile = path.join(keysPath, DEFAULT_KEY_FILE); + try { + await validateSigningKeyFile(keyFile); + return keyFile; + } catch { + // Create below. The final link-safe write handles concurrent workers. + } + + await fsp.mkdir(keysPath, { recursive: true, mode: 0o700 }); + const { privateKey } = crypto.generateKeyPairSync('ed25519'); + const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }); + try { + const handle = await fsp.open(keyFile, 'wx', 0o600); + try { + await handle.writeFile(pem); + } finally { + await handle.close(); + } + } catch (err) { + if (!err || err.code !== 'EEXIST') throw err; + } + await fsp.chmod(keyFile, 0o600).catch(() => {}); + await validateSigningKeyFile(keyFile); + return keyFile; +} + +module.exports = { + DEFAULT_KEY_FILE, + resolveBundleSigningKeyFile, + validateSigningKeyFile, +}; diff --git a/web-nodejs/services/dbAdapter.js b/web-nodejs/services/dbAdapter.js index 4d37a514..ee55ed33 100644 --- a/web-nodejs/services/dbAdapter.js +++ b/web-nodejs/services/dbAdapter.js @@ -27,6 +27,7 @@ const fs = require('fs'); const agentBundleService = require('./agentBundleService'); const { hashAccessToken } = require('../lib/tokenHash'); const { redactAuditDetails } = require('../lib/logRedact'); +const { normalizeProductType, normalizeBuildStatus } = require('../lib/generatorBuildTypes'); // Lazy-loaded drivers — keeps startup fast when one backend isn't installed. let _sqlite = null; @@ -988,6 +989,31 @@ function createSqliteAdapter(config) { } } + function migrateAgentBundleProductTypesSqlite(db) { + try { + const cols = new Set(db.prepare('PRAGMA table_info(agent_bundles)').all().map(c => c.name)); + if (!cols.has('product_type')) { + db.exec("ALTER TABLE agent_bundles ADD COLUMN product_type TEXT NOT NULL DEFAULT 'support-agent'"); + console.log('[DB] Migration: added agent_bundles.product_type'); + } + // SQLite cannot alter a column default in place. Normalize legacy + // values now; callers still accept aliases for older deployments. + db.exec(` + UPDATE agent_bundles + SET product_type = CASE LOWER(TRIM(COALESCE(product_type, ''))) + WHEN 'rdclient' THEN 'rdclient' + WHEN 'agent-client' THEN 'agent-client' + WHEN 'agent_client' THEN 'agent-client' + ELSE 'support-agent' + END + WHERE product_type IS NULL + OR LOWER(TRIM(product_type)) NOT IN ('support-agent', 'agent-client', 'rdclient') + `); + } catch (e) { + console.warn('[DB] Migration agent_bundles.product_type error:', e.message); + } + } + function ensureAgentBundleTables(db) { db.exec(` CREATE TABLE IF NOT EXISTS agent_bundles ( @@ -998,6 +1024,7 @@ function createSqliteAdapter(config) { branding TEXT NOT NULL DEFAULT '{}', branding_hash TEXT NOT NULL DEFAULT '', created_by INTEGER DEFAULT NULL, + product_type TEXT NOT NULL DEFAULT 'support-agent', revoked INTEGER NOT NULL DEFAULT 0, download_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), @@ -1027,15 +1054,7 @@ function createSqliteAdapter(config) { CREATE INDEX IF NOT EXISTS idx_agent_bundle_builds_status ON agent_bundle_builds (status); `); migrateAgentBundleSlugsSqlite(db); - try { - const cols = new Set(db.prepare('PRAGMA table_info(agent_bundles)').all().map(c => c.name)); - if (!cols.has('product_type')) { - db.exec("ALTER TABLE agent_bundles ADD COLUMN product_type TEXT NOT NULL DEFAULT 'agent'"); - console.log('[DB] Migration: added agent_bundles.product_type'); - } - } catch (e) { - console.warn('[DB] Migration agent_bundles.product_type error:', e.message); - } + migrateAgentBundleProductTypesSqlite(db); } // -- Multi-tenancy tables ---------------------------------------------- @@ -3548,7 +3567,15 @@ function createSqliteAdapter(config) { const r = db.prepare(` INSERT INTO agent_bundles (bundle_id, slug, name, branding, branding_hash, created_by, product_type) VALUES (?, ?, ?, ?, ?, ?, ?) - `).run(bundleId, slug || null, name, branding, brandingHash, createdBy || null, productType || 'agent'); + `).run( + bundleId, + slug || null, + name, + branding, + brandingHash, + createdBy || null, + normalizeProductType(productType) + ); return db.prepare('SELECT * FROM agent_bundles WHERE id = ?').get(r.lastInsertRowid); }, @@ -3597,14 +3624,13 @@ function createSqliteAdapter(config) { async upsertAgentBundleBuild({ brandingHash, platform, arch, format, status, artifactPath, artifactSize, artifactSha256, errorMessage }) { const db = openMain(); - const ts = (status === 'building') ? "datetime('now')" : 'started_at'; - const finishTs = (status === 'ready' || status === 'failed') ? "datetime('now')" : 'finished_at'; + const buildStatus = normalizeBuildStatus(status); db.prepare(` INSERT INTO agent_bundle_builds ( branding_hash, platform, arch, format, status, artifact_path, artifact_size, artifact_sha256, error_message, started_at, finished_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ${status === 'building' ? "datetime('now')" : 'NULL'}, ${status === 'ready' || status === 'failed' ? "datetime('now')" : 'NULL'}) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ${buildStatus === 'building' ? "datetime('now')" : 'NULL'}, ${buildStatus === 'ready' || buildStatus === 'failed' ? "datetime('now')" : 'NULL'}) ON CONFLICT(branding_hash, platform, arch, format) DO UPDATE SET status = excluded.status, artifact_path = COALESCE(excluded.artifact_path, agent_bundle_builds.artifact_path), @@ -3615,7 +3641,7 @@ function createSqliteAdapter(config) { finished_at = CASE WHEN excluded.status IN ('ready','failed') THEN datetime('now') ELSE agent_bundle_builds.finished_at END, updated_at = datetime('now') `).run( - brandingHash, platform, arch, format, status, + brandingHash, platform, arch, format, buildStatus, artifactPath || null, artifactSize || 0, artifactSha256 || null, errorMessage || '' ); return this.getAgentBundleBuild({ brandingHash, platform, arch, format }); @@ -4165,6 +4191,7 @@ function createPostgresAdapter() { branding TEXT NOT NULL DEFAULT '{}', branding_hash TEXT NOT NULL DEFAULT '', created_by INTEGER DEFAULT NULL, + product_type TEXT NOT NULL DEFAULT 'support-agent', revoked BOOLEAN NOT NULL DEFAULT FALSE, download_count INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -4196,6 +4223,36 @@ function createPostgresAdapter() { } catch (e) { console.warn('[DB] Migration agent_bundles.slug error:', e.message); } + try { + const productTypeCols = await all( + `SELECT is_nullable, column_default FROM information_schema.columns + WHERE table_name = 'agent_bundles' AND column_name = 'product_type'` + ); + if (productTypeCols.length === 0) { + await q("ALTER TABLE agent_bundles ADD COLUMN product_type TEXT NOT NULL DEFAULT 'support-agent'"); + console.log('[DB] Migration: added agent_bundles.product_type'); + } else { + await q(` + UPDATE agent_bundles + SET product_type = CASE LOWER(TRIM(COALESCE(product_type, ''))) + WHEN 'rdclient' THEN 'rdclient' + WHEN 'agent-client' THEN 'agent-client' + WHEN 'agent_client' THEN 'agent-client' + ELSE 'support-agent' + END + WHERE product_type IS NULL + OR LOWER(TRIM(product_type)) NOT IN ('support-agent', 'agent-client', 'rdclient') + `); + if (productTypeCols[0].is_nullable === 'YES') { + await q('ALTER TABLE agent_bundles ALTER COLUMN product_type SET NOT NULL'); + } + if (!String(productTypeCols[0].column_default || '').includes('support-agent')) { + await q("ALTER TABLE agent_bundles ALTER COLUMN product_type SET DEFAULT 'support-agent'"); + } + } + } catch (e) { + console.warn('[DB] Migration agent_bundles.product_type error:', e.message); + } await q(` CREATE TABLE IF NOT EXISTS agent_bundle_builds ( id SERIAL PRIMARY KEY, @@ -6844,12 +6901,20 @@ function createPostgresAdapter() { return excludeBundleId ? row.bundle_id !== excludeBundleId : true; }, - async createAgentBundle({ bundleId, slug, name, branding, brandingHash, createdBy }) { + async createAgentBundle({ bundleId, slug, name, branding, brandingHash, createdBy, productType }) { return one(` - INSERT INTO agent_bundles (bundle_id, slug, name, branding, branding_hash, created_by) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO agent_bundles (bundle_id, slug, name, branding, branding_hash, created_by, product_type) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * - `, [bundleId, slug || null, name, branding, brandingHash, createdBy || null]); + `, [ + bundleId, + slug || null, + name, + branding, + brandingHash, + createdBy || null, + normalizeProductType(productType), + ]); }, async updateAgentBundle(bundleId, { name, slug, branding, brandingHash }) { @@ -6895,6 +6960,7 @@ function createPostgresAdapter() { }, async upsertAgentBundleBuild({ brandingHash, platform, arch, format, status, artifactPath, artifactSize, artifactSha256, errorMessage }) { + const buildStatus = normalizeBuildStatus(status); return one(` INSERT INTO agent_bundle_builds ( branding_hash, platform, arch, format, status, @@ -6915,7 +6981,7 @@ function createPostgresAdapter() { finished_at = CASE WHEN EXCLUDED.status IN ('ready','failed') THEN NOW() ELSE agent_bundle_builds.finished_at END, updated_at = NOW() RETURNING * - `, [brandingHash, platform, arch, format, status, artifactPath || null, artifactSize || 0, artifactSha256 || null, errorMessage || '']); + `, [brandingHash, platform, arch, format, buildStatus, artifactPath || null, artifactSize || 0, artifactSha256 || null, errorMessage || '']); }, // ---- Integration Housekeeping ---- diff --git a/web-nodejs/services/rdclientBuildWorker.js b/web-nodejs/services/rdclientBuildWorker.js index c32994e9..39336025 100644 --- a/web-nodejs/services/rdclientBuildWorker.js +++ b/web-nodejs/services/rdclientBuildWorker.js @@ -15,6 +15,11 @@ const { spawn } = require('child_process'); const db = require('./database'); const bundleService = require('./agentBundleService'); const config = require('../config/config'); +const { + PRODUCT_TYPES, + normalizeProductType, + isQueuedBuildStatus, +} = require('../lib/generatorBuildTypes'); try { const envFile = process.env.BETTERDESK_BUILD_ENV_FILE || '/etc/betterdesk/build.env'; @@ -50,6 +55,10 @@ const BUILD_PROFILES = { 'linux/x64/rpm': { os: 'linux', bundles: ['rpm'], artifact: 'rpm' }, }; +function _isRdclientBundle(bundle) { + return normalizeProductType(bundle?.product_type) === PRODUCT_TYPES.RDCLIENT; +} + let _pollTimer = null; let _running = false; let _activeBuilds = 0; @@ -98,10 +107,10 @@ async function _listPendingRdclientBuilds(limit) { const bundles = await db.listAgentBundles(); const out = []; for (const b of bundles) { - if (b.revoked || (b.product_type || 'agent') !== 'rdclient') continue; + if (b.revoked || !_isRdclientBundle(b)) continue; const builds = await db.listAgentBundleBuildsForHash(b.branding_hash); for (const r of builds) { - if (r.status === 'pending') out.push(r); + if (isQueuedBuildStatus(r.status)) out.push(r); if (out.length >= limit) break; } if (out.length >= limit) break; @@ -113,7 +122,7 @@ async function _hasRdclientBuildInProgress() { if (_activeBuilds > 0) return true; const bundles = await db.listAgentBundles(); for (const b of bundles) { - if ((b.product_type || 'agent') !== 'rdclient' || b.revoked) continue; + if (!_isRdclientBundle(b) || b.revoked) continue; const builds = await db.listAgentBundleBuildsForHash(b.branding_hash); if (builds.some((r) => r.status === 'building')) return true; } @@ -134,7 +143,7 @@ async function enqueueBuildsForHash(brandingHash, { force = false } = {}) { platform: p.platform, arch: p.arch, format: p.format, - status: 'pending', + status: 'queued', artifactPath: existing?.artifact_path || null, artifactSize: existing?.artifact_size || 0, artifactSha256: existing?.artifact_sha256 || null, @@ -143,6 +152,55 @@ async function enqueueBuildsForHash(brandingHash, { force = false } = {}) { } } +async function requeueAllBundleBuilds() { + const bundles = await db.listAgentBundles({ includeRevoked: false }); + const hashes = [...new Set( + bundles + .filter((bundle) => !bundle.revoked && _isRdclientBundle(bundle)) + .map((bundle) => bundle.branding_hash) + .filter(Boolean) + )]; + for (const hash of hashes) { + await enqueueBuildsForHash(hash, { force: true }); + } + return { bundles: hashes.length }; +} + +async function rebuildBundleById(bundleId) { + const row = await db.getAgentBundle(bundleId); + if (!row) return { success: false, error: 'not_found' }; + if (!_isRdclientBundle(row)) return { success: false, error: 'not_rdclient' }; + if (!row.branding_hash) return { success: false, error: 'missing_hash' }; + await enqueueBuildsForHash(row.branding_hash, { force: true }); + return { success: true, platforms: (bundleService.PLATFORMS || []).length }; +} + +async function requeuePlatformBuild(brandingHash, platform, arch, format) { + if (!brandingHash || !platform || !arch || !format) { + return { success: false, error: 'missing_args' }; + } + const allowed = (bundleService.PLATFORMS || []).some( + (p) => p.platform === platform && p.arch === arch && p.format === format + ); + if (!allowed) return { success: false, error: 'unsupported_platform' }; + const bundle = await _findBundleForHash(brandingHash); + if (!bundle || !_isRdclientBundle(bundle)) { + return { success: false, error: 'not_rdclient' }; + } + await db.upsertAgentBundleBuild({ + brandingHash, + platform, + arch, + format, + status: 'queued', + artifactPath: null, + artifactSize: 0, + artifactSha256: null, + errorMessage: '', + }); + return { success: true }; +} + async function _materialiseWorkDir(hash, branding) { const workDir = path.join(WORK_ROOT, hash.slice(0, 16)); if (fs.existsSync(workDir)) { @@ -234,7 +292,7 @@ async function _runOne(buildRow) { if (!profile) throw new Error(`unsupported profile ${key}`); const bundleRow = await _findBundleForHash(buildRow.branding_hash); - if (!bundleRow || (bundleRow.product_type || 'agent') !== 'rdclient') { + if (!bundleRow || !_isRdclientBundle(bundleRow)) { throw new Error('not an rdclient bundle'); } @@ -343,4 +401,11 @@ module.exports = { startWorker, stopWorker, enqueueBuildsForHash, + requeueAllBundleBuilds, + rebuildBundleById, + requeuePlatformBuild, + _internals: { + isRdclientBundle: _isRdclientBundle, + listPendingBuilds: _listPendingRdclientBuilds, + }, }; diff --git a/web-nodejs/services/updateService.js b/web-nodejs/services/updateService.js index 407eafad..e483e881 100644 --- a/web-nodejs/services/updateService.js +++ b/web-nodejs/services/updateService.js @@ -2815,38 +2815,6 @@ async function applyUpdate(remoteSHA, changedData, opts = {}) { } } - // ---- Generator agent: sync agent-source/ + queue bundle rebuilds ---- - if (shouldQueueAgentRebuild(changedData)) { - try { - const agentBuildWorker = require('./agentBuildWorker'); - const stageResult = await agentBuildWorker.syncFullAgentSourceFromGitHub({ - remoteSHA, - download: ghDownloadFile, - listPaths: ghListRepoBlobPaths, - }); - agentBuildWorker.markRebuildPending('in-app update'); - // Requeue immediately so agent-only updates rebuild without waiting - // for a console restart. The pending flag remains as a restart safety net. - let requeue = { bundles: 0 }; - try { - requeue = await agentBuildWorker.requeueAllBundleBuilds(); - } catch (requeueErr) { - console.warn(`[UPDATE] Immediate agent rebuild requeue failed: ${requeueErr.message}`); - } - results.agentSourcesStaged = stageResult.staged; - results.agentSourcePaths = stageResult.paths; - results.agentRebuildQueued = true; - results.agentRebuildBundles = requeue.bundles; - console.log( - `[UPDATE] Agent source tree synced (${stageResult.staged}/${stageResult.paths} file(s));` - + ` generator rebuild queued for ${requeue.bundles} bundle(s)` - ); - } catch (err) { - results.failed.push({ file: 'support-agent-source-sync', error: err.message, nonCritical: true }); - console.warn(`[UPDATE] Full agent-source sync failed: ${err.message}`); - } - } - // ---- Update SHA tracking ---- const { critical: criticalFailures, nonCritical: nonCriticalFailures } = splitUpdateFailures( results.failed, @@ -2909,8 +2877,47 @@ async function applyUpdate(remoteSHA, changedData, opts = {}) { fs.writeFileSync(versionDest, versionContent); } catch (_e) { /* non-critical */ } - if (nonCriticalFailures.length > 0) { - console.log(`[UPDATE] SHA saved despite ${nonCriticalFailures.length} non-critical failure(s): ${nonCriticalFailures.map(f => f.file).join(', ')}`); + // ---- Support Agent generator: stage source + queue only its bundles ---- + // Agent Client and RdClient use their own workers. Keeping this rebuild + // scoped prevents a Support Agent source update from invalidating their + // ready artifacts, while legacy "agent" rows normalize to Support Agent. + if (shouldQueueAgentRebuild(changedData)) { + try { + const agentBuildWorker = require('./agentBuildWorker'); + const stageResult = await agentBuildWorker.syncFullAgentSourceFromGitHub({ + remoteSHA, + download: ghDownloadFile, + listPaths: ghListRepoBlobPaths, + }); + agentBuildWorker.markRebuildPending('in-app update'); + // The worker's product-type filter deliberately requeues only + // Support Agent bundles. The flag remains a restart safety net. + let requeue = { bundles: 0 }; + try { + requeue = await agentBuildWorker.requeueAllBundleBuilds(); + } catch (requeueErr) { + console.warn(`[UPDATE] Immediate support-agent rebuild requeue failed: ${requeueErr.message}`); + } + results.agentSourcesStaged = stageResult.staged; + results.agentSourcePaths = stageResult.paths; + results.agentRebuildQueued = true; + results.agentRebuildBundles = requeue.bundles; + results.agentRebuildProductType = 'support-agent'; + console.log( + `[UPDATE] Support Agent source tree synced (${stageResult.staged}/${stageResult.paths} file(s));` + + ` rebuild queued for ${requeue.bundles} bundle(s)` + ); + } catch (err) { + results.failed.push({ file: 'support-agent-source-sync', error: err.message, nonCritical: true }); + console.warn(`[UPDATE] Full support-agent source sync failed: ${err.message}`); + } + } + + const finalFailures = splitUpdateFailures(results.failed, ROOT_DIR); + results.criticalFailures = finalFailures.critical; + results.nonCriticalFailures = finalFailures.nonCritical; + if (finalFailures.nonCritical.length > 0) { + console.log(`[UPDATE] SHA saved despite ${finalFailures.nonCritical.length} non-critical failure(s): ${finalFailures.nonCritical.map(f => f.file).join(', ')}`); } } else { results.skipped.push('SHA tracking (critical update steps incomplete)'); diff --git a/web-nodejs/tests/agentBuildWorker.version.test.js b/web-nodejs/tests/agentBuildWorker.version.test.js new file mode 100644 index 00000000..a1d6cc91 --- /dev/null +++ b/web-nodejs/tests/agentBuildWorker.version.test.js @@ -0,0 +1,35 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +describe('agentBuildWorker product version injection', () => { + let rootDir; + + beforeEach(() => { + rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bd-agent-version-')); + }); + + afterEach(() => { + fs.rmSync(rootDir, { recursive: true, force: true }); + }); + + test('reads root VERSION and injects it into the copied Support Agent source', async () => { + const workspace = path.join(rootDir, 'workspace'); + fs.mkdirSync(workspace, { recursive: true }); + fs.writeFileSync(path.join(rootDir, 'VERSION'), '7.6.5\n'); + fs.writeFileSync( + path.join(workspace, 'main.go'), + 'package main\n\nvar version = "0.1.0"\n' + ); + + const worker = require('../services/agentBuildWorker'); + const version = await worker._internals.injectSupportAgentVersion(workspace, { rootDir }); + + expect(version).toBe('7.6.5'); + expect(worker._internals.getSupportAgentBuildVersion({ rootDir })).toBe('7.6.5'); + expect(fs.readFileSync(path.join(workspace, 'main.go'), 'utf8')) + .toContain('var version = "7.6.5"'); + }); +}); diff --git a/web-nodejs/tests/agentBundleService.security.test.js b/web-nodejs/tests/agentBundleService.security.test.js new file mode 100644 index 00000000..8673fce9 --- /dev/null +++ b/web-nodejs/tests/agentBundleService.security.test.js @@ -0,0 +1,35 @@ +'use strict'; + +const bundleService = require('../services/agentBundleService'); + +describe('Support Agent bundle transport policy', () => { + const baseBranding = { + company_name: 'Example Support', + server_host: 'desk.example.test', + }; + + it('defaults generated Support Agent profiles to HTTPS', () => { + const result = bundleService.validateBranding(baseBranding); + expect(result.valid).toBe(true); + expect(result.normalized.use_https).toBe(true); + }); + + it('rejects plaintext transport unless explicitly enabled for development', () => { + const previous = process.env.BETTERDESK_ALLOW_INSECURE_DEV_AGENT_BUNDLES; + delete process.env.BETTERDESK_ALLOW_INSECURE_DEV_AGENT_BUNDLES; + try { + const result = bundleService.validateBranding({ + ...baseBranding, + use_https: false, + }); + expect(result.valid).toBe(false); + expect(result.errors).toContain('https_required'); + } finally { + if (previous === undefined) { + delete process.env.BETTERDESK_ALLOW_INSECURE_DEV_AGENT_BUNDLES; + } else { + process.env.BETTERDESK_ALLOW_INSECURE_DEV_AGENT_BUNDLES = previous; + } + } + }); +}); diff --git a/web-nodejs/tests/bundleSigningKey.test.js b/web-nodejs/tests/bundleSigningKey.test.js new file mode 100644 index 00000000..9e17ff51 --- /dev/null +++ b/web-nodejs/tests/bundleSigningKey.test.js @@ -0,0 +1,48 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + DEFAULT_KEY_FILE, + resolveBundleSigningKeyFile, + validateSigningKeyFile, +} = require('../services/bundleSigningKey'); + +describe('Support Agent branding signing key', () => { + let rootDir; + + beforeEach(() => { + rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bd-branding-key-')); + }); + + afterEach(() => { + fs.rmSync(rootDir, { recursive: true, force: true }); + }); + + test('creates a persistent Ed25519 key outside a bundle workspace', async () => { + const keysPath = path.join(rootDir, 'keys'); + const first = await resolveBundleSigningKeyFile({ keysPath, env: {} }); + const firstPem = fs.readFileSync(first, 'utf8'); + const second = await resolveBundleSigningKeyFile({ keysPath, env: {} }); + + expect(first).toBe(path.join(keysPath, DEFAULT_KEY_FILE)); + expect(second).toBe(first); + expect(fs.readFileSync(second, 'utf8')).toBe(firstPem); + await expect(validateSigningKeyFile(first)).resolves.toBeUndefined(); + expect(crypto.createPrivateKey(firstPem).asymmetricKeyType).toBe('ed25519'); + }); + + test('rejects a configured key that is not Ed25519', async () => { + const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const keyPath = path.join(rootDir, 'rsa.pem'); + fs.writeFileSync(keyPath, privateKey.export({ type: 'pkcs8', format: 'pem' })); + + await expect(resolveBundleSigningKeyFile({ + keysPath: path.join(rootDir, 'keys'), + env: { BETTERDESK_BUNDLE_SIGNING_KEY_FILE: keyPath }, + })).rejects.toThrow('Ed25519'); + }); +}); diff --git a/web-nodejs/tests/dbAdapter.generatorBundles.test.js b/web-nodejs/tests/dbAdapter.generatorBundles.test.js new file mode 100644 index 00000000..307b2bcc --- /dev/null +++ b/web-nodejs/tests/dbAdapter.generatorBundles.test.js @@ -0,0 +1,97 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const Database = require('better-sqlite3'); + +describe('dbAdapter generator bundle compatibility', () => { + let tempDir; + let dbPath; + let adapter; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bd-generator-bundles-')); + dbPath = path.join(tempDir, 'db_v2.sqlite3'); + process.env.DATA_DIR = path.join(tempDir, 'data'); + process.env.DB_PATH = dbPath; + process.env.DB_TYPE = 'sqlite'; + jest.resetModules(); + }); + + afterEach(async () => { + if (adapter) await adapter.close(); + adapter = null; + delete process.env.DATA_DIR; + delete process.env.DB_PATH; + delete process.env.DB_TYPE; + jest.resetModules(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test('migrates legacy bundles and canonicalizes product and queue values', async () => { + const legacy = new Database(dbPath); + legacy.exec(` + CREATE TABLE agent_bundles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bundle_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + branding TEXT NOT NULL DEFAULT '{}', + branding_hash TEXT NOT NULL DEFAULT '', + created_by INTEGER DEFAULT NULL, + revoked INTEGER NOT NULL DEFAULT 0, + download_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + INSERT INTO agent_bundles (bundle_id, name, branding_hash) + VALUES ('legacy-bundle', 'Legacy bundle', 'legacy-hash'); + `); + legacy.close(); + + const { getAdapter } = require('../services/dbAdapter'); + adapter = getAdapter(); + await adapter.init(); + + const created = await adapter.createAgentBundle({ + bundleId: 'support-bundle', + slug: 'support-bundle', + name: 'Support bundle', + branding: '{}', + brandingHash: 'support-hash', + productType: 'agent', + }); + const client = await adapter.createAgentBundle({ + bundleId: 'client-bundle', + slug: 'client-bundle', + name: 'Client bundle', + branding: '{}', + brandingHash: 'client-hash', + productType: 'agent_client', + }); + const build = await adapter.upsertAgentBundleBuild({ + brandingHash: 'support-hash', + platform: 'linux', + arch: 'x64', + format: 'portable', + status: 'pending', + artifactPath: null, + artifactSize: 0, + artifactSha256: null, + errorMessage: '', + }); + + const check = new Database(dbPath, { readonly: true }); + const columns = check.prepare('PRAGMA table_info(agent_bundles)').all().map((column) => column.name); + const legacyRow = check.prepare( + 'SELECT product_type FROM agent_bundles WHERE bundle_id = ?' + ).get('legacy-bundle'); + check.close(); + + expect(columns).toContain('product_type'); + expect(legacyRow.product_type).toBe('support-agent'); + expect(created.product_type).toBe('support-agent'); + expect(client.product_type).toBe('agent-client'); + expect(build.status).toBe('queued'); + }); +}); diff --git a/web-nodejs/tests/generatorBuildTypes.test.js b/web-nodejs/tests/generatorBuildTypes.test.js new file mode 100644 index 00000000..99a307ea --- /dev/null +++ b/web-nodejs/tests/generatorBuildTypes.test.js @@ -0,0 +1,28 @@ +'use strict'; + +const { + PRODUCT_TYPES, + normalizeProductType, + isQueuedBuildStatus, + normalizeBuildStatus, +} = require('../lib/generatorBuildTypes'); + +describe('generator build type compatibility', () => { + test('normalizes legacy product type aliases to canonical worker types', () => { + expect(normalizeProductType()).toBe(PRODUCT_TYPES.SUPPORT_AGENT); + expect(normalizeProductType('agent')).toBe(PRODUCT_TYPES.SUPPORT_AGENT); + expect(normalizeProductType('support_agent')).toBe(PRODUCT_TYPES.SUPPORT_AGENT); + expect(normalizeProductType('agent_client')).toBe(PRODUCT_TYPES.AGENT_CLIENT); + expect(normalizeProductType('rdclient')).toBe(PRODUCT_TYPES.RDCLIENT); + expect(normalizeProductType('unknown', PRODUCT_TYPES.AGENT_CLIENT)) + .toBe(PRODUCT_TYPES.AGENT_CLIENT); + }); + + test('treats queued and legacy pending jobs as the same queue state', () => { + expect(isQueuedBuildStatus('queued')).toBe(true); + expect(isQueuedBuildStatus('PENDING')).toBe(true); + expect(isQueuedBuildStatus('building')).toBe(false); + expect(normalizeBuildStatus('pending')).toBe('queued'); + expect(normalizeBuildStatus('ready')).toBe('ready'); + }); +}); diff --git a/web-nodejs/tests/generatorBuildWorkers.queue.test.js b/web-nodejs/tests/generatorBuildWorkers.queue.test.js new file mode 100644 index 00000000..4e391b8b --- /dev/null +++ b/web-nodejs/tests/generatorBuildWorkers.queue.test.js @@ -0,0 +1,77 @@ +'use strict'; + +jest.mock('../services/database', () => ({ + listAgentBundles: jest.fn(), + listAgentBundleBuildsForHash: jest.fn(), + getAgentBundleBuild: jest.fn(), + upsertAgentBundleBuild: jest.fn(), + getAgentBundle: jest.fn(), +})); + +jest.mock('../services/agentBundleService', () => ({ + PLATFORMS: [ + { platform: 'linux', arch: 'x64', format: 'portable', label: 'Linux portable' }, + ], +})); + +jest.mock('../config/config', () => ({ dataDir: '/tmp/betterdesk-generator-worker-test' })); + +const db = require('../services/database'); +const supportWorker = require('../services/agentBuildWorker'); +const agentClientWorker = require('../services/agentClientBuildWorker'); +const rdclientWorker = require('../services/rdclientBuildWorker'); + +const bundles = [ + { branding_hash: 'legacy-support', product_type: 'agent', revoked: false }, + { branding_hash: 'support', product_type: 'support-agent', revoked: false }, + { branding_hash: 'client', product_type: 'agent-client', revoked: false }, + { branding_hash: 'rdclient', product_type: 'rdclient', revoked: false }, +]; + +const buildsByHash = { + 'legacy-support': [{ branding_hash: 'legacy-support', status: 'queued', platform: 'linux', arch: 'x64', format: 'portable' }], + support: [{ branding_hash: 'support', status: 'pending', platform: 'linux', arch: 'x64', format: 'portable' }], + client: [{ branding_hash: 'client', status: 'queued', platform: 'linux', arch: 'x64', format: 'portable' }], + rdclient: [{ branding_hash: 'rdclient', status: 'pending', platform: 'linux', arch: 'x64', format: 'portable' }], +}; + +describe('generator build workers', () => { + beforeEach(() => { + jest.clearAllMocks(); + db.listAgentBundles.mockResolvedValue(bundles); + db.listAgentBundleBuildsForHash.mockImplementation(async (hash) => buildsByHash[hash] || []); + db.getAgentBundleBuild.mockResolvedValue(null); + db.upsertAgentBundleBuild.mockResolvedValue({}); + }); + + test('claims both queued and pending records only for its product type', async () => { + expect(typeof rdclientWorker.rebuildBundleById).toBe('function'); + expect(typeof rdclientWorker.requeuePlatformBuild).toBe('function'); + const support = await supportWorker._internals.listPendingBuilds(10); + const client = await agentClientWorker._internals.listPendingBuilds(10); + const rdclient = await rdclientWorker._internals.listPendingBuilds(10); + + expect(support.map((build) => build.branding_hash)) + .toEqual(expect.arrayContaining(['legacy-support', 'support'])); + expect(support).toHaveLength(2); + expect(client.map((build) => build.branding_hash)).toEqual(['client']); + expect(rdclient.map((build) => build.branding_hash)).toEqual(['rdclient']); + }); + + test('rebuild queues remain isolated by product worker', async () => { + await supportWorker.requeueAllBundleBuilds(); + expect(db.upsertAgentBundleBuild.mock.calls.map(([job]) => job.brandingHash).sort()) + .toEqual(['legacy-support', 'support']); + expect(db.upsertAgentBundleBuild.mock.calls.every(([job]) => job.status === 'queued')).toBe(true); + + db.upsertAgentBundleBuild.mockClear(); + await agentClientWorker.requeueAllBundleBuilds(); + expect(db.upsertAgentBundleBuild.mock.calls.map(([job]) => job.brandingHash)) + .toEqual(['client']); + + db.upsertAgentBundleBuild.mockClear(); + await rdclientWorker.requeueAllBundleBuilds(); + expect(db.upsertAgentBundleBuild.mock.calls.map(([job]) => job.brandingHash)) + .toEqual(['rdclient']); + }); +});