mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-09 17:16:46 +00:00
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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 ./...
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,8 @@ const (
|
||||
PermUserDelete = "user.delete"
|
||||
|
||||
// Server configuration
|
||||
PermServerConfig = "server.config" // read/write server_config
|
||||
PermServerKeys = "server.keys" // manage API keys
|
||||
PermServerConfig = "server.config" // read/write server_config
|
||||
PermServerKeys = "server.keys" // manage API keys
|
||||
PermServerAttestation = "server.attestation" // run/view server performance attestation
|
||||
|
||||
// Organization permissions
|
||||
@@ -62,10 +62,10 @@ const (
|
||||
PermBrandingEdit = "branding.edit"
|
||||
|
||||
// Billing / commercialization
|
||||
PermBillingView = "billing.view"
|
||||
PermBillingManage = "billing.manage"
|
||||
PermBillingView = "billing.view"
|
||||
PermBillingManage = "billing.manage"
|
||||
PermBillingReports = "billing.reports"
|
||||
PermBillingExport = "billing.export"
|
||||
PermBillingExport = "billing.export"
|
||||
)
|
||||
|
||||
// AllPermissions is the complete list of permission strings for validation.
|
||||
@@ -88,13 +88,14 @@ var AllPermissions = []string{
|
||||
// Custom overrides from the DB take precedence.
|
||||
//
|
||||
// Role scoping (Discussion #99):
|
||||
// super_admin — all permissions, manages other super admins
|
||||
// server_admin — server infrastructure only, read-only user list
|
||||
// global_admin — all-org user/device/org management, NO server access
|
||||
// admin — legacy alias, equivalent to super_admin
|
||||
// operator — day-to-day device ops + chat
|
||||
// viewer — read-only dashboards
|
||||
// pro — API-only RustDesk PRO activation; no device or org device access
|
||||
//
|
||||
// super_admin — all permissions, manages other super admins
|
||||
// server_admin — server infrastructure only, read-only user list
|
||||
// global_admin — all-org user/device/org management, NO server access
|
||||
// admin — legacy alias, equivalent to super_admin
|
||||
// operator — day-to-day device ops + chat
|
||||
// viewer — read-only dashboards
|
||||
// pro — API-only RustDesk PRO activation; no device or org device access
|
||||
var DefaultRolePermissions = map[string]map[string]bool{
|
||||
RoleSuperAdmin: buildPermMap(AllPermissions),
|
||||
RoleAdmin: buildPermMap(AllPermissions), // legacy admin = super_admin
|
||||
@@ -143,6 +144,9 @@ var DefaultRolePermissions = map[string]map[string]bool{
|
||||
PermChatAccess,
|
||||
}),
|
||||
RolePro: buildPermMap([]string{}),
|
||||
// Device credentials authenticate an agent to its own transport only.
|
||||
// They never confer panel/API permissions.
|
||||
RoleDevice: buildPermMap([]string{}),
|
||||
}
|
||||
|
||||
// buildPermMap converts a slice of permission strings into a lookup map.
|
||||
@@ -169,6 +173,9 @@ func ProRoleBlocksPermission(permission string) bool {
|
||||
// according to default role mappings. Returns true for super_admin and legacy admin.
|
||||
// For DB-overridden permissions, use the Database.HasRolePermission method instead.
|
||||
func RoleHasPermission(role, permission string) bool {
|
||||
if IsDeviceRole(role) {
|
||||
return false
|
||||
}
|
||||
if IsProRole(role) && ProRoleBlocksPermission(permission) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user