refactor(agent): enhance session management and input handling

- Updated session control mechanisms to ensure proper handling of remote input and clipboard operations.
- Introduced session authorization checks to validate operator permissions before starting desktop sessions.
- Improved input injection logic to prevent unauthorized access during active sessions.
- Added new capabilities for managing session flags and controls, ensuring a more robust and secure desktop experience.
- Enhanced error handling and logging for better traceability of session-related actions.
This commit is contained in:
UNITRONIX
2026-08-06 00:50:02 +02:00
parent ca83009d07
commit ed96e94806
146 changed files with 11098 additions and 1227 deletions
+8 -5
View File
@@ -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.
---
+141
View File
@@ -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 ./...
+3
View File
@@ -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
+53
View File
@@ -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.
+20 -3
View File
@@ -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
})
}
+17
View File
@@ -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()
+10 -9
View File
@@ -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"},
}
+3
View File
@@ -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
@@ -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)
})
}
+9 -4
View File
@@ -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
+81 -39
View File
@@ -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 ──────────────────────────────────────────────────────
@@ -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 ""
}
}
+102 -410
View File
@@ -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 <node>` 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
@@ -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)
}
}
+74 -3
View File
@@ -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
}
@@ -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")
}
}
@@ -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
}
+22 -12
View File
@@ -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,
})
}
}
+48 -24
View File
@@ -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()
+9 -2
View File
@@ -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)
+14 -1
View File
@@ -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."
}
@@ -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])
}
}
+142 -16
View File
@@ -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.
+16
View File
@@ -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)
+20 -16
View File
@@ -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 {
+343 -148
View File
@@ -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_<id> 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)
@@ -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())
}
}
+81
View File
@@ -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")
}
}
+27 -24
View File
@@ -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()
+225
View File
@@ -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
}
+401
View File
@@ -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())
}
})
}
+1 -11
View File
@@ -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 == "" {
+1 -1
View File
@@ -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)
@@ -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
}
@@ -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())
}
}
+19 -12
View File
@@ -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
// viewerread-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
}
+16
View File
@@ -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 {
+26 -5
View File
@@ -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.
@@ -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")
}
}
+85 -14
View File
@@ -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)
@@ -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)
}
}
+17
View File
@@ -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 }
+3
View File
@@ -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)
}
+183
View File
@@ -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
}
@@ -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")
}
}
+69
View File
@@ -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
+73
View File
@@ -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)
+31 -12
View File
@@ -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 |
+81
View File
@@ -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 ""
}
}
@@ -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)
}
}
+21 -1
View File
@@ -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)
+42
View File
@@ -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")
}
}
+68 -38
View File
@@ -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() {
+7
View File
@@ -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()
}
+34
View File
@@ -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)
}
}
+136 -23
View File
@@ -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
}
@@ -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)
}
}
+45 -5
View File
@@ -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
@@ -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
}
@@ -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")
}
}
+7 -1
View File
@@ -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()
}
@@ -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) {}
+31
View File
@@ -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())
}
}
+78 -35
View File
@@ -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()
@@ -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)
}
}
+149 -34
View File
@@ -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
}
}()
}
@@ -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
}
@@ -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)
}
}
+130
View File
@@ -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)
}
}
+98 -13
View File
@@ -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
}
}
@@ -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"
}
}
@@ -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
}
@@ -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))
}
}
@@ -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
}
@@ -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")
}
}
@@ -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
}
}
@@ -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
}
@@ -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")
)
@@ -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)
}
@@ -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
@@ -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"
}
}
@@ -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),
}, ".")
}
@@ -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
@@ -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")
}
@@ -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")
)
@@ -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)
}
@@ -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")
}
}
+11 -6
View File
@@ -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
}
+182
View File
@@ -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,
})
}
+7 -5
View File
@@ -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)
@@ -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"])
}
}
+16 -9
View File
@@ -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
@@ -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)
}
}
+81 -19
View File
@@ -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"
}
@@ -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
}
@@ -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)
}
}
@@ -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")
}
}
@@ -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
}
@@ -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")
}
}
@@ -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
}

Some files were not shown because too many files have changed in this diff Show More