40 Commits

Author SHA1 Message Date
mike 0df4ee4143 UI improvement (add device to)
build / build-linux-amd64 (push) Successful in 1m55s
2026-05-25 00:58:22 +02:00
mike d941ae9739 Fix horizontal scrolling in admin UI
build / build-linux-amd64 (push) Successful in 1m56s
2026-05-25 00:19:14 +02:00
mike bc61ec6046 Fix docker caching and correcting version in Gitea workflow
build / build-linux-amd64 (push) Successful in 1m56s
2026-05-25 00:07:15 +02:00
mike 8fa1b3e609 Bump version to 1.1.17-pro
build / build-linux-amd64 (push) Successful in 1m56s
2026-05-24 23:08:56 +02:00
mike d3c1128f23 Fix docker image caching. Add version indicator to admin UI
build / build-linux-amd64 (push) Successful in 1m57s
2026-05-24 23:02:34 +02:00
mike b044ab4de9 Improve device list view
build / build-linux-amd64 (push) Successful in 1m53s
2026-05-22 23:41:55 +02:00
mike 7b6526a2e8 Improve device detail view
build / build-linux-amd64 (push) Successful in 1m53s
2026-05-22 23:03:06 +02:00
mike aeee852835 Improve device detail view
build / build-linux-amd64 (push) Successful in 1m56s
2026-05-22 22:08:54 +02:00
mike 3ab67e80e1 Implement performance monitor
build / build-linux-amd64 (push) Successful in 1m54s
2026-05-22 21:41:54 +02:00
mike 62a8870ea2 Implement user login logging
build / build-linux-amd64 (push) Successful in 1m54s
2026-05-22 20:10:11 +02:00
mike ac058d31c2 Implement filters and column management in admin UI lists
build / build-linux-amd64 (push) Successful in 1m52s
2026-05-22 18:55:31 +02:00
mike 6a0b698384 Implement remote execution
build / build-linux-amd64 (push) Successful in 1m58s
2026-05-22 14:18:48 +02:00
mike 475da0e950 Implement signed API communication to improve security
build / build-linux-amd64 (push) Successful in 1m50s
2026-05-22 13:05:50 +02:00
mike 21b25bcc1b Impement software inventory
build / build-linux-amd64 (push) Successful in 1m56s
2026-05-21 23:56:06 +02:00
mike cd461a4507 Show inventory for all reporting clients
build / build-linux-amd64 (push) Successful in 1m49s
2026-05-21 16:24:00 +02:00
mike c2d320b782 Implement "strategy selector" so it is possible to push certain strategies only to selected devices
build / build-linux-amd64 (push) Successful in 1m49s
2026-05-21 12:24:06 +02:00
mike e22e4f6fb6 Implement RUSTDESK_UNATTENDED_PWD_VISIBILITY to enable visibility of unattended passwords within the Admin UI even when User is logged in.
build / build-linux-amd64 (push) Successful in 1m45s
2026-05-18 18:25:29 +02:00
mike 5ec9776207 Documentation of available strategy keys (Rustdesk Client)
build / build-linux-amd64 (push) Successful in 1m48s
2026-05-18 18:23:21 +02:00
mike 8298252b06 Updated docker-compose.yml
build / build-linux-amd64 (push) Has been cancelled
2026-05-13 16:18:47 +02:00
mike 1e961cdd92 Implementing multi-language Admin UI
build / build-linux-amd64 (push) Successful in 2m2s
2026-05-09 16:58:20 +02:00
mike a7b3e83f02 Improve admin UI (remove unused functions, added hello-agent deployment
build / build-linux-amd64 (push) Successful in 2m2s
2026-05-09 14:14:58 +02:00
mike f7c359a8a0 Include WiFi and network interfaces to device details
build / build-linux-amd64 (push) Successful in 2m1s
2026-05-09 00:25:02 +02:00
mike 0dda056bda Implement asset inventory
build / build-linux-amd64 (push) Successful in 2m6s
2026-05-08 23:06:04 +02:00
mike 9d53999eea Implement password handling for unattended access
build / build-linux-amd64 (push) Successful in 2m0s
2026-05-08 11:34:07 +02:00
mike c1eaac1cb3 web UI and web client improvements.
build / build-linux-amd64 (push) Successful in 2m1s
2026-05-08 08:42:59 +02:00
mike 8ad3f43d21 ci(linux): add build workflow + Docker build instructions
build / build-linux-amd64 (push) Successful in 1m48s
2026-05-07 09:53:29 +02:00
mike 7e2c7a7e4c docs: refresh CONFIGURATION.md — TOTP self-service, new routes, bind flags
Caught up the docs to match what the dashboard actually does. Four spots
were stale enough to be misleading.

- TOTP / 2FA section rewritten. The doc still claimed admins enrolled
  TOTP from the Users action menu, but that button was removed when
  TOTP enrollment moved to the self-service profile page (two-step
  with QR + 6-digit confirmation; nothing written to user_totp_secrets
  until the user proves they have a working authenticator). Admins can
  disable a user's TOTP but can no longer enroll on someone's behalf.
  Also called out that OIDC-linked users skip local TOTP — their MFA
  lives at the IdP.

- Admin dashboard URLs table was missing nine routes that exist
  today: /admin/assets/{tailwindcss,htmx.min}.js (vendored CDN
  assets), /admin/pages/profile + four sub-routes (self-service
  profile flow), /admin/connect/:peer_id, and the two web-client SPA
  asset routes. Updated the Users-page row to mention the inline
  edit-profile + TOTP-disable controls.

- CLI flags / HTTP API & dashboard table now lists --http-listen and
  --ws-listen (they previously only appeared inside the nginx
  subsection — discoverability matters when an operator scans the
  flag tables looking for what's available). Added a one-liner about
  hbbr's matching --ws-listen flag.

- Security checklist gained a bind-flags hardening tip
  (--http-listen=127.0.0.1, --ws-listen=127.0.0.1 on both daemons
  when fronted by nginx) and a note about forwarding
  X-Forwarded-Proto: https so the dashboard generates wss:// URLs.

Sections cross-checked and confirmed accurate as-is: OIDC walk-through
+ role sync + troubleshooting, strategies, address books, recordings,
audit retention, SMTP, web client (routes / browser reqs / codec /
HUD diagnostics / build), database / backup notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:27:32 +02:00
mike aa40784dc6 feat(deploy): bind-address flags for browser-facing ports + nginx docs
By default hbbs and hbbr bind every port to the wildcard, which collides
with operators wanting to put nginx/Caddy in front of the dashboard
(443) and the two browser-facing WebSocket ports (21118 rendezvous,
21119 relay) for TLS termination. Operators reported having to choose
between exposing hbbs directly (no TLS for `wss://`, breaks browsers
since the page is HTTPS) or moving the daemon to a different port.

New flags:
- hbbs `--http-listen=<HOST>` pins the HTTP API + dashboard port.
- hbbs `--ws-listen=<HOST>`   pins the WS rendezvous port (port + 2).
- hbbr `--ws-listen=<HOST>`   pins the WS relay port (port + 2).

All default to the wildcard (current behaviour). Set to `127.0.0.1` to
free up the corresponding public port for nginx.

The plain TCP/UDP ports used by desktop clients (21115 NAT test, 21116
rendezvous, 21117 relay) intentionally stay on the wildcard — desktop
clients bring their own framing + secretbox encryption and don't go
through nginx.

Implementation: a small `bind_tcp_listener(host, port)` helper in
common.rs that falls through to the existing `listen_any` when host is
empty, otherwise binds explicitly. Reused for both ws_port (rendezvous +
relay) and the http_port; the latter just builds a `SocketAddr` inline
since axum::serve takes one.

Documentation: new "TLS deployment with nginx" section in
docs/CONFIGURATION.md covering the port plan, the bind flags, full
example nginx vhost config (three server blocks: 443 dashboard,
21118 WSS rendezvous, 21119 WSS relay) with the WebSocket Upgrade
plumbing and bump-up timeouts that long sessions need, plus the
firewall list and the four common failure modes (SSL protocol error,
connection refused, 502, hung 200 instead of 101).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:43:20 +02:00
mike 4ccfe7a0e6 feat(admin): user-administration QoL — last-seen, profile page, OIDC awareness
Bundles the dashboard improvements that landed since 782e4c5 into one
commit. None of these change wire protocols or DB schema; they're all
UI + handlers on top of existing tables.

Users page (/admin/#users)
- "Last seen" column derived from MAX(tokens.last_used_at) per user
  (single GROUP BY query in users_last_seen_map). Shows relative
  short-form ("5m ago", "3h ago", "2d ago") with the absolute UTC
  timestamp in the cell title= for hover.
- Per-row dropdown gains an inline "Edit profile" form (display name,
  email, Save) so admins can edit other users' info without going
  through the self-service profile.
- "Enroll TOTP" button removed from the dropdown — TOTP enrollment is
  now self-service only, so admin-side enroll (which generated a secret
  out-of-band with no QR/confirm) is dead UX. "Disable TOTP" stays,
  shown only when the user has it enrolled, with hx-confirm.
- Per-row action popover (the ··· menu) now closes on outside click,
  via a global handler in index.html that targets details.relative.
  Deploy page's collapsible help section is unaffected (no `relative`).

Self-service profile page (/admin/#profile)
- New page accessible to any signed-in user (no admin gate). Sections:
  * Profile info — display name, email
  * Change password — requires current password + new + confirm
  * Two-factor authentication — enroll/disable
- TOTP enrollment is two-step with QR confirmation. POST .../totp/start
  generates a fresh secret, renders a server-side SVG QR code (new
  `qrcode` crate dependency, no_std SVG renderer) plus the manual-entry
  base32 secret. The secret rides in a hidden form field; nothing is
  written to user_totp_secrets until the user submits a valid 6-digit
  code at .../totp/confirm. Wrong code re-renders the same QR with a
  "code didn't match" notice so the user can retry without re-scanning.
- TOTP removal requires the current password.
- Sidebar now has a "My profile" link at the bottom.

OIDC linkage awareness
- UserRow exposes oidc_subject (was already in schema, just not surfaced
  in the struct). UserRow::is_oidc_linked() returns true for non-empty.
- Admin Users page: for OIDC-linked rows the password-set form is
  replaced by a small italic note ("Linked to OIDC — password sign-in
  is disabled."). Server-side, reset_password also rejects with the
  same message — UI hide is cosmetic; the handler check is the actual
  guarantee.
- TOTP column doubles as an auth-path indicator: OIDC-linked users get
  a cyan "OIDC" badge instead of (or in preference to) the violet
  "enrolled" badge.
- Self-service profile page: change-password and TOTP sections become
  short notes ("Your account signs in via the identity provider …" /
  "MFA is managed by your identity provider") for OIDC users.
  change_password handler also short-circuits with the same message.

Login page error fragment
- The auth handler returns 401 with an HTML body for bad credentials /
  disabled / not-admin / bad-TOTP, but HTMX skips the swap on 4xx by
  default — so login errors silently never appeared. Form now has
  hx-on::before-swap that forces shouldSwap=true and clears isError on
  4xx, but only for this form (page-level htmx:responseError handler
  that bounces 401s to /admin/login.html still applies elsewhere — it
  wouldn't loop here since this form sets isError=false).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:55:29 +02:00
mike 782e4c545e build(admin): vendor Tailwind + HTMX, drop CDN dependencies
The dashboard pages (index.html, login.html) were fetching tailwindcss
and htmx.org from cdn.tailwindcss.com and unpkg.com at runtime. That
leaks browser request metadata to third parties, makes the dashboard
inoperable on air-gapped deployments, and ties dashboard availability
to two SaaS CDNs the operator doesn't control.

Both files are now embedded in the hbbs binary (include_bytes!) and
served from /admin/assets/{tailwindcss.js,htmx.min.js}. Versions
pinned in source: Tailwind 3.4.16 (Play CDN JIT, the same JS the
<script src="cdn.tailwindcss.com"> tag was previously loading) and
htmx.org 1.9.10. To upgrade either: re-fetch the file at the same
path and rebuild hbbs.

Asset routes are unauthenticated so the login page can load them,
and served with Cache-Control: public, max-age=31536000, immutable
since version bumps roll with binary upgrades anyway.

Bundle size impact: +500 KB in the hbbs binary, fully cached on the
client after first load.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:59:11 +02:00
mike 4308a2f112 feat: M6 web client QoL — clipboard, multi-monitor, quality, reconnect, H.264, docs
Builds on the M6a-g MVP (d07e98e) with five user-facing features that
take the in-browser remote-control client from "demo toy" to "workable
for daily use", plus user-facing documentation.

M6h — Text clipboard sync (both directions)
- Host → browser via Clipboard{format: Text} → navigator.clipboard.writeText.
- Browser → host via Cmd-V/Ctrl-V keydown intercept → navigator.clipboard
  .readText → Message{clipboard}, sent BEFORE the V keystroke so the
  host's paste hotkey lands on the freshly-synced text.
- Handles both single-format Clipboard (older peers) and MultiClipboards
  wrapper (peers ≥ 1.3.0; gated by clipboard.rs:is_support_multi_clipboard).
- Switched printable-with-modifier hotkeys (Ctrl-C etc.) from Unicode to
  Chr payload — host's process_unicode does key_sequence(char) which
  ignores modifiers, breaking copy/cut; process_chr respects them.
- Firefox refuses navigator.clipboard.readText() by default — accepted as
  a known browser limitation, host → browser direction works regardless.

M6i — Multi-monitor switching
- HUD picker shown when peer_info.displays > 1.
- On change: SwitchDisplay + CaptureDisplays{set:[idx]} two-message dance
  — required for clients ≥ 1.2.4 (we send "1.4.0"). Without the follow-
  up, switch_display_to leaves both video services subscribed and
  switching display 0 → 1 → 0 doesn't restore display 0.
- Mouse coords offset by the active display's virtual-desktop origin
  (DisplayInfo.x, .y). Without this, clicks on display 2 landed on
  display 1 because both share canvas (0,0) but only display 1 has
  origin (0,0) in virtual-desktop space.

M6j — Quality / FPS / mute controls
- Image quality preset (Low/Balanced/Best) → Misc{option: {image_quality}}.
- Custom FPS (15/30/60) → Misc{option: {custom_fps}}; host caps at 30
  unless allow_more_fps is advertised.
- Mute toggle additionally sends Misc{option: {disable_audio: Yes/No}}
  so the peer stops encoding audio while muted (saves CPU + bandwidth).

M6k — Auto-reconnect on transient drops
- session.recv() throw → reconnect with exponential backoff: 1s, 2s, 4s,
  8s, 16s, 30s, 30s, capped at 30s, max 10 attempts.
- Dim overlay sits on top of the canvas during retry; canvas keeps
  last-known frame for visual continuity.
- Auth errors (password/signature) bail immediately — no point retrying.
- User options (mute, image_quality, custom_fps, current display)
  re-applied to host on each successful reconnect, since host treats
  every session as fresh and resets to defaults.
- Architecture: `session` is a let-binding mutated on reconnect; HUD
  button closures read it at click-time so they automatically retarget.
  Input modules (mouse/keyboard/clipboard) get a Proxy that forwards
  method calls to whatever session is current — avoids re-binding
  window/canvas listeners on each reconnect.

M6l — H.264 video decode (Annex-B + SPS-derived codec string)
- decode/bitstream.ts: iterate Annex-B NAL units, derive avc1.PPCCLL
  from the keyframe's inline SPS (host's hwcodec defaults to high
  profile; a hardcoded baseline string would make WebCodecs refuse the
  stream).
- Defer H.264 decoder configure until first keyframe arrives.
- VP9 codec string corrected from level 1.0 (vp09.00.10.08) to level
  5.0 (vp09.00.50.08) — wrong level was probably forcing software
  decode in some browsers.
- Default prefer flipped to VP8 (cheapest software encoder; H.264 path
  stays implemented for hosts with hwcodec/nvenc).

M6m — docs/CONFIGURATION.md "Web client" section: routes, browser
matrix, network requirements (relay reachability + reverse-proxy WS
upgrade), feature status table, codec selection rationale, the
recv/dec/draw HUD diagnostic, build commands.

Bundle: 535 KB / ~75 KB gzipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:43:23 +02:00
mike d07e98e607 feat: M6 web client — view + control + audio in the dashboard
Adds a TypeScript SPA embedded in hbbs that lets a logged-in admin click
"Connect (web client)" on a Devices row and remote-control the peer from
the browser, no desktop client install required. View, mouse/keyboard
control, and host-audio playback all work end-to-end.

Architecture
------------
Pure browser app, no server-side WS proxy:

  Browser  ──ws://hbbs:21118── rendezvous (PunchHole + RequestRelay)
       │
       └──ws://hbbr:21119──── relay (paired by uuid)
                                  │
                                  └── peer (RustDesk desktop, any platform)

Same wire path the desktop client takes via `Client::request_relay` and
`Client::create_relay`. Browser-relay-only — no NAT punching, so we send
nat_type=SYMMETRIC in PunchHoleRequest to make the peer skip the direct
attempt and go straight to relay (initiate=true with a host-generated uuid
that we then use for our own relay leg).

The 5 wire steps:
  1. PunchHoleRequest → harvests signed peer Ed25519 sign-pk + relay host
     + the peer's session uuid from RelayResponse
  2. Verify the signed pk against /admin/connect's `id_ed25519.pub`
  3. Open WS to hbbr:21119, send RequestRelay with that uuid
  4. Read peer's Message{signed_id}, verify with peer_sign_pk, extract
     Curve25519 box pk; box-seal a fresh secretbox key under it; send
     Message{public_key} unencrypted
  5. Secretbox-encrypted stream from here. Hash → LoginRequest →
     LoginResponse with PeerInfo. Mode = Legacy (Translate silently drops
     ControlKey/Unicode payloads on the host side).

New Rust surface
----------------
- `admin_ui/connect.html` — SPA shell with `{{CUSTOM_CONFIG}}` placeholder
- `src/api/admin/pages/connect.rs` — gates on AuthedUser, injects per-request
  config (rendezvous host, relay host, server pubkey, peer_id, admin name)
  into the `<script id="custom-config">` tag, serves bundle.{js,css} via
  include_bytes!
- 3 routes added: GET /admin/connect/:peer_id and the two assets
- Devices dropdown gains a sky-blue "Connect (web client)" link that opens
  in a new tab

New TypeScript SPA (`web_client/`)
----------------------------------
Stack: pure DOM/TS, no React/Vue. Bundled by esbuild → `dist/bundle.{js,css}`
which is committed (cargo build needs no Node toolchain).

  src/main.ts                 boot + password retry loop + receive dispatch
  src/crypto.ts               tweetnacl wrapper (sign_open, box, secretbox)
                              + @noble/hashes/sha2 (works on plain http;
                              SubtleCrypto requires a secure context)
  src/proto/generated.{js,d.ts}  pbjs static-module from
                              libs/hbb_common/protos/{rendezvous,message}.proto
  src/transport/rendezvous.ts WS to hbbs; PunchHole + RequestRelay
  src/transport/relay.ts      WS to hbbr; duplex frame transport
  src/transport/session.ts    secure-handshake state machine + Hash/Login
                              + 8-byte LE secretbox sequence counter
                              (PRE-increment, send/recv independent —
                              matches libs/hbb_common/src/tcp.rs:317-320)
                              + preloginExtras stash for AudioFormat that
                              arrives before LoginResponse
  src/decode/video.ts         WebCodecs VideoDecoder (vp09.00.10.08 today;
                              h264/h265/av1/vp8 codec strings ready for M6f)
  src/decode/audio.ts         WebCodecs AudioDecoder (opus) → AudioContext;
                              detects f32 vs f32-planar AudioData layout
                              and deinterleaves when needed; gap-less
                              scheduling via a sliding playhead
  src/ui/canvas.ts            <canvas> with object-fit: contain letterbox;
                              auto-resizes on resolution change; FPS counter
  src/input/mouse.ts          MouseEvent → MouseEvent proto. Mask layout:
                              (button << 3) | type (0=move,1=down,2=up,
                              3=wheel). Letterbox-aware viewport→peer
                              coord mapping. Right-click suppresses the
                              browser context menu; left-click does NOT
                              preventDefault (would block focus)
  src/input/keyboard.ts       Window-level keydown/keyup → KeyEvent proto
                              in Legacy mode. Special keys → ControlKey
                              enum; printable → unicode codepoint (down
                              only, host's process_unicode does a single
                              key_click). Browser shortcuts allowlisted
                              (Cmd-T/N/W/R, Tab) so the user keeps tab
                              control. Ctrl+Alt+Del HUD button (host-side
                              `send_sas` is `#[cfg(windows)]`; no-op on
                              Mac/Linux hosts but present for parity)

Bundle size: 529 KB raw / ~74 KB gzipped. Tree-shaken protobufjs +
tweetnacl + @noble/hashes only.

Deployment notes
----------------
- WebCodecs and SubtleCrypto are gated to "secure context" origins —
  HTTPS, or http://localhost. Plain http://lan-ip won't work. Open via
  http://localhost during dev, or terminate TLS in front of hbbs (Caddy
  / nginx / Traefik) for production access.
- `--relay-servers <host>` on hbbs must point at a host where TCP/WS
  21119 is reachable from end-user browsers.

Wire-format gotchas this commit nails (each one was a session of bisecting)
--------------------------------------------------------------------------
- Hash.salt / Hash.challenge are proto `string` fields used as raw UTF-8
  bytes in the SHA-256 chain. NOT base64-decoded. `pwd_hash =
  SHA256(pwd_text || salt_utf8)`, `resp = SHA256(pwd_hash || challenge_utf8)`.
- Translate keyboard mode silently drops Unicode + ControlKey payloads on
  the host (input_service.rs:2022 has `// Do not handle unicode for now.`).
  Only Seq + Chr work in Translate. Use Legacy (mode=0) for everything.
- Browser is forced to relay path by sending nat_type=SYMMETRIC. The peer
  generates its OWN uuid in handle_punch_hole's symmetric branch; use that
  uuid (carried back in RelayResponse) for the relay leg, not a fresh one.
- Misc{audio_format} fires from the host's audio_service first-snapshot
  BETWEEN add_connection and login_response, so it lands on the wire
  before our session.recv() loop is set up. Session.open() captures
  pre-login messages into preloginExtras for the caller to replay.
- protobufjs static-module sets unpopulated oneof fields to JS `null`,
  not `undefined`. A `if (msg.cursor_id !== undefined)` cursor branch
  swallowed every other message type including Misc; switched to loose
  `!= null` comparison.
- WebCodecs AudioDecoder for opus emits `f32` (interleaved) AudioData —
  must deinterleave into separate AudioBuffer channels before playback.
- VideoDecoder/AudioDecoder/SubtleCrypto are SecureContext-only; need
  http://localhost or https:// on the *page origin*, not the WS targets.
- libsodium-wrappers ESM ships a broken relative import (`./libsodium.mjs`
  in a sibling package); switched to tweetnacl which has no such problem.
- WebCrypto's SubtleCrypto.digest() doesn't accept SharedArrayBuffer-backed
  Uint8Arrays in newer TS lib types; doesn't matter — we use @noble/hashes
  for sha256 anyway since Subtle is secure-context-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:55:40 +02:00
mike 8b0219a877 Prefill deploy parameters by defaults. 2026-05-02 18:49:28 +02:00
mike 4730c46f46 build: point hbb_common submodule at gitea fork
The submodule was carrying a local cherry-pick (proto tags 27/28 for
HttpProxyRequest/Response) on top of upstream rustdesk/hbb_common.
That commit (0c49f9a) only existed on the dev machine, so cloning
elsewhere and running `git submodule update` failed with
"upload-pack: not our ref" because github upstream doesn't have it.

Repoint the submodule URL at gitea.cstudio.ch/mike/hbb_common which
hosts the cherry-picked branch (pro-features-httpproxy) and a mirror
of main. Anyone cloning from now on can `git submodule update --init`
without further setup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:26:03 +02:00
mike 98b55e138e feat(admin): OIDC sign-in, role sync, deploy/delete UX, and docs
This commit lights up the missing pieces of the admin dashboard and the
OIDC flow that the desktop client already speaks. It bundles several
independent fixes that share enough touch points (oidc/callback,
admin/mod, database schema) that splitting was more churn than help.

OIDC — desktop client
- /api/login: when TOTP is enrolled, return type:"email_check"
  + tfa_type:"tfa_check" instead of type:"tfa_check". The Flutter
  client's switch only branches on access_token / email_check; the
  prior shape silently fell into "bad response from server".
- /api/login dispatcher: route the second leg to login_tfa_code when
  tfaCode + secret are both present, regardless of the declared type.
  The desktop client sends type:"email_code" for both email-code AND
  TOTP second legs and distinguishes by which field is set.
- /api/oidc/auth-query: drop the bogus extra {"body": "..."} envelope.
  The desktop client's http_request_sync already wraps every response
  in {status_code, headers, body}, and HbbHttpResponse::parse expects
  the auth payload at that level. Our extra envelope made the parser
  fail silently as DataTypeFormat and the poll loop spun until the
  180 s client timeout.
- UserPayload: add a required info: {} field; the Rust-side polling
  deserializer at src/hbbs_http/account.rs expects it (no
  #[serde(default)]). Without it the AuthBody parse failed on every
  poll, producing the same forever-pending symptom as above.
- Add an always-on info-level log line at the poll handler so this
  family of "client never advances" bugs is observable from hbbs.log.

OIDC — admin dashboard
- New unauthenticated entry points:
    GET /admin/oidc/providers      JSON list for login.html
    GET /admin/login/oidc/:provider 302 → IdP authorization endpoint
  The session is marked admin-flow via a sentinel ("__admin_ui__") in
  client_id_str / client_uuid so the existing /oidc/callback can tell
  it apart from a desktop device flow.
- /oidc/callback finishes admin sessions by setting the
  rd_admin_session cookie + 303 to /admin/. Non-admin users get a
  helpful error page instead of a session.
- Admin-flow callbacks SKIP device_claim() so the dashboard sign-in
  no longer inserts a phantom "__admin_ui__" device row in
  device_sysinfo, and the token's peer_id / peer_uuid columns stay
  blank instead of carrying the sentinel.
- admin_ui/login.html fetches the providers list on load and renders
  one button per enabled provider beneath the password form.

OIDC — role-based admin sync
- New per-provider config fields admin_role + roles_claim (in
  oidc.toml AND oidc_providers, via soft ALTER TABLE). When set, the
  callback evaluates the userinfo claim and forces users.is_admin
  accordingly on every login. Promotion AND demotion at the IdP
  propagate. Two claim shapes supported:
    - object key match  (Zitadel:
        urn:zitadel:iam:org:project:roles -> { "admin": {...} })
    - string-array contains (generic: roles -> ["admin","user"])
- user_upsert_oidc gains a desired_admin: Option<bool> arg so the same
  upsert path handles "leave admin alone" (desired_admin = None) and
  "force from IdP" (Some(bool)). Three unit tests cover both shapes
  plus the missing-claim case.

Admin dashboard — Address books
- Full CRUD for shared books from the dashboard:
  create, list shares, add/upgrade/remove a per-user share with
  read / read+write / full rules, delete the book.
- Personal books also get a Delete action — confirms with a stronger
  message that the user's desktop client will recreate an empty
  personal book on next sync if it's still signed in (deletion is
  effectively "reset to empty", not "permanently revoked"). Use in
  combination with user-delete to fully revoke.
- New DB methods: ab_create_shared, ab_delete (cascades peers/tags/
  peer_tags/shares), ab_get_owner_kind, ab_list_shares, ab_share_set
  (idempotent upsert), ab_share_remove.

Admin dashboard — Devices
- Delete action in the per-row menu. device_delete cascades through
  device_sysinfo, peer (rendezvous identity), heartbeat_commands and
  peer-scoped strategy_assignments. Audit logs, recordings, and AB
  entries that reference the peer are intentionally preserved
  (historical/manual data).

Admin dashboard — Deploy page
- New page that generates the unsigned CustomServer blob the desktop
  client accepts via `rustdesk --config <blob>` (see
  rustdesk/src/custom_server.rs:get_custom_server_from_config_string;
  the unsigned-JSON path is a real codepath, no Pro signing key
  needed). Form prefills the public key from id_ed25519.pub in CWD.
- Also emits the equivalent renamed-installer filename
  (rustdesk-host=...,key=... .exe). Strips api= from the filename
  when it equals the default http://<host>:21114 (Windows can't store
  : or / in filenames); warns when the API URL is non-default.

Login form fixes
- TOTP form field: rename serde wire field from tfa_code to tfaCode
  so the dashboard's HTMX form (input name="tfaCode") actually
  populates it. The previous mismatch silently dropped the code and
  the server kept asking for it.
- TOTP redirect guard: only redirect on empty 2xx body (real login).
  The TOTP-required path returns 2xx with an HTML prompt fragment
  that must NOT be redirected away from.

Docs
- New docs/CONFIGURATION.md covering all CLI flags, OIDC setup
  (generic + Zitadel walk-through), role-based admin sync, TOTP,
  strategies, address books, dashboard URL map, DB notes, and a
  pre-prod security checklist.

Schema
- soft ALTER TABLE oidc_providers ADD COLUMN admin_role / roles_claim
  (guarded by the duplicate-column-name swallower for SQLite < 3.35).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 01:05:52 +02:00
mike e183b386a1 fix(admin): show TOTP prompt instead of redirecting on 2fa-required login
The login form's hx-on::after-request redirected to /admin/ on any 2xx
response. The TOTP-required path also returns 2xx — with an HTML
fragment that unhides the TFA section — so the redirect fired before
the user ever saw the code input, locking out anyone who had enrolled
TOTP.

Only redirect when the 2xx body is empty (the real-login signal). When
the body is non-empty it's the prompt fragment, which htmx swaps into
#err and whose inline <script> reveals #tfa-section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:37:49 +02:00
mike 940b407560 fix(admin): drop overflow-hidden from action-dropdown tables
The Users and Devices tables had `overflow-hidden` on the wrapper div for
clean rounded corners. That same clipping was hiding the bottom half of
the per-row action menu (a `<details>`/`<summary>` popover absolutely
positioned inside the last cell). Removing `overflow-hidden` lets the
dropdown extend past the table edge — the popover already has its own
border + shadow, so the loss in corner aesthetics is negligible.

The other read-only tables (audit, recordings, oidc, address_books) keep
`overflow-hidden` since they don't host popovers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:26:43 +02:00
mike 5b288d671c feat: M5 admin dashboard (HTMX + Tailwind CDN, embedded HTML)
A web admin UI for the rustdesk-server, mounted at /admin/* on the
existing HTTP API listener. Single-binary deploy preserved — the two
HTML files live in admin_ui/ and are pulled into the binary via
include_str! at build time, so there's nothing extra to ship.

================================================================================
Architecture
================================================================================
- Stack: HTMX 1.9 + Tailwind play CDN. No SPA, no Node toolchain. Pages
  are server-rendered HTML fragments returned by Rust handlers via
  Html<String>; the index.html shell uses hx-get to drop a fragment into
  the main pane and hx-push-url for back-button history.
- Auth: same Bearer-token table the API uses. The dashboard log-in form
  POSTs username + password (+ optional TOTP) to /admin/login; on success
  the server mints a token and pins it in an HttpOnly + SameSite=Strict
  cookie (`rd_admin_session`). The AuthedUser extractor was extended to
  accept either the Authorization: Bearer header (curl, desktop client)
  OR the session cookie (browser).
- Embedding: src/api/admin/mod.rs has `include_str!("../../../admin_ui/index.html")`
  + login.html. No tower_http::ServeDir wildcard — we ran into axum 0.5
  routing conflicts between literal /admin/login routes and an /admin/*
  catch-all, so each HTML file is its own explicit route.

================================================================================
M5a — foundation
================================================================================
Files:
  admin_ui/index.html   page shell + sidebar + HTMX + 401-bounces-to-login
  admin_ui/login.html   credentials + TOTP form, posts to /admin/login
  src/api/admin/mod.rs  router + include_str! + Cache-Control: no-cache
  src/api/admin/auth.rs /admin/login POST (form-encoded), /admin/logout POST
  src/api/admin/me.rs   sidebar fragment ("Signed in as <name>")
  src/api/middleware.rs `AuthedUser` now reads either Bearer OR cookie
  src/api/state.rs      `admin_ui_dir` (informational; UI is embedded)
  src/main.rs           --admin-ui-dir flag (empty disables the dashboard)

The login flow asks for TOTP transparently in the same form when the
target user has a secret enrolled, so the dashboard inherits the TOTP
gate from the API auth surface for free.

================================================================================
M5b — full CRUD pages
================================================================================
- Users (src/api/admin/pages/users.rs) — list, create, password reset,
  toggle admin / status, TOTP enroll / unenroll, delete. TOTP enroll
  surfaces the secret + otpauth URL once, on a dismissible banner above
  the table.
- Devices (devices.rs) — list with hostname/OS/last-heartbeat/conn count,
  force-disconnect (queues `heartbeat_commands` row consumed at the next
  /api/heartbeat tick), force-sysinfo refresh.
- Device groups (groups.rs) — list / create / delete / add member /
  remove member. Per-group section, with an add-member dropdown of users
  not yet in the group.
- Strategies (strategies.rs) — list / create / edit config_options /
  delete. config_options is validated as a JSON object on the server side
  before persist; bad JSON is reflected to the page with a friendly
  error notice.
- Address books (address_books.rs) — read-only overview of all books
  with owner, kind (personal / shared badge), peer count, GUID.
- OIDC providers (oidc.rs) — read-only list of what's configured. Editing
  remains operator-side via --oidc-config TOML or direct SQL.

================================================================================
M5c — audit + recordings browsers
================================================================================
- Audit log (audit.rs) — three tabs (Connections / File transfers /
  Alarms), each capped at the latest 200 rows. Tab pills are HTMX links
  with hx-get + hx-target="#main" so the tab switch is a single fetch.
- Recordings (recordings.rs) — read-only list with peer / size / state /
  start / finish. Streaming download is a follow-up; for now operators
  pull files from --recording-dir directly.

================================================================================
DB methods added
================================================================================
- Users:    users_list_all, user_set_status, user_set_admin,
            user_set_password, user_delete, user_has_totp,
            raw_update_user_email
- Devices:  devices_list_all, device_sysinfo_get_conns,
            heartbeat_command_queue (also used elsewhere; surfaced)
- Groups:   device_groups_list_all, device_group_members,
            device_group_create, device_group_delete,
            device_group_add_member, device_group_remove_member
- Strategy: strategies_list_all, strategy_create,
            strategy_update_config, strategy_delete
- Audit:    audit_conn_list, audit_file_list, audit_alarm_list
- Misc:     ab_list_all_with_owner, recordings_list

All use the runtime sqlx::query("...") form (matching the project-wide
convention) so the SQLite compile-time-check macros don't require these
new tables to pre-exist in the dev DB.

================================================================================
Conventions enforced
================================================================================
- Every page handler gates on require_admin(&AuthedUser) — non-admin
  users get an HTTP 403 + JSON envelope, which the SPA shell catches and
  bounces back to the login form.
- HTML fragments are produced via `format!`-with-named-args; html_escape
  is centralized in src/api/admin/pages/shared.rs and applied to every
  user-supplied string before it lands in the DOM.
- All mutations return either the updated table fragment OR
  notice_html(kind, msg) + the table — same pattern across pages, so
  HTMX swap targets stay simple (always #region innerHTML).
- Cookie carries no path restriction so it also authorizes /api/* calls
  the dashboard might want to make from the browser; HttpOnly +
  SameSite=Strict mitigates XSS / CSRF; Max-Age tracks ApiConfig's
  session_ttl_secs (30 days).

================================================================================
Verification
================================================================================
1. cargo build --release — clean.
2. End-to-end smoke test:
   - /admin/ serves index.html (4406 bytes), /admin/login.html serves
     login.html (2598 bytes).
   - POST /admin/login with valid creds returns 200 + Set-Cookie
     `rd_admin_session=…; HttpOnly; Path=/; SameSite=Strict; Max-Age=…`.
   - All eight /admin/pages/* fragments return 200 with cookie.
   - Users CRUD round-trip: create alice → toggle admin → disable →
     reset password → enroll TOTP (32-char secret displayed once) →
     unenroll → delete; self-action guard rejects suicide deletes.
   - Groups CRUD: create engineering → add alice as member → SQL
     confirms the row.
   - Strategies: valid JSON accepted, invalid JSON rejected with a
     friendly notice.
   - Audit tabs: all three render 200; empty-state messages appear when
     no rows.
   - /admin/logout clears the cookie; subsequent /admin/me returns 401.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:13:35 +02:00
mike 8ecf05b106 feat: HTTP-over-rendezvous fallback (HttpProxyRequest)
Closes the M4 plan. When `OPTION_USE_RAW_TCP_FOR_API=Y` (typical in
locked-down networks where direct HTTPS to port 21114 is blocked), the
client wraps every /api/* request in an HttpProxyRequest protobuf and
ships it over the already-encrypted rendezvous TCP channel. We now decode
those messages on hbbs and dispatch them through the *same* axum Router
the HTTPS listener uses — so every existing handler (login, AB, audit,
TOTP, OIDC, devices/cli, plugin-sign, …) is reachable through this path
with zero per-route plumbing.

Components
==========
- libs/hbb_common (submodule, pro-features-httpproxy branch): backports
  HeaderEntry / HttpProxyRequest / HttpProxyResponse + union tags 27/28
  from upstream @87b11a7 onto our pinned @83419b6. Proto-only — the rest
  of hbb_common is unchanged so we keep the tokio 1.x / axum 0.5 / pinned
  reqwest fork intact (a full submodule bump risked breaking those).
- src/api/http_proxy.rs: the dispatch shim. Holds a `Mutex<Option<Router>>`
  populated by `api::serve` before the HTTPS listener starts, builds an
  `http::Request<Body>` from the proto fields (sanitizing hop-by-hop
  headers, defaulting Content-Type: application/json), runs it through
  `router.oneshot(req)`, and serializes the response into HttpProxyResponse.
  Tower added as a direct dep with the `util` feature for ServiceExt.
- src/api/mod.rs: pub mod http_proxy; install_router(app.clone()) before
  axum::Server::bind to share the router.
- src/rendezvous_server.rs::handle_tcp: new match arm right before the
  catch-all that decodes HttpProxyRequest and replies with an
  HttpProxyResponse via the existing Sink::TcpStream(..., Encrypt) path.
  The reply is automatically secretbox-sealed by `send_to_sink`, so the
  end-to-end channel is encrypted symmetrically with secure_tcp.
- examples/http_proxy_test.rs: end-to-end smoke test that opens a TCP
  connection, walks the secure_tcp handshake by hand (read server's
  signed box pubkey, derive symmetric key, send sealed reply), then
  ships an HttpProxyRequest GET /api/login-options and verifies the
  response is 200 + ["account"]. Used as the validation gate.

New crate deps
==============
- tower = "0.4"     (features = ["util"]) — for ServiceExt::oneshot
- http-body = "0.4" — for the Body trait import in dispatch

Verification
============
1. cargo build --release — clean.
2. examples/http_proxy_test against a fresh hbbs:
   [ok] secure_tcp handshake complete
   [ok] sent HttpProxyRequest GET /api/login-options
   [ok] response status = 200
   [ok] response body   = ["account"]
   [pass] full HTTP-over-rendezvous round trip verified
3. hbbs log confirms the secure_tcp handshake completed and the dispatch
   went through the standard axum router.

Notes on cherry-pick vs submodule bump
======================================
The plan flagged the bump as the riskiest M4 item because newer
hbb_common pulls newer tokio that breaks axum 0.5. The proto-only cherry
pick keeps everything stable; the upstream-divergence cost is one extra
commit in the hbb_common submodule that we own.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:29:46 +02:00
mike 3e89d61566 feat: add Pro-equivalent management API on top of OSS hbbs
Brings the rustdesk-server up to feature parity with RustDesk Server Pro for
the API surface the desktop client expects (CONSOLE_API.md). Implemented as
an in-process axum router mounted by hbbs alongside its existing
rendezvous + relay TCP/UDP/WS listeners; everything persists in the existing
SQLx + SQLite database via additional CREATE TABLE IF NOT EXISTS migrations.

================================================================================
M1 — Auth foundation + heartbeat + sysinfo
================================================================================
- New tables: users, tokens, device_sysinfo.
- Endpoints: HEAD+GET /api/login-options, POST /api/login, POST /api/logout,
  POST /api/currentUser, POST /api/heartbeat, POST /api/sysinfo_ver,
  POST /api/sysinfo.
- Bearer-token auth: tokens are 32 random bytes (base64url); only the
  sha256 of the token is stored. `tokens.last_used_at`/`expires_at` slide
  forward on every authenticated request (30-day TTL by default).
- Bcrypt-cost-10 password hashing, always wrapped in
  tokio::task::spawn_blocking to keep the runtime responsive.
- New CLI flags --http-port, --bootstrap-admin-username,
  --bootstrap-admin-password.
- Heartbeat returns the `sysinfo: true` flag on first contact and after
  cfg.sysinfo_ver bumps; sysinfo upload returns the bare-string body
  ("SYSINFO_UPDATED" / "ID_NOT_FOUND") the client expects.

================================================================================
M2 — Address book, device groups, accessible peers
================================================================================
- New tables: address_books, address_book_shares, address_book_peers,
  address_book_tags, address_book_peer_tags, device_groups,
  device_group_members. Soft-ALTER adds device_sysinfo.user_id (the
  binding from a device to its enrolled user, set by /api/login).
- Endpoints: POST /api/ab/settings, POST /api/ab/personal,
  POST /api/ab/shared/profiles, POST /api/ab/peers, POST /api/ab/tags/{guid},
  POST /api/ab/peer/add/{guid}, PUT /api/ab/peer/update/{guid},
  DELETE /api/ab/peer/{guid}, POST /api/ab/tag/add/{guid},
  PUT /api/ab/tag/rename/{guid}, PUT /api/ab/tag/update/{guid},
  DELETE /api/ab/tag/{guid}, GET+POST /api/ab (legacy single-blob fallback),
  GET /api/device-group/accessible, GET /api/users, GET /api/peers.
- Share-rule enforcement (1=read, 2=read/write, 3=full) at the top of every
  AB mutation. Owners are full; other rules come from
  address_book_shares (direct or via device_group). Rejection is HTTP 200 +
  {"error":"read-only"} so the client doesn't yank the session.
- New CLI flags --ab-legacy-mode, --ab-max-peers-per-book.
- Action endpoints (peer add/update/delete, tag CRUD) return HTTP 200 with
  EMPTY body on success — matches the Flutter _jsonDecodeActionResp at
  ab_model.dart:2002 which treats {} as the literal error string "null".

================================================================================
M3 — Audit, recording, strategy push
================================================================================
- New tables: audit_conn (PK guid echoed back to client),
  audit_file, audit_alarm, recordings, strategies, strategy_assignments,
  heartbeat_commands.
- Endpoints: POST /api/audit/conn (returns {"guid":"..."}),
  POST /api/audit/file, POST /api/audit/alarm, PUT /api/audit (note update),
  POST /api/record?type={new|part|tail|remove}.
- Recording uploader: filesystem state machine under --recording-dir;
  filenames sanitized to a single Normal path component to block traversal;
  `tail` writes the first ≤1024 bytes at offset 0 after all `part` chunks.
- Heartbeat extended to:
  * resolve a per-peer strategy (peer > device-group > user, highest
    priority wins) and emit `strategy.config_options` + `extra` +
    `modified_at`.
  * read-and-delete heartbeat_commands rows so an admin can queue
    `disconnect: [conn_id]` or force `sysinfo: true` via SQL and have it
    delivered on the next 15-second tick.
- New CLI flags --recording-dir (default ./recordings),
  --recording-max-size-mb, --audit-retention-days.

================================================================================
secure_tcp on the rendezvous TCP listener (M3 polish)
================================================================================
A logged-in client conditionally calls secure_tcp() on its TCP rendezvous
connection (src/client.rs:427-431, gated on `key && token` both non-empty).
OSS hbbs had no KeyExchange handler at all on TCP rendezvous, so the
client's secure_tcp_impl read timed out with "Failed to secure tcp:
deadline has elapsed". Added:
- A try_secure_tcp_handshake helper that, on every accepted TCP connection,
  generates an ephemeral box keypair, signs the box public key with the
  server's Ed25519 sk (already loaded for relay-response signing), sends
  KeyExchange, then waits 5s for the client's reply.
  - Reply is KeyExchange[client_box_pk, sealed_sym_key] -> decrypt the
    sealed key, install Encrypt on both halves of the stream.
  - Reply is any other RendezvousMessage -> buffer it and replay through
    the normal handle_tcp dispatcher (plain-mode clients filter unsolicited
    KeyExchange via get_next_nonkeyexchange_msg, so our preceding KX is
    harmless).
  - Reply never comes (timeout) -> fall through to plain mode.
- Sink::TcpStream now carries an Option<Encrypt>; outgoing writes are
  sealed when keyed. Symmetric Encrypt is cloned for inbound (`dec`) and
  outbound (`enc`) so the two directions track independent counters.

================================================================================
M4 — Advanced auth (TOTP, email-code, OIDC), CLI assign, plugin signing
================================================================================
- New tables: user_totp_secrets, pending_tfa_challenges,
  pending_email_codes, oidc_providers, oidc_sessions. Soft-ALTER adds
  users.oidc_subject.
- /api/login extended:
  * type:"account" (existing) — issues an `tfa_check` challenge (5-min
    nonce in `secret`) when the user has TOTP enrolled.
  * type:"tfa_code" — verifies the nonce + the 6-digit TOTP code against
    user_totp_secrets.secret_b32.
  * type:"email_code" — passwordless. First leg mints a 6-digit code and
    sends it via SMTP (or logs to stdout when --smtp-host is empty);
    second leg verifies. Brute-force capped at 5 attempts per code, then
    the row is purged.
- /api/oidc/auth + GET /oidc/callback + GET /api/oidc/auth-query implement
  the standard OAuth2 authorization-code flow with userinfo. Discovery via
  <issuer>/.well-known/openid-configuration with an in-memory cache.
  --oidc-config TOML upserts providers at startup; --public-base-url builds
  the redirect_uri.
- New endpoints: POST /api/2fa/enroll (admin-only, returns secret_b32 +
  otpauth_url), POST /api/2fa/unenroll, POST /api/devices/cli (used by
  `rustdesk --assign`; binds device to user, ensures device-group, adds
  AB entry, attaches peer-scoped strategy), POST /lic/web/api/plugin-sign
  (Ed25519 over the request body using the same id_ed25519 secret).
- /api/login-options is now dynamic: returns ["account"], plus "email_code"
  when SMTP or ALLOW_DEV_EMAIL_CODE is set, plus an "oidc/<name>" entry
  per enabled provider in oidc_providers.
- New CLI flags --smtp-host, --smtp-port, --smtp-user, --smtp-pass,
  --smtp-from, --smtp-tls, --public-base-url, --oidc-config.
- New crate deps: tokio (fs/io-util features), totp-rs, lettre (rustls +
  builder + smtp-transport, no defaults), toml.

================================================================================
Code organization
================================================================================
- src/api/                 axum router + shared state + error envelope
  ├── ab/                  address book endpoints (settings/profiles/peers/
  │                        tags/legacy/rules)
  ├── audit/               conn/file/alarm/note
  ├── oidc/                providers/discovery/auth/callback/poll
  ├── record/              storage state machine + handler
  ├── strategy/            resolver wrapper around DB
  ├── auth.rs              login/logout/currentUser
  ├── devices_cli.rs       /api/devices/cli
  ├── email.rs             SMTP transport (lettre) + dev-mode stdout fallback
  ├── error.rs             ApiError enum -> HTTP 200/401/403/404 + JSON envelope
  ├── groups.rs            /api/device-group/accessible
  ├── heartbeat.rs         /api/heartbeat
  ├── middleware.rs        AuthedUser extractor (Bearer -> sha256 -> token row)
  ├── pagination.rs        Page<T> + PageQuery
  ├── peers.rs             /api/peers
  ├── plugin_sign.rs       /lic/web/api/plugin-sign
  ├── state.rs             AppState + ApiConfig (incl. EmailConfig)
  ├── sysinfo.rs           /api/sysinfo, /api/sysinfo_ver
  ├── twofa.rs             /api/2fa/enroll, /unenroll
  └── users.rs             UserPayload + /api/users + bcrypt helpers

================================================================================
Conventions enforced throughout
================================================================================
- All new SQL uses the runtime sqlx::query("...") form (NOT the query!
  macro) so first-time builds don't require DATABASE_URL to point at a DB
  containing the new tables.
- Soft-ALTER helper (try_alter) swallows "duplicate column name" errors so
  schema migrations are idempotent across re-runs and existing-DB upgrades.
- Bcrypt compares always via spawn_blocking.
- Tokens (Bearer access_token, TFA challenge nonce, OIDC poll handle) are
  always 24-32 random bytes from sodiumoxide::randombytes; the Bearer is
  stored only as its sha256.
- Constant-time hash comparison for email codes.
- Action endpoints return HTTP 200 with empty body on success; HTTP 200 +
  {"error": "..."} for business errors so the client doesn't get logged
  out; 401 only from the auth middleware.

Tested end-to-end via curl + a stock RustDesk client (M1-M2 verified
against two laptops; M3 verified against the strategy-push and
force-disconnect paths; M4 verified via direct flow tests + a mock IdP for
OIDC). Stock client connect now works whether the user is signed in or
not (the secure_tcp regression that blocked logged-in connect is fixed).

The remaining piece on the M4 plan — HttpProxyRequest, the TCP-over-
rendezvous fallback for clients with OPTION_USE_RAW_TCP_FOR_API=Y — is
gated on bumping the OSS server's vendored hbb_common to a commit that
includes proto tags 27 and 28. That work lives on a separate branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:07:01 +02:00
115 changed files with 80262 additions and 32 deletions
+42
View File
@@ -0,0 +1,42 @@
# Copy to .env and edit. docker-compose reads it automatically.
# --- Required ----------------------------------------------------------------
# Public domain clients connect to. hbbs advertises this as the relay address.
RUSTDESK_DOMAIN=rd.gamecom.ch
# --- Bootstrap admin ---------------------------------------------------------
# Seeded into the users table on the FIRST startup only (when users is empty).
# Subsequent restarts ignore these — change the password via the admin UI.
# Without these set on first boot, you'll have no way to log in.
RUSTDESK_BOOTSTRAP_ADMIN_USERNAME=admin
RUSTDESK_BOOTSTRAP_ADMIN_PASSWORD=changeme
# --- Optional runtime --------------------------------------------------------
# Pre-shared key. "-" lets hbbs auto-generate on first run; "_" forces
# encrypted-only mode without an explicit key.
#RUSTDESK_KEY=-
# HTTP management API / admin UI port (pro-features). Set to 0 to disable.
#RUSTDESK_HTTP_PORT=21114
# Force relay for all sessions even on LAN.
#RUSTDESK_ALWAYS_USE_RELAY=Y
# When the admin UI shows a device's unattended (per-boot) password.
# logged-out only when nobody is logged in on the device (default)
# always also while an interactive user is logged in
#RUSTDESK_UNATTENDED_PWD_VISIBILITY=logged-out
#RUST_LOG=info
# --- Optional build source ---------------------------------------------------
# Override the upstream repo / branch the image is built from.
#RUSTDESK_GIT_URL=https://gitea.cstudio.ch/mike/rustdesk-server.git
#RUSTDESK_GIT_BRANCH=pro-features
# --- Database connectivity ---------------------------------------------------
DATABASE_URL=sqlite://./db_v2.sqlite3
+122
View File
@@ -0,0 +1,122 @@
name: build
on:
push:
branches: [pro-features]
workflow_dispatch:
env:
# Cargo.lock is lockfile v4, which requires Rust >= 1.78. Upstream's
# .github/workflows/build.yaml pins 1.90; mirror that here.
RUST_VERSION: "1.90"
jobs:
build-amd64:
name: build-linux-amd64
# Same self-hosted runner as the rustdesk client build. provision.sh on the
# host installs the Rust toolchain and devscripts/debhelper used here.
runs-on: [self-hosted, Linux, X64, ubuntu-22.04]
timeout-minutes: 120
steps:
- name: Checkout source
uses: actions/checkout@v4
with:
submodules: recursive
- name: Verify host toolchain
shell: bash
run: |
required=(git bash rustc cargo rustup pkg-config debuild dh dpkg-deb)
missing=()
for t in "${required[@]}"; do
if command -v "$t" >/dev/null 2>&1; then
printf '%-20s %s\n' "$t" "$(command -v "$t")"
else
missing+=("$t")
printf '%-20s MISSING\n' "$t"
fi
done
if [[ ${#missing[@]} -gt 0 ]]; then
echo "Missing tools: ${missing[*]}. Install via: sudo apt install -y devscripts build-essential debhelper pkg-config"
exit 1
fi
- name: Read version from Cargo.toml
shell: bash
run: |
# Single source of truth: the top-level Cargo.toml's first `version =`
# line (hbbs package). debian/changelog is patched to match below so
# the produced .deb filenames carry the same version.
display=$(grep -m1 -E '^version[[:space:]]*=' Cargo.toml \
| sed -E 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/')
[[ -n "$display" ]] || { echo "Could not read version from Cargo.toml"; exit 1; }
echo "Version : $display"
echo "VERSION_DISPLAY=$display" >> "$GITHUB_ENV"
- name: Patch debian/changelog with display version
shell: bash
run: |
# The .deb filename is derived from the first changelog entry's
# parenthesized version, NOT from Cargo.toml. Rewrite whatever
# version is in the leading entry to match $VERSION_DISPLAY.
sed -i -E "0,/^rustdesk-server \([^)]+\)/{s/^rustdesk-server \([^)]+\)/rustdesk-server (${VERSION_DISPLAY})/}" debian/changelog
head -1 debian/changelog
- name: Ensure Rust toolchain configured
shell: bash
run: |
rustup toolchain install "$RUST_VERSION" --profile minimal --component rustfmt
rustup default "$RUST_VERSION"
rustup target add x86_64-unknown-linux-gnu
rustc --version
cargo --version
- name: Build hbbs / hbbr / rustdesk-utils
shell: bash
run: |
set -e
# Native build for the runner's amd64 host. --all-features matches
# what upstream's GitHub workflow uses for its musl cross builds.
cargo build --release --all-features
mkdir -p debian-build/amd64/bin
cp -v target/release/hbbs debian-build/amd64/bin/
cp -v target/release/hbbr debian-build/amd64/bin/
cp -v target/release/rustdesk-utils debian-build/amd64/bin/
chmod -v a+x debian-build/amd64/bin/*
- name: Build .deb packages (amd64)
shell: bash
run: |
set -e
# Mirrors the deb-package job in upstream's .github/workflows/build.yaml:
# stage debian/ and systemd/ next to the pre-built bin/ tree, then run
# debuild -b so dh's auto_build step is a no-op (no source detected)
# and dh_install just packages the binaries listed in the .install files.
cp -vr debian systemd debian-build/amd64/
sed "s/{{ ARCH }}/amd64/" debian/control.tpl > debian-build/amd64/debian/control
(cd debian-build/amd64 && debuild -i -us -uc -b -aamd64)
mkdir -p ./SignOutput
mv -v ./debian-build/rustdesk-server-hbbs_${VERSION_DISPLAY}_amd64.deb ./SignOutput/
mv -v ./debian-build/rustdesk-server-hbbr_${VERSION_DISPLAY}_amd64.deb ./SignOutput/
mv -v ./debian-build/rustdesk-server-utils_${VERSION_DISPLAY}_amd64.deb ./SignOutput/
- name: Report signing status of build artifacts
shell: bash
run: |
# .deb files are typically signed with debsign or via the apt repo
# signing pipeline, not the .deb itself. Just list contents for now.
for f in ./SignOutput/*.deb; do
[[ -f "$f" ]] || continue
size=$(stat -c%s "$f")
printf '[UNSIGNED] %s (%d bytes)\n' "$(basename "$f")" "$size"
done
echo "::warning title=Unsigned .deb::Wire up debsigs / repo signing before distributing."
- name: Upload artifacts
uses: actions/upload-artifact@v3
with:
name: rustdesk-server-linux-amd64-${{ github.sha }}
path: SignOutput/rustdesk-server-*.deb
if-no-files-found: error
retention-days: 14
+1 -1
View File
@@ -1,3 +1,3 @@
[submodule "libs/hbb_common"]
path = libs/hbb_common
url = https://github.com/rustdesk/hbb_common
url = https://gitea.cstudio.ch/mike/hbb_common.git
Generated
+141 -4
View File
@@ -195,6 +195,12 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "base32"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076"
[[package]]
name = "base64"
version = "0.13.0"
@@ -414,6 +420,12 @@ dependencies = [
"toml 0.5.9",
]
[[package]]
name = "constant_time_eq"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -567,12 +579,13 @@ dependencies = [
[[package]]
name = "digest"
version = "0.10.3"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@@ -668,6 +681,22 @@ version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
[[package]]
name = "email-encoding"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a87260449b06739ee78d6281c68d2a0ff3e3af64a78df63d3a1aeb3c06997c8a"
dependencies = [
"base64 0.22.1",
"memchr",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -1068,7 +1097,7 @@ dependencies = [
[[package]]
name = "hbbs"
version = "1.1.15"
version = "1.1.17-pro"
dependencies = [
"async-speed-limit",
"async-trait",
@@ -1084,15 +1113,18 @@ dependencies = [
"hbb_common",
"headers",
"http",
"http-body",
"ipnetwork",
"jsonwebtoken",
"lazy_static",
"lettre",
"local-ip-address",
"mac_address",
"machine-uid 0.2.0",
"minreq",
"once_cell",
"ping",
"qrcode",
"regex",
"reqwest",
"rust-ini",
@@ -1101,7 +1133,11 @@ dependencies = [
"serde_json",
"sodiumoxide",
"sqlx",
"tokio",
"tokio-tungstenite",
"toml 0.7.8",
"totp-rs",
"tower",
"tower-http",
"tungstenite",
"uuid",
@@ -1163,6 +1199,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "http"
version = "0.2.7"
@@ -1294,6 +1339,16 @@ dependencies = [
"unicode-normalization",
]
[[package]]
name = "idna"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6"
dependencies = [
"unicode-bidi",
"unicode-normalization",
]
[[package]]
name = "indexmap"
version = "1.8.1"
@@ -1438,6 +1493,33 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
[[package]]
name = "lettre"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76bd09637ae3ec7bd605b8e135e757980b3968430ff2b1a4a94fb7769e50166d"
dependencies = [
"async-trait",
"base64 0.21.7",
"email-encoding",
"email_address",
"fastrand",
"futures-io",
"futures-util",
"httpdate",
"idna 0.3.0",
"mime",
"nom 7.1.1",
"once_cell",
"quoted_printable",
"rustls 0.21.12",
"rustls-pemfile 1.0.0",
"socket2 0.4.4",
"tokio",
"tokio-rustls 0.24.1",
"webpki-roots 0.23.1",
]
[[package]]
name = "lexical-core"
version = "0.7.6"
@@ -2056,6 +2138,12 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9e1dcb320d6839f6edb64f7a4a59d39b30480d4d1765b56873f7c858538a5fe"
[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
[[package]]
name = "quickcheck"
version = "1.0.3"
@@ -2083,6 +2171,12 @@ dependencies = [
"proc-macro2 1.0.93",
]
[[package]]
name = "quoted_printable"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a3866219251662ec3b26fc217e3e05bf9c4f84325234dfb96bf0bf840889e49"
[[package]]
name = "rand"
version = "0.8.5"
@@ -2404,6 +2498,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.100.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f6a5fc258f1c1276dfe3016516945546e2d5383911efc0fc4f1cdc5df3a4ae3"
dependencies = [
"ring 0.16.20",
"untrusted 0.7.1",
]
[[package]]
name = "rustls-webpki"
version = "0.101.7"
@@ -2558,6 +2662,17 @@ dependencies = [
"digest",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.2"
@@ -3141,6 +3256,19 @@ dependencies = [
"winnow",
]
[[package]]
name = "totp-rs"
version = "5.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2b36a9dd327e9f401320a2cb4572cc76ff43742bcfc3291f871691050f140ba"
dependencies = [
"base32",
"constant_time_eq",
"hmac",
"sha1",
"sha2",
]
[[package]]
name = "tower"
version = "0.4.12"
@@ -3338,7 +3466,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c"
dependencies = [
"form_urlencoded",
"idna",
"idna 0.2.3",
"matches",
"percent-encoding",
]
@@ -3503,6 +3631,15 @@ dependencies = [
"webpki",
]
[[package]]
name = "webpki-roots"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b03058f88386e5ff5310d9111d53f48b17d732b401aeb83a8d5190f2ac459338"
dependencies = [
"rustls-webpki 0.100.3",
]
[[package]]
name = "webpki-roots"
version = "0.25.4"
+8 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hbbs"
version = "1.1.15"
version = "1.1.17-pro"
authors = ["rustdesk <info@rustdesk.com>"]
edition = "2021"
build = "build.rs"
@@ -18,6 +18,11 @@ path = "src/utils.rs"
[dependencies]
hbb_common = { path = "libs/hbb_common" }
tokio = { version = "1", features = ["fs", "io-util"] }
totp-rs = { version = "5.4", default-features = false }
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
lettre = { version = "0.10", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "builder"] }
toml = "0.7"
serde_derive = "1.0"
serde = "1.0"
serde_json = "1.0"
@@ -45,7 +50,9 @@ tokio-tungstenite = "0.17"
tungstenite = "0.17"
regex = "1.4"
tower-http = { version = "0.3", features = ["fs", "trace", "cors"] }
tower = { version = "0.4", features = ["util"] }
http = "0.2"
http-body = "0.4"
flexi_logger = { version = "0.22", features = ["async", "use_chrono_for_offset", "dont_minimize_extra_stacks"] }
ipnetwork = "0.20"
local-ip-address = "0.5.1"
+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>RustDesk &mdash; Connect</title>
<link rel="stylesheet" href="/admin/connect/assets/bundle.css" />
<!--
The Rust handler at src/api/admin/pages/connect.rs replaces
{{CUSTOM_CONFIG}} with a JSON object the SPA reads on boot. Same
pattern as the deploy page and rustdesk.com/web/.
-->
<script id="custom-config" type="application/json">{{CUSTOM_CONFIG}}</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/admin/connect/assets/bundle.js"></script>
</body>
</html>
+294
View File
@@ -0,0 +1,294 @@
<!doctype html>
<html lang="{{LANG_CODE}}" class="h-full">
<head>
<meta charset="utf-8" />
<title>{{T_APP_TITLE}}</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="/admin/assets/tailwindcss.js"></script>
<script src="/admin/assets/htmx.min.js"></script>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
.nav-link.active { background: rgb(15 23 42); color: rgb(125 211 252); }
</style>
</head>
<body class="h-full bg-slate-950 text-slate-100">
<!--
Single-page shell. The sidebar drives navigation via HTMX:
each link does an `hx-get` of an HTML fragment URL that returns the
body of the page. The fragments live under /admin/pages/ and are
server-rendered Rust handlers that return Html<String>.
This keeps the UI a flat directory of static files plus a small
set of fragment endpoints — no SPA, no Node, no build step.
-->
<div class="min-h-full flex">
<aside class="w-56 shrink-0 bg-slate-900 border-r border-slate-800 flex flex-col">
<div class="px-4 py-5 border-b border-slate-800">
<h1 class="text-base font-semibold">{{T_APP_TITLE}}</h1>
<p id="me-display" class="text-xs text-slate-500 mt-1" hx-get="/admin/me" hx-trigger="load" hx-swap="innerHTML"></p>
</div>
<nav class="flex-1 px-2 py-3 space-y-1">
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
hx-get="/admin/pages/users" hx-target="#main" hx-push-url="#users">{{T_NAV_USERS}}</a>
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
hx-get="/admin/pages/devices" hx-target="#main" hx-push-url="#devices">{{T_NAV_DEVICES}}</a>
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
hx-get="/admin/pages/groups" hx-target="#main" hx-push-url="#groups">{{T_NAV_GROUPS}}</a>
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
hx-get="/admin/pages/strategies" hx-target="#main" hx-push-url="#strategies">{{T_NAV_STRATEGIES}}</a>
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
hx-get="/admin/pages/address-books" hx-target="#main" hx-push-url="#address-books">{{T_NAV_AB}}</a>
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
hx-get="/admin/pages/audit" hx-target="#main" hx-push-url="#audit">{{T_NAV_AUDIT}}</a>
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
hx-get="/admin/pages/deploy" hx-target="#main" hx-push-url="#deploy">{{T_NAV_DEPLOY}}</a>
</nav>
<div class="px-2 py-3 border-t border-slate-800 space-y-1">
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-400 hover:bg-slate-800"
hx-get="/admin/pages/profile" hx-target="#main" hx-push-url="#profile">{{T_NAV_PROFILE}}</a>
<button
class="w-full text-left px-3 py-1.5 text-sm rounded text-slate-400 hover:bg-slate-800"
hx-post="/admin/logout"
hx-on::after-request="window.location.href = '/admin/login.html'"
>{{T_NAV_SIGNOUT}}</button>
<div class="pt-2">
<label class="block text-[10px] uppercase tracking-wide text-slate-600 px-3 mb-1">{{T_LANGUAGE}}</label>
<select
class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300"
onchange="document.cookie='admin_lang='+this.value+'; path=/; max-age=31536000; samesite=strict'; window.location.reload();"
>
<option value="en"{{LANG_SEL_EN}}>English</option>
<option value="de"{{LANG_SEL_DE}}>Deutsch</option>
<option value="es"{{LANG_SEL_ES}}>Español</option>
<option value="fr"{{LANG_SEL_FR}}>Français</option>
<option value="ro"{{LANG_SEL_RO}}>Română</option>
</select>
</div>
<p class="text-[10px] text-slate-600 text-center pt-2">v{{APP_VERSION}}</p>
</div>
</aside>
<main id="main" class="flex-1 p-6 overflow-x-hidden">
<div class="text-slate-500 text-sm">{{T_LOADING}}</div>
</main>
</div>
<!-- Toast container used by all admin handlers via hx-trigger="load delay:1s" -->
<div id="toast"
class="fixed bottom-4 right-4 max-w-sm space-y-2 pointer-events-none"></div>
<!-- Load fragment + highlight active link based on the URL hash.
Sub-routes like #users/new map to dedicated fragment URLs but
keep the parent section's nav-link highlighted. -->
<script>
// Hash → fragment URL for routes that aren't owned by a sidebar
// nav-link (e.g. forms on their own page). The first path segment
// also tells us which nav-link to highlight.
const SUB_ROUTES = {
'#users/new': '/admin/pages/users/new',
};
function topLevelHash(hash) {
const slash = hash.indexOf('/');
return slash === -1 ? hash : hash.slice(0, slash);
}
function linkForHash() {
const hash = location.hash || '#users';
const top = topLevelHash(hash);
return document.querySelector('.nav-link[hx-push-url="' + top + '"]')
|| document.querySelector('.nav-link[hx-push-url="#users"]');
}
function refreshActive() {
const active = linkForHash();
document.querySelectorAll('.nav-link').forEach(a => {
a.classList.toggle('active', a === active);
});
}
// Dynamic deep-links: `#devices/<id>` and `#devices/<id>/exec`.
// The detail / exec fragments are designed to swap into the
// devices index page's `#devices-region` section, so when we
// land here from a page refresh we have to chain two ajax calls:
// first render the parent page (which provides `#devices-region`),
// then swap the fragment into it. htmx.ajax has returned a Promise
// since 1.9.4, so the `.then` chain is safe at our pinned 1.9.10.
const DEEP_LINK_PATTERNS = [
{
re: /^#devices\/([^/]+)\/exec$/,
parent: '/admin/pages/devices',
fragment: id => `/admin/pages/devices/${encodeURIComponent(id)}/exec`,
},
{
re: /^#devices\/([^/]+)$/,
parent: '/admin/pages/devices',
fragment: id => `/admin/pages/devices/${encodeURIComponent(id)}/detail`,
},
];
function loadDeepLink(hash) {
for (const p of DEEP_LINK_PATTERNS) {
const m = hash.match(p.re);
if (!m) continue;
const id = decodeURIComponent(m[1]);
Promise.resolve(
htmx.ajax('GET', p.parent, { target: '#main', swap: 'innerHTML' })
).then(() =>
htmx.ajax('GET', p.fragment(id), {
target: '#devices-region',
swap: 'innerHTML',
})
);
return true;
}
return false;
}
function loadFromHash() {
const hash = location.hash || '#users';
if (loadDeepLink(hash)) {
refreshActive();
return;
}
const subUrl = SUB_ROUTES[hash];
if (subUrl) {
htmx.ajax('GET', subUrl, { target: '#main', swap: 'innerHTML' });
} else {
const link = linkForHash();
if (link) {
htmx.ajax('GET', link.getAttribute('hx-get'),
{ target: '#main', swap: 'innerHTML' });
}
}
refreshActive();
}
window.addEventListener('hashchange', loadFromHash);
document.body.addEventListener('htmx:afterSwap', refreshActive);
loadFromHash();
// Bounce to login if any HTMX request comes back 401.
document.body.addEventListener('htmx:responseError', (evt) => {
if (evt.detail.xhr.status === 401) {
window.location.href = '/admin/login.html';
}
});
// Plain-link confirmation prompt. HTMX has hx-confirm for its own
// requests; this is the equivalent for raw `<a href>` anchors that
// can't go through HTMX (e.g. "Connect (web client)", which opens
// a new tab and triggers a popup on the controlled machine — easy
// to fire by accident from the row action menu). Use:
//
// <a href="..." data-confirm="..." onclick="return confirmFromDataAttr(this)">
//
// The message lives in the data-attribute (only HTML-escaped, no
// JS-string escaping) which keeps the server-side renderer simple.
function confirmFromDataAttr(el) {
const msg = el && el.dataset ? el.dataset.confirm : '';
return !msg || window.confirm(msg);
}
window.confirmFromDataAttr = confirmFromDataAttr;
// Close any open per-row action popover when a click happens outside it.
// The action dropdowns are <details class="... relative"> with an
// absolutely-positioned panel; the deploy page uses <details> too but
// without `relative`, so the selector is specific to the popover style.
document.addEventListener('click', (e) => {
document.querySelectorAll('details.relative[open]').forEach(d => {
if (!d.contains(e.target)) d.removeAttribute('open');
});
});
// Read the current value of the users-search input (if present).
// Used by usersColumnToggle/usersPageSize so a column or page-size
// change preserves the active filter.
function usersSearchValue() {
const el = document.getElementById('users-search');
return el ? el.value : '';
}
// Users table column-visibility toggle. The popover in the page header
// emits checkboxes with onchange="usersColumnToggle(this)" — we POST
// the new state to the server (which persists it in user_prefs) and
// swap in the re-rendered table so the popover stays open.
function usersColumnToggle(input) {
const col = input.dataset.col;
if (!col) return;
htmx.ajax('POST', '/admin/pages/users/columns', {
target: '#users-region',
swap: 'innerHTML',
values: {
col: col,
visible: input.checked ? '1' : '0',
q: usersSearchValue(),
},
});
}
window.usersColumnToggle = usersColumnToggle;
// Users table per-page selector. Driven by the <select> in the
// pagination footer — POSTs to persist the choice and re-renders the
// table at page 1 (size change shifts which rows are on which page).
function usersPageSize(size) {
htmx.ajax('POST', '/admin/pages/users/page-size', {
target: '#users-region',
swap: 'innerHTML',
values: { size: size, q: usersSearchValue() },
});
}
window.usersPageSize = usersPageSize;
// Devices table — mirrors the users helpers above. Same persistence
// model (per-user prefs in `user_prefs`) and the same fragment-swap
// approach so the columns popover and search input stay put while
// pagination/columns/page-size all preserve the active filter.
function devicesSearchValue() {
const el = document.getElementById('devices-search');
return el ? el.value : '';
}
function devicesColumnToggle(input) {
const col = input.dataset.col;
if (!col) return;
htmx.ajax('POST', '/admin/pages/devices/columns', {
target: '#devices-region',
swap: 'innerHTML',
values: {
col: col,
visible: input.checked ? '1' : '0',
q: devicesSearchValue(),
},
});
}
window.devicesColumnToggle = devicesColumnToggle;
function devicesPageSize(size) {
htmx.ajax('POST', '/admin/pages/devices/page-size', {
target: '#devices-region',
swap: 'innerHTML',
values: { size: size, q: devicesSearchValue() },
});
}
window.devicesPageSize = devicesPageSize;
// The devices table lives inside an `overflow-x-auto` wrapper so wide
// column sets get a horizontal scrollbar instead of pushing the page
// out. CSS forces overflow-y to auto on the same axis, which would
// clip the per-row action popover (a `<details>` > `<div>` inside a
// <td>). On toggle we flip the popover to `position: fixed` and pin
// it to the summary's viewport rect so it escapes the scroll context.
// Inline `ontoggle=` is preserved across htmx swaps without re-binding.
function actionMenuToggle(details) {
const popover = details.querySelector('[data-action-menu]');
if (!popover) return;
if (!details.open) {
popover.style.position = '';
popover.style.top = '';
popover.style.left = '';
popover.style.right = '';
return;
}
const summary = details.querySelector('summary');
if (!summary) return;
const rect = summary.getBoundingClientRect();
popover.style.position = 'fixed';
popover.style.top = rect.bottom + 4 + 'px';
popover.style.right = (window.innerWidth - rect.right - 8) + 'px';
popover.style.left = 'auto';
}
window.actionMenuToggle = actionMenuToggle;
</script>
</body>
</html>
+126
View File
@@ -0,0 +1,126 @@
<!doctype html>
<html lang="{{LANG_CODE}}" class="h-full">
<head>
<meta charset="utf-8" />
<title>{{T_SIGNIN}} — {{T_TITLE}}</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="/admin/assets/tailwindcss.js"></script>
<script src="/admin/assets/htmx.min.js"></script>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
</style>
</head>
<body class="h-full bg-slate-950 text-slate-100 flex items-center justify-center">
<main class="w-full max-w-sm px-6">
<div class="text-center mb-8">
<h1 class="text-2xl font-semibold">{{T_TITLE}}</h1>
<p class="text-slate-400 text-sm mt-1">{{T_SUBTITLE}}</p>
</div>
<form
class="space-y-4 bg-slate-900 border border-slate-800 rounded-lg p-6 shadow-xl"
hx-post="/admin/login"
hx-target="#err"
hx-swap="innerHTML"
hx-on::before-swap="
/* The auth handler returns 401 with an HTML error fragment for
bad credentials / disabled / not-admin / bad-TOTP. HTMX skips
the swap on 4xx by default, so force it back on. */
if (event.detail.xhr.status >= 400 && event.detail.xhr.status < 500) {
event.detail.shouldSwap = true;
event.detail.isError = false;
}
"
hx-on::after-request="
const xhr = event.detail.xhr;
if (event.detail.successful && (xhr.responseText || '').trim() === '') {
/* Empty 2xx body = real login. The TOTP-required path returns 2xx
with an HTML prompt fragment, which we MUST NOT redirect away
from. */
window.location.href = '/admin/';
}
"
>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1" for="username">{{T_USERNAME}}</label>
<input
id="username" name="username" type="text" required autocomplete="username"
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500"
/>
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1" for="password">{{T_PASSWORD}}</label>
<input
id="password" name="password" type="password" required autocomplete="current-password"
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500"
/>
</div>
<div id="tfa-section" class="hidden">
<label class="block text-xs font-medium text-slate-400 mb-1" for="tfaCode">{{T_TOTP_LABEL}}</label>
<input
id="tfaCode" name="tfaCode" type="text" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code"
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm tracking-widest text-center focus:outline-none focus:border-sky-500"
/>
<input id="secret" name="secret" type="hidden" />
</div>
<button
type="submit"
class="w-full bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition"
>
{{T_SIGNIN}}
</button>
<div id="err" class="text-sm text-rose-400 min-h-[1.25em]"></div>
</form>
<!-- OIDC providers (rendered only when /admin/oidc/providers is non-empty) -->
<div id="oidc-block" class="mt-6 hidden">
<div class="flex items-center gap-3 mb-3">
<div class="flex-1 h-px bg-slate-800"></div>
<span class="text-xs text-slate-500">{{T_OR}}</span>
<div class="flex-1 h-px bg-slate-800"></div>
</div>
<div id="oidc-buttons" class="space-y-2"></div>
</div>
<div class="mt-6 text-center">
<label class="text-[10px] uppercase tracking-wide text-slate-600 mr-2">{{T_LANGUAGE}}</label>
<select
class="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300"
onchange="document.cookie='admin_lang='+this.value+'; path=/; max-age=31536000; samesite=strict'; window.location.reload();"
>
<option value="en"{{LANG_SEL_EN}}>English</option>
<option value="de"{{LANG_SEL_DE}}>Deutsch</option>
<option value="es"{{LANG_SEL_ES}}>Español</option>
<option value="fr"{{LANG_SEL_FR}}>Français</option>
<option value="ro"{{LANG_SEL_RO}}>Română</option>
</select>
</div>
<p class="mt-4 text-center text-[10px] text-slate-600">v{{APP_VERSION}}</p>
</main>
<script>
// Fetch enabled providers and render one button each. The button just
// navigates to /admin/login/oidc/<name>, which 302s the browser to the
// IdP. After the IdP redirects to /oidc/callback, the server sets our
// session cookie and redirects to /admin/.
var SIGNIN_WITH = {{T_SIGNIN_WITH_JSON}};
fetch('/admin/oidc/providers').then(r => r.json()).then(list => {
if (!Array.isArray(list) || list.length === 0) return;
const block = document.getElementById('oidc-block');
const root = document.getElementById('oidc-buttons');
list.forEach(p => {
const a = document.createElement('a');
a.href = '/admin/login/oidc/' + encodeURIComponent(p.name);
a.className = 'block w-full text-center bg-slate-800 hover:bg-slate-700 border border-slate-700 text-sm rounded px-4 py-2 transition';
a.textContent = SIGNIN_WITH + ' ' + (p.display_name || p.name);
root.appendChild(a);
});
block.classList.remove('hidden');
}).catch(() => { /* silently hide block on any error */ });
</script>
</body>
</html>
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1,4 +1,4 @@
rustdesk-server (1.1.15) UNRELEASED; urgency=medium
rustdesk-server (1.1.17-pro) UNRELEASED; urgency=medium
* Fix: 127.0.0.1 is not loopback (#515)
* Higher default bandwidth
+64 -7
View File
@@ -1,21 +1,72 @@
version: '3'
# Builds a minimal debian image that, on every container start, pulls the
# hbbs/hbbr/utils .deb from a Gitea Actions artifact and installs it before
# launching the binary. Both services share the same image; only the command
# differs.
#
# The .deb is fetched at runtime (not build time), so `docker compose up`
# always picks up the newest successful run on $GITEA_BRANCH. To pin a
# specific zip and skip auto-discovery, set ARTIFACT_URL in your shell or
# .env, e.g.:
# ARTIFACT_URL=https://gitea.cstudio.ch/mike/rustdesk-server/actions/runs/173/artifacts/rustdesk-server-linux-amd64-1e961cdd929f7af97148b76d9de79998a89402a3 \
# docker compose up -d
#
networks:
rustdesk-net:
external: false
# The image only needs to bundle curl/jq/unzip/tini/dpkg + the fetch scripts —
# no build args required. The actual .deb download happens at container start
# (see docker/entrypoint.sh), driven by the env vars in x-rustdesk-env below.
x-rustdesk-build: &rustdesk-build
context: ./docker
dockerfile: Dockerfile.deb
# Runtime env. Two groups:
# 1) Artifact-fetch config consumed by entrypoint.sh on every container
# start — set ARTIFACT_URL to pin a specific zip, otherwise the script
# picks the newest successful run on $GITEA_BRANCH.
# 2) hbbs/hbbr knobs. Most settings (relay, bootstrap admin, key, http
# port) are passed via CLI flags below — the binary's env-var
# convention transforms `--foo-bar` into `FOO-BAR` (literal dashes,
# uppercase), which is awkward in YAML, so flags are clearer.
x-rustdesk-env: &rustdesk-env
GITEA_URL: "${GITEA_URL:-https://gitea.cstudio.ch}"
GITEA_OWNER: "${GITEA_OWNER:-mike}"
GITEA_REPO: "${GITEA_REPO:-rustdesk-server}"
GITEA_BRANCH: "${GITEA_BRANCH:-pro-features}"
ARTIFACT_URL: "${ARTIFACT_URL:-}"
RUST_LOG: "${RUST_LOG:-info}"
# Force relay for all sessions even on LAN. Uncomment to enable.
# ALWAYS_USE_RELAY: "Y"
# Override DB path. Default: ./db_v2.sqlite3 in WORKDIR
# (= /var/lib/rustdesk-server/db_v2.sqlite3 in this image).
# DB_URL: "/var/lib/rustdesk-server/db_v2.sqlite3"
services:
hbbs:
container_name: hbbs
build: *rustdesk-build
image: rustdesk-server-cst:latest
platform: linux/amd64
command:
- hbbs
- --bootstrap-admin-username=${RUSTDESK_BOOTSTRAP_ADMIN_USERNAME:-admin}
- --bootstrap-admin-password=${RUSTDESK_BOOTSTRAP_ADMIN_PASSWORD:-changeme}
# - --key=- # "-" auto-generates a key; "_" forces encrypted-only with no explicit key
# - --http-port=21114 # admin HTTP API/UI port; 0 disables
# When the admin UI shows a device's unattended password.
# logged-out (default) = only when nobody is logged in; always = also while a user is logged in.
- --unattended-pwd-visibility=${RUSTDESK_UNATTENDED_PWD_VISIBILITY:-logged-out}
environment: *rustdesk-env
ports:
- 21114:21114
- 21115:21115
- 21116:21116
- 21116:21116/udp
- 21118:21118
image: rustdesk/rustdesk-server:latest
command: hbbs -r rustdesk.example.com:21117
volumes:
- ./data:/root
- ./data:/var/lib/rustdesk-server
networks:
- rustdesk-net
depends_on:
@@ -24,13 +75,19 @@ services:
hbbr:
container_name: hbbr
# Same build + image tag as hbbs — compose builds once and both reuse it.
build: *rustdesk-build
image: rustdesk-server-cst:latest
platform: linux/amd64
command:
- hbbr
# - --key=- # match the key set on hbbs (if any)
environment: *rustdesk-env
ports:
- 21117:21117
- 21119:21119
image: rustdesk/rustdesk-server:latest
command: hbbr
volumes:
- ./data:/root
- ./data:/var/lib/rustdesk-server
networks:
- rustdesk-net
restart: unless-stopped
+29
View File
@@ -0,0 +1,29 @@
# Minimal debian image with the tooling needed to download + install the
# hbbs/hbbr/rustdesk-utils .deb at *container start* (not build time).
# The actual fetch happens in /usr/local/sbin/entrypoint.sh, which calls
# fetch-artifact.sh on every start — see docker-compose.yml for the runtime
# env vars (GITEA_URL, GITEA_OWNER, GITEA_REPO, GITEA_BRANCH, ARTIFACT_URL).
#
# The Gitea workflow only produces amd64 .debs, so pin the image platform.
# On non-amd64 hosts (e.g. Apple Silicon) Docker will emulate via qemu.
FROM --platform=linux/amd64 debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl jq unzip tini \
&& rm -rf /var/lib/apt/lists/*
COPY fetch-artifact.sh /usr/local/sbin/fetch-artifact.sh
COPY entrypoint.sh /usr/local/sbin/entrypoint.sh
RUN chmod +x /usr/local/sbin/fetch-artifact.sh /usr/local/sbin/entrypoint.sh
WORKDIR /var/lib/rustdesk-server
# 21114 admin http, 21115 nat test, 21116/tcp+udp signal, 21117 relay,
# 21118 web socket signal, 21119 web socket relay.
EXPOSE 21114 21115 21116 21116/udp 21117 21118 21119
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/sbin/entrypoint.sh"]
CMD ["hbbs"]
+48
View File
@@ -0,0 +1,48 @@
# Multi-stage build: clones rustdesk-server from a Git remote and builds the
# hbbs / hbbr / rustdesk-utils binaries from source.
#
# Build args:
# RUSTDESK_GIT_URL Git URL to clone (default: gitea.cstudio.ch fork)
# RUSTDESK_GIT_BRANCH Branch / tag / ref to check out (default: pro-features)
# RUST_VERSION Rust toolchain image tag (default: 1-bookworm)
ARG RUST_VERSION=1-bookworm
FROM rust:${RUST_VERSION} AS builder
ARG RUSTDESK_GIT_URL=https://gitea.cstudio.ch/mike/rustdesk-server.git
ARG RUSTDESK_GIT_BRANCH=pro-features
# sqlx::query! macros verify SQL at compile time against the checked-in
# db_v2.sqlite3. Override only if you point cargo at a different DB.
ARG DATABASE_URL=
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates pkg-config cmake \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
RUN git clone --recurse-submodules --shallow-submodules \
--branch "${RUSTDESK_GIT_BRANCH}" --single-branch \
"${RUSTDESK_GIT_URL}" .
RUN if [ -n "${DATABASE_URL}" ]; then export DATABASE_URL="${DATABASE_URL}"; fi \
&& cargo build --release --bins
# Runtime stage
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates tini \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /src/target/release/hbbs /usr/local/bin/hbbs
COPY --from=builder /src/target/release/hbbr /usr/local/bin/hbbr
COPY --from=builder /src/target/release/rustdesk-utils /usr/local/bin/rustdesk-utils
COPY --from=builder /src/admin_ui /opt/rustdesk/admin_ui
WORKDIR /root
EXPOSE 21114 21115 21116 21116/udp 21117 21118 21119
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["hbbs"]
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Runtime entrypoint: pulls the latest .deb artifact from Gitea Actions and
# installs it on every container start, then execs the CMD (hbbs / hbbr).
#
# Configuration comes from the environment — see docker-compose.yml:
# ARTIFACT_URL pinned zip URL; if unset the script discovers the newest
# successful run on GITEA_BRANCH
# GITEA_URL, GITEA_OWNER, GITEA_REPO, GITEA_BRANCH
# required when ARTIFACT_URL is unset
#
# Container fails to start if the fetch fails — that's intentional: we always
# want a fresh artifact, never a stale one.
set -euo pipefail
/usr/local/sbin/fetch-artifact.sh
exec "$@"
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Fetches a Gitea Actions artifact zip and installs every .deb inside.
#
# Two modes:
# 1. $ARTIFACT_URL is set → download that zip directly.
# 2. Otherwise → discover the newest successful run on $GITEA_BRANCH via the
# `/api/v1/.../actions/tasks` endpoint and download
# `<run.url>/artifacts/<ARTIFACT_PREFIX><head_sha>`. We use the web
# download URL rather than `/api/v1/.../actions/artifacts`, which on this
# Gitea instance returns an empty list even when uploads have succeeded.
set -euo pipefail
ARTIFACT_URL="${ARTIFACT_URL:-}"
ARTIFACT_PREFIX="${ARTIFACT_PREFIX:-rustdesk-server-linux-amd64-}"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
if [[ -n "$ARTIFACT_URL" ]]; then
zip_url="$ARTIFACT_URL"
echo "==> Using pinned ARTIFACT_URL: $zip_url"
else
: "${GITEA_URL:?GITEA_URL required when ARTIFACT_URL is unset}"
: "${GITEA_OWNER:?GITEA_OWNER required when ARTIFACT_URL is unset}"
: "${GITEA_REPO:?GITEA_REPO required when ARTIFACT_URL is unset}"
: "${GITEA_BRANCH:?GITEA_BRANCH required when ARTIFACT_URL is unset}"
api="${GITEA_URL%/}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}"
echo "==> Listing workflow runs at $api/actions/tasks (branch=$GITEA_BRANCH)"
list="$(curl -fsSL "$api/actions/tasks?limit=20")"
# Newest successful run on $GITEA_BRANCH. The .url field is the html run
# page (e.g. .../actions/runs/173) — append /artifacts/<name> for the zip.
read -r run_url head_sha < <(jq -r --arg branch "$GITEA_BRANCH" '
.workflow_runs
| map(select(.head_branch == $branch and .status == "success"))
| sort_by(.updated_at)
| last
| if . == null then "" else "\(.url) \(.head_sha)" end
' <<<"$list")
if [[ -z "${run_url:-}" || "$run_url" == "null" ]]; then
echo "ERROR: no successful run on branch '$GITEA_BRANCH'." >&2
jq -r '.workflow_runs[] | " url=\(.url) branch=\(.head_branch) status=\(.status) updated=\(.updated_at)"' <<<"$list" >&2 || true
exit 1
fi
zip_url="$run_url/artifacts/${ARTIFACT_PREFIX}${head_sha}"
echo "==> Discovered $zip_url"
fi
echo "==> Downloading $zip_url"
curl -fsSL -o "$work/artifact.zip" "$zip_url"
mkdir -p "$work/deb"
unzip -o "$work/artifact.zip" -d "$work/deb"
mapfile -t debs < <(find "$work/deb" -type f -name '*.deb' | sort)
if [[ ${#debs[@]} -eq 0 ]]; then
echo "ERROR: artifact zip contained no .deb files" >&2
exit 1
fi
printf ' - %s\n' "${debs[@]}"
# Postinst scripts call deb-systemd-invoke/systemctl; block them from starting
# anything while we're inside a build layer.
echo '#!/bin/sh' >/usr/sbin/policy-rc.d
echo 'exit 101' >>/usr/sbin/policy-rc.d
chmod +x /usr/sbin/policy-rc.d
# The .debs declare "Depends: systemd", which would drag full systemd into the
# image. The binaries themselves don't need it at runtime — only the bundled
# .service files reference it — so install with --force-depends.
dpkg -i --force-depends "${debs[@]}"
rm -f /usr/sbin/policy-rc.d
+356
View File
@@ -0,0 +1,356 @@
# Agent API authentication
Reference for the per-device signature gate on the agent-facing HTTP
API. Seven endpoints are gated:
- `POST /api/heartbeat`
- `POST /api/sysinfo`
- `POST /api/unattended-password`
- `POST /api/agent/exec-result` — managed-only (no legacy/unsigned path)
- `POST /api/agent/login-event` — user-logon / logoff events observed
by the agent. Same TOFU lifecycle as heartbeat / sysinfo: stock
RustDesk doesn't post here at all, so in practice every caller is a
managed agent; the legacy/unsigned path is kept for symmetry.
- `POST /api/agent/metrics` — continuous CPU / memory / top-process
samples (≈1 / minute). Surfaced on the admin Devices detail page as
a 24 h sparkline + live snapshot card.
- `POST /api/agent/perf-events` — sparse Windows-event-log entries
flagged by `Microsoft-Windows-Diagnostics-Performance/Operational`,
`Microsoft-Windows-Resource-Exhaustion-Detector/Operational`, and
hand-picked `System` IDs (41 / 6008 / 1001 — unexpected reboot /
dirty shutdown / BSOD). Server dedups via UNIQUE (peer_id, provider,
record_id).
For the operator workflow — turning it on, the dashboard toggle, what
happens when a managed agent is uninstalled — see the matching section
in [CONFIGURATION.md](CONFIGURATION.md).
## Why this exists
All three endpoints originally accepted any caller who supplied an `id`
and `uuid` in the JSON body. Knowing those two values (plaintext on the
device, sent over the rendezvous wire) was enough to inject arbitrary
inventory or heartbeat state for that device — including BIOS serials,
BitLocker recovery keys, the active console user, network interfaces,
connection lists, and the per-boot unattended-access password the
admin UI surfaces to support staff.
The fix reuses the Ed25519 keypair that the agent **already** generates
on first run and registers with the rendezvous server via `RegisterPk`.
Every signed HTTP request is verified against the public key the
rendezvous handshake stored in `peer.pk`, so the trust root is the same
one the relay encryption already depends on. No new credential to
provision, no new secret to leak.
## Trust root
```
First run Rendezvous (port 21116, TCP/protobuf)
agent generates sk,pk ───── RegisterPk(id, pk) ─────► server stores
in hello-agent.toml peer.pk
Every subsequent request HTTP API (port 21114)
agent signs body server verifies sig
with sk ───── POST /api/heartbeat ─────► against peer.pk
───── POST /api/sysinfo ─────► (when peer.managed=1)
───── POST /api/unattended-password ─────►
───── POST /api/agent/exec-result ─────► (always required)
```
The same secret key signs both the rendezvous identity proof and the
HTTP-API payload — there's only one credential per device.
## Per-peer `managed` flag
The gate is per-device, controlled by the `peer.managed` column
(`INTEGER NOT NULL DEFAULT 0`, added by a soft `ALTER` at startup).
| `managed` | Server behaviour |
|-----------|----------------------------------------------------------------------------------|
| `0` | Legacy path. Signed requests are still verified if present, but absence is OK. |
| `1` | Signature required. Any unsigned request claiming this `id` returns 401. |
How the flag transitions:
- **TOFU promote (0 → 1).** The first request that arrives with a valid
signature flips `managed` to 1. Hello-agent signs from boot one, so
the first heartbeat after a hello-agent install transparently locks
the peer down. No admin action required.
- **Admin promote (0 → 1).** `PUT /api/peers/:id/managed {"managed":true}`
or the **Require signed API** action in the dashboard's Devices row
menu. Useful for pre-enrolling a peer record before the agent has
posted anything.
- **Admin downgrade (1 → 0).** Same endpoint, `{"managed":false}`, or
**Allow unsigned API** in the dashboard. Use when the managed agent
has been replaced with stock RustDesk on that device. The dashboard
toggle requires a confirm because the operation reopens the
spoofing surface.
- **Never auto-downgraded.** A failed signature on a `managed=1` peer
is a 401, full stop — there is no "fall back to unsigned" path.
- **Invalid sig on a `managed=0` peer is also 401**, never silently
treated as legacy. This prevents an attacker from probing for the
legacy path by deliberately sending a broken signature.
## Wire format
A signed agent request carries two headers in addition to the JSON body:
```
X-RD-Device-Id: <id>
X-RD-Signature: v1.<unix_ts>.<base64(ed25519_sig)>
```
The signed message is the byte concatenation:
```
"rd-api-v1\n" || METHOD || "\n" || PATH || "\n" || TS || "\n" || sha256(BODY)
```
Where:
- `METHOD` is the uppercase HTTP method (`POST`).
- `PATH` is the request path with leading slash and no query string
(`/api/heartbeat`, `/api/sysinfo`, `/api/unattended-password`).
- `TS` is the same decimal Unix timestamp that appears in the header.
- `sha256(BODY)` is the raw 32-byte SHA-256 of the request body — *not*
hex-encoded, *not* base64-encoded. It is concatenated as binary.
- The signature is detached Ed25519 over that 32-byte-plus-prefix
message, base64-encoded with the standard alphabet and no
URL-safe substitutions.
The `v1.` prefix on the header value reserves a rotation point. The
server rejects any other version string.
### Why this shape
- **Domain separator (`rd-api-v1\n`)** prevents the same `sk` being
tricked into signing data interpretable as another protocol.
- **Method + path** stop a captured `POST /api/sysinfo` signature from
being replayed as some future `POST /api/disconnect`.
- **`sha256(body)`** lets us sign without holding the body twice in
memory on the verify side, and survives any future proxy
re-chunking.
- **Timestamp in both the header and the signed message** makes the
skew check trivial without re-parsing the signature value.
## Server-side verification
The extractor [`api::device_auth::verify`](../src/api/device_auth.rs)
runs before each agent handler:
1. **Parse headers.** Both `X-RD-Device-Id` and `X-RD-Signature` must
be present, or both absent. Mixed states are 401.
2. **Validate the signature envelope.** Version must be `v1`. The
timestamp must be within ±300 seconds of the server's clock. The
base64 decode must succeed.
3. **Replay-check.** A keyed-by-`(id, ts, sig-prefix)` LRU cache (size
16 384, sliding 600-second TTL, sweep-on-insert) rejects exact
replays inside the window. If the cache is full, we accept and skip
the cache — DoS-by-cache-exhaustion is uninteresting compared to
the rest of the surface.
4. **Look up `peer.pk` and `peer.managed`** in one query.
5. **Verify the detached Ed25519 signature** against the canonical
signed-message bytes (see *Wire format* above).
6. **TOFU promote.** A valid signature on a `managed=0` peer flips the
flag to 1 in the same request. The promote is best-effort — if the
DB write fails, the original request is still served, the next
heartbeat will retry.
7. **Bind the trusted id to the body.** After the handler parses JSON,
the body's `id` field must match the header's `X-RD-Device-Id`.
Mismatch is 401 — this is the gate that stops a signed request from
being repurposed to write to a different peer's row.
If no signature headers are present and the peer is `managed=0`, the
verifier returns `LegacyUnsigned`; the handler then calls
`enforce_managed_for_id(body.id)` after parsing the body, which still
rejects unsigned requests for any *other* peer that has since become
managed.
## Agent-side signing
The signer is one small module: [`vendor/rustdesk/src/hbbs_http/sign.rs`](https://example.invalid/sign.rs)
in the hello-agent vendor tree. It reads the existing
`Config::get_key_pair()` (returns `(sk, pk)` from `hello-agent.toml`)
and the existing `Config::get_id()`, builds the canonical message, and
calls `sodiumoxide::crypto::sign::sign_detached`. Returns the two
header lines joined by `\n`, ready for the multi-header parser in
`common.rs::post_request_`.
The agent always tries to sign. If the keypair hasn't been generated
yet (extremely early boot, before rendezvous has run), the signer
returns `None`, the request goes out unsigned, and:
- If `peer.managed=0`: server accepts it (legacy path).
- If `peer.managed=1`: server returns 401, the agent's next heartbeat
retries.
This is the only condition under which a hello-agent build sends an
unsigned request, and it self-resolves on the next sync tick.
## Operational gotchas
- **Stock RustDesk clients keep working** because they post unsigned
and their peer rows stay at `managed=0`. The first time you install
hello-agent on a device, the existing `peer.pk` row gets reused (the
agent re-generated a keypair iff `hello-agent.toml` was wiped). The
first signed heartbeat then promotes the row.
- **`hello-agent --uninstall` preserves the keypair.** A reinstall is
transparent — signing keeps working.
- **Wiping `hello-agent.toml` between sessions** does mean the next
boot generates a new keypair. The rendezvous server will treat that
as a key roll (`register_pk of … due to key not confirmed`) and
store the new `pk`. The signed HTTP API picks up the new key as soon
as that rendezvous step completes — usually within a few seconds.
See [the stale-key recovery note in hello-agent's README](https://example.invalid/README.md)
for the supporter-side symptoms of a key drift.
- **Clock skew over ±5 minutes** will reject signatures. If your
fleet shows scattered 401s on heartbeat, check NTP on the affected
hosts. The server side is the canonical clock.
- **Replay cache survives only inside a single hbbs process.** A
restart clears it. Combined with the 300-second skew window this
means a captured signature is replayable across a restart if and
only if both restarts happen inside that window — an acceptable
trade-off for keeping the cache in-memory.
- **One server, mixed fleet.** Stock clients and hello-agent clients
can target the same hbbs without any flag-level config. The gate is
per-peer.
## Failure modes & log lines
| Symptom | Likely cause |
|-------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
| Heartbeats from a known peer suddenly return 401 | Peer was just promoted (TOFU or admin) and the agent build doesn't sign yet → upgrade agent. |
| Heartbeats fail intermittently with 401 | Clock skew > 5 min, or NAT churn replaying a captured request inside the window. |
| `peer X TOFU-promoted to managed=1` in hbbs log | Normal — first valid signature from a previously-unsigned peer. |
| `admin <user> set peer X managed=<bool> via dashboard` | Normal — operator used the Devices toggle. |
| `peer_set_managed(X) failed: …` | DB write failed during TOFU promote. The request was still served; next request will retry. |
| Admin row shows **Unsigned** for a peer running hello-agent | Agent hasn't completed its first signed POST yet (keypair race), or it's running a build |
| | that pre-dates the signing patch — check `vendor/rustdesk/src/hbbs_http/sign.rs` is present. |
## Remote PowerShell exec
Layered on top of the signature gate. An admin in the dashboard sends a
script to a peer; the agent runs it as its service account; output and
exit code come back into the dashboard within ~1 s (the heartbeat
interval).
### Three independent gates
A dispatch must pass **all three** server-side checks before a row is
queued — the agent never sees a script it shouldn't have:
1. **`AuthedUser.is_admin`** — only admins can dispatch.
2. **`peer.managed = 1`** — the same flag the signed-API gate uses.
This means TOFU has already promoted the peer (or an admin explicitly
flipped it). Stock RustDesk clients are uninvited.
3. **Strategy `enable-remote-exec = "Y"`** — the resolved strategy for
the peer must explicitly opt in. Defaults to off. Set it on a strategy,
assign the strategy to the peer (or its group / owner), exec is now
live for that scope. *Server-side only — the key is never pushed to
the client.* See [STRATEGIES.md](STRATEGIES.md).
### Wire path
```
Admin UI ──POST /admin/pages/devices/:id/exec──► Server inserts exec_history(status='queued')
Agent's next heartbeat reply carries `exec: [{cmd_id, script, max_secs, max_bytes}]`;
the server flips the row to 'running' atomically (exec_pop_queued_for_peer).
Agent runs `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command -`,
writes the script to stdin, captures stdout+stderr up to 1 MiB, kills on 5-minute wall clock.
Admin UI ◄──poll /admin/pages/devices/:id/exec/:cmd_id/poll── Server ◄──POST /api/agent/exec-result (signed)── Agent
```
### Limits
| Setting | Default | Where |
|----------------|--------:|------------------------------------------------------------|
| Script size | 32 KiB | `src/api/admin/pages/exec.rs::MAX_SCRIPT_BYTES` |
| Wall-clock | 300 s | `src/api/heartbeat.rs::EXEC_MAX_SECS` (sent to agent) |
| Output capture | 1 MiB | `src/api/heartbeat.rs::EXEC_MAX_BYTES` (sent to agent) |
| In-flight/peer | 1 | `exec_in_flight_count > 0` blocks new dispatch |
The agent enforces wall-clock and output-capture locally — server caps
are advisory unless you also harden the agent. If you don't trust your
own agent build, the server caps still bound storage and replay-cost.
### Result POST authentication
`POST /api/agent/exec-result` is the only agent endpoint that **always**
requires a signature, even when the peer happens to be `managed=0`.
There's no legacy compatibility story for exec — if the agent can't
sign, the result POST is rejected outright and the row sits in `running`
until an admin notices. Reason: an attacker who can spoof `(id, uuid)`
shouldn't be able to forge "I executed your command and here's the
output" for a device they don't actually control.
### Operational notes
- **The dispatch row stays `running` until the agent posts a result.**
If the agent crashes mid-script there's no automatic timeout cleanup
yet (planned: a hourly task that flips long-stuck `running` rows to
`errored`). Admins can dispatch a fresh command after the in-flight
one ages past 5 minutes by waiting; the in-flight check is wall-clock
based on `issued_at`.
- **Output may contain secrets.** A `Get-Content` of a credential file
goes straight into the `exec_history` table and the admin UI. The
current schema has no per-row access control beyond "is_admin"; if
you need finer scoping, audit log retention plus your `users` table
ACL is the only knob.
- **No interactive REPL yet.** Each dispatch is one shot: write script,
run, read result. Multi-command sessions or interactive prompts
(Read-Host, sudo-style passwords) will hang and time out. This is by
design for v1 — Option B in the original architecture discussion.
## File map
Server:
| Path | Purpose |
|-------------------------------------------|------------------------------------------------------------------|
| `src/api/device_auth.rs` | The verifier (extractor + replay cache + TOFU promote). |
| `src/api/heartbeat.rs`, `src/api/sysinfo.rs`, `src/api/unattended.rs` | Wired to call `verify` then `enforce_managed_for_id`. |
| `src/api/agent_exec.rs` | `POST /api/agent/exec-result` (sig-required, no legacy path). |
| `src/api/peers.rs::set_managed` | `PUT /api/peers/:id/managed` admin endpoint. |
| `src/api/admin/pages/devices.rs::toggle_managed` | Dashboard action handler. |
| `src/api/admin/pages/exec.rs` | Per-device exec page (form + history + HTMX poll fragment). |
| `src/api/strategy/mod.rs::allows_remote_exec` | Resolves the per-peer strategy and reads `enable-remote-exec`. |
| `src/database.rs::M2_SOFT_ALTERS` | `ALTER TABLE peer ADD COLUMN managed`. |
| `src/database.rs::M5_SCHEMA` | `CREATE TABLE exec_history` + indexes. |
| `src/database.rs::peer_get_auth, peer_set_managed` | DB helpers (untyped `sqlx::query` so they survive the no-DB-migrated dev build). |
| `src/database.rs::exec_create, exec_pop_queued_for_peer, exec_finish, exec_get_by_cmd_id, exec_in_flight_count, exec_list_for_peer` | Exec lifecycle helpers. |
Agent — hello-agent vendor tree:
| Path | Purpose |
|------------------------------------------------------------|---------------------------------------------------------------|
| `vendor/rustdesk/src/hbbs_http/sign.rs` | The signer. |
| `vendor/rustdesk/src/hbbs_http/sync.rs` (call sites + `EXEC_SENDER`) | Heartbeat + sysinfo POSTs sign; heartbeat reply forwards queued `exec` requests to the broadcast channel. |
| `vendor/rustdesk/src/common.rs::post_request_, parse_simple_header` | Header parser now accepts `\n`-separated `Name: Value` pairs (backward-compatible). |
| `vendor/rustdesk/src/lib.rs` | `pub mod hbbs_http` — required so hello-agent can reach both `::sign` and `::sync::exec_signal_receiver`. |
Agent — hello-agent crate (outside the vendor tree):
| Path | Purpose |
|-------------------------------------|-----------------------------------------------------------------------------------------|
| `src/unattended_password.rs::try_report` | Reports the per-boot password to `/api/unattended-password`; signs the POST. |
| `src/exec.rs` | PowerShell runner. Subscribes to the sync layer's broadcast channel, spawns `powershell.exe`, captures stdout/stderr with caps, signs and POSTs the result to `/api/agent/exec-result`. Started from `run_server()` in main.rs. |
## Out of scope
Other agent / management endpoints exist on the same server. They are
deliberately *not* covered by this gate because their trust model is
different:
| Endpoint | Why it isn't signature-gated |
|--------------------------------|-----------------------------------------------------------------------------------------------------------|
| `POST /api/devices/cli` | Enrollment via `rustdesk --assign --token <T> …`. Already authenticated by a user/admin bearer session; the operator's job is to *supply* an arbitrary `(id, uuid)` for binding. Requiring the device's `sk` would defeat the use case. |
| `GET /api/sysinfo_ver` | Returns a single public version string. No body, no DB write — no spoof surface to gate. |
| `POST /api/record` | Session-recording upload. Disabled by default in the OSS uploader; managed builds use it under a separate auth model. Out of scope for the current sweep. |
| `POST /api/login`, `/api/login-options`, `/api/currentUser`, `/api/logout` | User session management — separate auth model (password + TOTP / OIDC). |
| Everything under `/api/ab/*`, `/api/audit/*`, `/api/peers*`, `/api/2fa/*`, `/api/oidc/*`, `/admin/*` | Already gated by `AuthedUser` (cookie or bearer). |
+759
View File
@@ -0,0 +1,759 @@
# RustDesk Server (hbbs) — Configuration Guide
This document covers the runtime flags exposed by `hbbs`, the file formats
it reads (notably `oidc.toml`), and the operator workflows that string
those together — bootstrap admin, OIDC sign-in, TOTP, address books,
strategies, recordings, the admin dashboard.
The matching desktop-client API surface is documented separately in the
`rustdesk` repo at `docs/CONSOLE_API.md`.
---
## CLI flags
Pass flags directly on the command line. There is no config file for
hbbs itself (only the optional `oidc.toml` referenced from a flag).
### Networking & rendezvous
| Flag | Default | Purpose |
|---|---|---|
| `--port=<NUM>` | `21116` | TCP/UDP rendezvous port. |
| `--rendezvous-servers=<HOSTS>` | unset | Peer rendezvous servers (comma-separated). |
| `--relay-servers=<HOSTS>` | unset | Default relay hosts handed to clients. |
| `--rmem=<BYTES>` | platform default | UDP recv buffer size. Bump along with `net.core.rmem_max`. |
| `--mask=<CIDR>` | unset | LAN mask (e.g. `192.168.0.0/16`) used to flag local connections. |
| `--key=<B64>` | derived from `id_ed25519` | Force a specific public key; clients must match. Leave unset to auto-load `id_ed25519` next to the binary. |
### HTTP API & dashboard
| Flag | Default | Purpose |
|---|---|---|
| `--http-port=<NUM>` | `21114` | HTTP API port (`/api/*`) and admin dashboard (`/admin/*`). `0` disables both. |
| `--http-listen=<HOST>` | wildcard | Bind address for `--http-port`. Set to `127.0.0.1` (or `::1`) when nginx/Caddy fronts this port for TLS so the reverse proxy can claim the public port without colliding. See "TLS deployment with nginx" below. |
| `--ws-listen=<HOST>` | wildcard | Bind address for the browser-facing WebSocket rendezvous port (`port + 2`, default 21118). Same usage pattern as `--http-listen`. The plain TCP/UDP rendezvous ports (21115/21116) intentionally stay on the wildcard — desktop clients don't go through the reverse proxy. |
| `--admin-ui-dir=<PATH>` | `./admin_ui` | Hint at where the dashboard's static HTML lives. The HTML is *embedded* in the binary; this flag is informational. Setting it to empty (`--admin-ui-dir=`) disables the dashboard entirely. |
| `--public-base-url=<URL>` | unset | The externally-reachable HTTP base of this server, e.g. `https://rustdesk.example.com:21114`. **Required when OIDC is enabled** — used to build `/oidc/callback` redirect URIs. |
The matching flag on **hbbr** is `--ws-listen=<HOST>` (binds the relay's WS port, default 21119). hbbr's plain TCP relay port (21117) stays on the wildcard for desktop clients. See `./hbbr --help` for the full list.
### Bootstrap admin
| Flag | Purpose |
|---|---|
| `--bootstrap-admin-username=<USER>` | On first startup, if the `users` table is empty *and* both flags are set, insert one admin user. Subsequent restarts ignore these flags (no overwrite). |
| `--bootstrap-admin-password=<PASS>` | Same. Bcrypt-hashed at insert time. |
If you forget to bootstrap, hbbs logs a warning at startup ("no users in users table"); recover by either restarting with the flags or `INSERT INTO users` directly via `sqlite3`.
### Address books
| Flag | Default | Purpose |
|---|---|---|
| `--ab-legacy-mode=<on\|off>` | `off` | When `on`, `/api/ab/personal` returns 404. Forces clients into the legacy single-blob AB mode. |
| `--ab-max-peers-per-book=<NUM>` | `100` | Surfaced via `/api/ab/settings.max_peer_one_ab`. Soft cap; the client uses it for UI hints. |
### Recordings
| Flag | Default | Purpose |
|---|---|---|
| `--recording-dir=<PATH>` | `./recordings` | Root for `/api/record` uploads. One subdirectory per peer. |
| `--recording-max-size-mb=<NUM>` | unset (=unlimited) | Per-file ceiling. Aborts oversized parts. |
> **Note:** Stock OSS RustDesk clients **do not upload** recordings to `/api/record` — the uploader's `ENABLE` flag at `src/hbbs_http/record_upload.rs` has no setter in OSS source. Server-side recording requires a custom client build that flips that flag. The `Recordings` admin tab will stay empty for stock clients; the endpoint is provided for wire parity with Pro clients.
### Audit retention
| Flag | Default | Purpose |
|---|---|---|
| `--audit-retention-days=<NUM>` | `0` (=keep forever) | Hourly task deletes `audit_conn` / `audit_file` / `audit_alarm` rows older than N days. |
### Email-code login (`/api/login` with `type:"email_code"`)
| Flag | Default | Purpose |
|---|---|---|
| `--smtp-host=<HOST>` | unset | If unset, codes are *logged to stdout* (dev mode) instead of mailed. The `email_code` login option is also dropped from `/api/login-options` until SMTP is configured. |
| `--smtp-port=<NUM>` | `587` | |
| `--smtp-user=<USER>` | unset | Omit for unauthenticated relays. |
| `--smtp-pass=<PASS>` | unset | |
| `--smtp-from=<ADDR>` | `noreply@<smtp-host>` | From: header. |
| `--smtp-tls=<on\|off>` | `on` | STARTTLS on the SMTP transport. |
### OIDC
| Flag | Purpose |
|---|---|
| `--oidc-config=<PATH>` | TOML file (see below). Providers are upserted into `oidc_providers` at startup. Re-run with a different file to change providers; rows missing from the new file remain in the DB but can be `enabled=0`'d via SQL. |
| `--public-base-url=<URL>` | **Required** if any provider is configured. Determines the redirect URI registered with the IdP. |
---
## OIDC integration
The server speaks standard OIDC Authorization Code flow with discovery
(`/.well-known/openid-configuration`). Tested against Zitadel; should work
with any standards-compliant IdP (Keycloak, Auth0, Google, Okta, Authelia,
Dex, etc.).
Two entry points are wired:
1. **Desktop client**`/api/login-options` advertises `oidc/<name>` per enabled provider. The Flutter login dialog renders a button per advertised name. Clicking starts the device-flow polling cycle (`/api/oidc/auth` → browser → `/oidc/callback``/api/oidc/auth-query` poll).
2. **Admin dashboard**`/admin/login.html` fetches `/admin/oidc/providers` and renders a "Sign in with X" button per provider. Clicking jumps the browser to `/admin/login/oidc/<name>` which 302-redirects to the IdP. After the IdP returns to `/oidc/callback`, the server detects the admin-flow sentinel and finishes by setting the dashboard session cookie + redirecting to `/admin/`.
### `oidc.toml` schema
Pass via `--oidc-config /path/to/oidc.toml`.
```toml
[[providers]]
# Slug used in URLs (`/admin/login/oidc/<name>`, `/api/login-options`
# advertises `oidc/<name>`). Lowercase, no spaces.
name = "zitadel"
# Display label on the sign-in button.
display_name = "Sign in with Zitadel"
# Optional. Square icon URL shown next to the label (not used yet by all
# UIs; reserved for future button rendering).
# icon_url = "https://example.com/zitadel.svg"
# OIDC issuer. The server fetches `<issuer_url>/.well-known/openid-configuration`
# and caches the discovery doc in-process. Trailing slash is stripped.
issuer_url = "https://idp.example.com"
# Application credentials from the IdP.
client_id = "..."
client_secret = "..."
# Scopes requested at the authorization endpoint. Most setups want
# "openid email profile". Zitadel additionally needs the project audience
# scope to receive role claims (see Role-based admin sync below).
scopes = "openid email profile"
# Optional. If unset, computed as `<--public-base-url>/oidc/callback`.
# Override only when you reverse-proxy under a different host.
# redirect_url = "https://rustdesk.example.com/oidc/callback"
# Optional. Defaults to true.
enabled = true
# --- Role-based admin sync (optional) ---
# When `admin_role` is set, every successful sign-in via this provider
# evaluates the userinfo claim at `roles_claim` and forces the local
# user's `is_admin` to (role present in claim). Promotion AND demotion
# at the IdP propagate. Leave both unset to manage admin status manually
# from the dashboard.
# admin_role = "admin"
# roles_claim = "roles" # or e.g. "urn:zitadel:iam:org:project:roles"
```
`oidc.toml` may contain multiple `[[providers]]` blocks for multi-IdP setups.
### Walk-through: Zitadel
#### In Zitadel
1. **Project → New project** (or pick an existing one).
2. **New application** under the project:
- Type: **Web**
- Authentication flow: **Code** (Authorization Code with client secret)
- Auth method: **Basic** *or* **Post** (server sends `client_id` + `client_secret` in the form body — both modes accept that)
- **Redirect URIs**: `<public-base-url>/oidc/callback` — character-exact, including scheme. Zitadel rejects `http://` redirects on non-localhost unless dev mode is on, so use TLS in production.
3. **Authorizations** — assign the project's roles to whichever users you want to be admins.
4. **Project → General**: turn on **"Assert Roles On Authentication"** so roles flow into the userinfo response.
5. Copy **Client ID** and **Client Secret** from the application's overview page.
#### `oidc.toml`
```toml
[[providers]]
name = "zitadel"
display_name = "Sign in with Zitadel"
issuer_url = "https://your-instance.zitadel.cloud"
client_id = "PASTE_FROM_ZITADEL"
client_secret = "PASTE_FROM_ZITADEL"
# `urn:zitadel:iam:org:project:id:zitadel:aud` is required for the project's
# roles to be included in the userinfo response.
scopes = "openid email profile urn:zitadel:iam:org:project:id:zitadel:aud"
admin_role = "admin"
roles_claim = "urn:zitadel:iam:org:project:roles"
```
#### `hbbs` flags
```sh
./hbbs --http-port 21114 \
--public-base-url 'https://rustdesk.example.com:21114' \
--oidc-config /etc/rustdesk/oidc.toml
```
#### Verify
After hbbs starts, look for:
```
oidc: provider "zitadel" configured
oidc: loaded 1 providers from /etc/rustdesk/oidc.toml
```
Then:
```sh
# 1. Provider visible to the desktop client
curl -s http://127.0.0.1:21114/api/login-options
# expect a list including "oidc/zitadel"
# 2. Provider visible to the admin dashboard
curl -s http://127.0.0.1:21114/admin/oidc/providers
# expect [{"name":"zitadel","display_name":"Sign in with Zitadel",...}]
# 3. Discovery is reachable (IdP-side)
curl -s https://your-instance.zitadel.cloud/.well-known/openid-configuration | jq .issuer
```
### Role-based admin sync
When `admin_role` is set on a provider, every successful sign-in evaluates
the userinfo claim at `roles_claim` (defaults to `"roles"` if unset) and
forces `users.is_admin` accordingly. **Promotion and demotion at the IdP
propagate on the next login.**
Two claim shapes are supported:
- **Object** (Zitadel default at `urn:zitadel:iam:org:project:roles`): role names are keys.
```json
"urn:zitadel:iam:org:project:roles": {
"admin": {"123": "myorg"},
"user": {"123": "myorg"}
}
```
- **Array of strings** (generic, common with Keycloak, Auth0 custom claims):
```json
"roles": ["admin", "user"]
```
Set `admin_role = "admin"` and either set `roles_claim` to the exact claim
name (Zitadel) or omit it to default to `"roles"` (generic).
> **Sharp edge:** when role-sync is configured, manually-granted admin
> rights in the dashboard get **revoked** on the next OIDC login if the
> role isn't present at the IdP. This is the correct contract for a
> single source of truth, but surprising if you forget. Manage admin
> status in *one* place at a time.
### Troubleshooting OIDC
- **"Sign-in complete" page in browser but desktop client stays at "Waiting account auth"**: usually a state mismatch between server and client. Check `hbbs.log` — the poll endpoint logs every tick at INFO. If you see `status=success` lines that don't stop, suspect a wire-shape mismatch. (This was a real bug we hit and fixed; see git log for `oidc envelope`.)
- **Browser shows "identity provider returned an error"**: check `oidc_sessions.error` for the row that just failed. Most common: `redirect_uri` mismatch between Zitadel and `--public-base-url`.
- **No "Sign in with X" button in the dashboard or desktop client**: check `oidc_provider_list_enabled()` returns rows. If `--public-base-url` is empty, `/admin/oidc/providers` and `/api/login-options` both suppress OIDC entries (the redirect URI would be unbuildable).
- **Admin landing on the "no admin access" error after first OIDC sign-in**: expected if `admin_role` isn't configured. Either configure role-sync (preferred), or have the user sign in once to create their row, then promote them on the Users page. The next OIDC sign-in resolves to that row.
---
## TOTP / 2FA
TOTP enrollment is **self-service**: each user enables it for their own
account from the dashboard's "My profile" page. The flow is two-step
with QR confirmation, so no secret is stored until the user proves they
have a working authenticator:
1. Sign in to the dashboard → **My profile** (sidebar) → **Enroll TOTP**.
2. Server generates a fresh secret and renders an inline SVG QR code +
the base32 secret for manual entry. Nothing is written to
`user_totp_secrets` at this point.
3. User scans the QR into an authenticator (1Password, Authy, Google
Authenticator, etc.) and submits the 6-digit code shown.
4. Server verifies the code against the pending secret. On match, the
secret lands in `user_totp_secrets`. Wrong code re-renders the same
QR with an error notice — no need to re-scan.
5. Removing TOTP requires the user's current password.
Admins can disable a user's TOTP from the **Users** page action menu
(useful when a user lost their authenticator), but **cannot enroll it
on someone else's behalf** — the user has to do that themselves so the
secret never travels through admin hands. OIDC-linked accounts skip
local TOTP entirely; their MFA lives at the IdP.
After enrollment, the next desktop-client login flow is:
1. Username + password → server returns `{"type":"email_check","tfa_type":"tfa_check","secret":<nonce>}`.
2. Client opens its verification-code dialog → user enters the 6-digit code → re-POSTs `/api/login` with `type:"email_code"` (yes, that's what the desktop client sends for both email and TOTP second legs), `tfaCode` set, `secret` echoed back.
3. Server verifies the code against `user_totp_secrets`, mints an access token, returns `{"type":"access_token", ...}`.
For dashboard logins, the inline form at `/admin/login.html` shows the TOTP field after the first password submit returns the prompt fragment.
---
## Strategies (server-pushed config)
Strategies push `config_options` to peers via heartbeat replies. They are
managed entirely from the dashboard's **Strategies** page. Resolution
order per peer:
1. Direct peer-scoped assignment (`strategy_assignments.peer_id`)
2. Device-group assignment via the peer's owner
3. User assignment
The peer's `Config::get_option` calls reflect the resolved values within
~15 s of any change to `modified_at` on the strategy row.
See [STRATEGIES.md](STRATEGIES.md) for the full list of `config_options`
keys and what each one does.
---
## Remote PowerShell exec (per-peer, strategy-gated)
Admins can dispatch a PowerShell script to a managed device from the
dashboard's **Run command…** action (Devices page row menu, or directly
via `/admin/pages/devices/:peer_id/exec`). The agent runs the script as
its service account — typically LocalSystem on Windows — and the
output streams back into the dashboard within ~1 s.
This feature is **disabled by default**. To enable it for a peer (or
fleet):
1. Edit (or create) a strategy on the **Strategies** page with the JSON:
```json
{ "enable-remote-exec": "Y" }
```
(mix with whatever other strategy options you already push)
2. Assign that strategy to the peer, its device group, or its owner.
3. The peer's `Auth` column must show **Signed** — exec is refused on
`peer.managed=0` peers. See [AGENT-API-AUTH.md](AGENT-API-AUTH.md).
All three gates (admin role, managed=1, strategy opt-in) are enforced
server-side at dispatch time. The strategy key is never pushed to the
client — it's checked on the server only and serves purely as the
authorization toggle.
Caps (defaults; live in `src/api/heartbeat.rs` and
`src/api/admin/pages/exec.rs`):
- Script size: **32 KiB** per dispatch.
- Wall clock: **5 minutes** per command; the agent kills the process
on timeout and marks the row `timed_out`.
- Output capture: **1 MiB** combined stdout+stderr; further bytes are
drained and discarded, the row gets `truncated=true`.
- One in-flight exec per peer at a time.
See [AGENT-API-AUTH.md](AGENT-API-AUTH.md) for the wire format,
authentication, and threat model. Result POSTs are mandatory-signed —
there's no legacy/unsigned path for the exec result endpoint.
---
## Agent API signing (per-peer)
`POST /api/heartbeat`, `POST /api/sysinfo`, and
`POST /api/unattended-password` are the three agent-facing endpoints
that write per-device state. Stock RustDesk and managed builds
(hello-agent) both call the first two; only managed builds use the
third. Each peer row has a `managed` flag that gates whether the
server requires a per-request Ed25519 signature on these endpoints;
everything else (`/api/peers`, `/api/ab/*`, audit, recordings, OIDC,
etc.) is unaffected. See [AGENT-API-AUTH.md](AGENT-API-AUTH.md) for
the full out-of-scope list.
| `peer.managed` | Heartbeat / sysinfo behaviour |
|----------------|----------------------------------------------------------------------------------------|
| `0` (default) | Unsigned posts accepted (stock-client compatible). Signed posts still verified. |
| `1` | Signature required; unsigned posts return 401. First valid sig auto-promoted to here. |
Default is `0` after the migration, so **stock RustDesk clients are not
affected by the rollout** — they keep posting unsigned, the server keeps
accepting. The first valid signature the server sees from a peer is the
TOFU promote: that peer's `managed` flips to `1` for good, and unsigned
requests claiming that `id` are rejected from then on.
The wire format and verification details live in
[AGENT-API-AUTH.md](AGENT-API-AUTH.md). What you need to know to operate:
### Dashboard
The Devices page has a per-row **Auth** column:
- *Signed* (emerald badge) — `peer.managed = 1`. The peer's heartbeat
and sysinfo posts must carry a valid signature; spoofed unsigned
requests are rejected.
- *Unsigned* (slate badge) — `peer.managed = 0`. Legacy path. Anyone
who knows the id+uuid can post inventory and heartbeats as this
device.
The row's action menu has two new entries (mutually exclusive based on
current state):
- **Require signed API** — flips `managed` to 1 (no confirm — it
strengthens security). Useful for pre-enrolling a peer record
before the agent has booted, or for force-locking a peer if you
want to fail fast when an agent is not signing yet.
- **Allow unsigned API** — flips `managed` to 0 (confirm dialog,
because this reopens the spoofing surface). Use when a managed
agent has been uninstalled and replaced with stock RustDesk on the
same hardware.
### API
`PUT /api/peers/:id/managed` with body `{"managed": true|false}`, gated
on the `is_admin` flag of the calling session, returns
`{"ok":true,"managed":<bool>}`. Same effect as the dashboard toggle —
the dashboard handler just calls this internally after reading the
current value to avoid stale-toggle races.
### Operational notes
- **Mixed fleets are fine.** Stock and hello-agent clients can target
the same hbbs. The gate is per-peer, not per-deployment.
- **Replacing hello-agent with stock RustDesk on a device.** The
device's `peer.managed` is stuck at 1; the stock client doesn't
sign and will start getting 401s. Either re-deploy a signing build
*or* flip the peer back to Unsigned in the dashboard.
- **TLS still recommended.** Signing protects against id+uuid spoof,
not against the unsigned-by-default endpoint surface elsewhere
(`/api/login`, `/api/record`, dashboard) — those still rely on
whatever TLS termination is in front of hbbs. See *TLS deployment*
earlier in this doc.
- **Clock skew tolerance is ±5 minutes.** If a host's clock drifts
past that, heartbeat starts failing 401. Keep NTP healthy on
managed peers; the server's clock is the canonical one.
- **The replay cache lives in-memory only.** A hbbs restart clears
it. The 5-minute timestamp window bounds the worst-case replay
exposure across restarts.
---
## Address books
- **Personal books** are owned per-user and managed from the user's desktop client. The dashboard surfaces them read-only.
- **Shared books** are server-side artifacts. Create from the dashboard's **Address books** page → "Manage shares" → grant per-user `read` / `read+write` / `full` access. Clients pick up shared books on their next AB sync (~30 s).
If you set `--ab-legacy-mode=on`, `/api/ab/personal` 404s and clients fall back to the single-blob `/api/ab` path. Use only if a stock client misbehaves on the modern path.
---
## Admin dashboard URLs
| Path | Auth | What |
|---|---|---|
| `/admin/`, `/admin/index.html` | none (login page redirects in JS) | Single-page shell |
| `/admin/login.html` | none | Sign-in form (password / TOTP / OIDC buttons) |
| `/admin/login` | none (POST form) | Password+TOTP submit → sets `rd_admin_session` cookie |
| `/admin/logout` | cookie | Clears cookie |
| `/admin/me` | cookie | Sidebar's logged-in-as widget |
| `/admin/assets/tailwindcss.js` | none | Vendored Tailwind 3.4.16 Play CDN. Long-cache. |
| `/admin/assets/htmx.min.js` | none | Vendored htmx.org 1.9.10. Long-cache. |
| `/admin/oidc/providers` | none | JSON list of enabled providers, used by login.html |
| `/admin/login/oidc/:name` | none | Starts admin OIDC flow (302s to IdP) |
| `/admin/pages/users` | cookie + admin | Users page fragment (incl. inline edit-profile / password-reset / TOTP-disable per row) |
| `/admin/pages/devices` | cookie + admin | Devices (incl. delete, force-disconnect, force-sysinfo, toggle managed-auth — see [AGENT-API-AUTH.md](AGENT-API-AUTH.md)) |
| `/admin/pages/groups` | cookie + admin | Device groups |
| `/admin/pages/strategies` | cookie + admin | Strategy management |
| `/admin/pages/address-books` | cookie + admin | Personal + shared books |
| `/admin/pages/oidc` | cookie + admin | Read-only OIDC provider listing |
| `/admin/pages/audit` | cookie + admin | Audit log browser |
| `/admin/pages/recordings` | cookie + admin | Recording file listing |
| `/admin/pages/deploy` | cookie + admin | `--config` blob + renamed-installer generator |
| `/admin/pages/profile` | cookie | Self-service profile (display name, email, password, TOTP enroll/remove). Available to all signed-in users — no admin gate. |
| `/admin/pages/profile/update-info` | cookie (POST) | Display name + email update |
| `/admin/pages/profile/change-password` | cookie (POST) | Requires current password; refused for OIDC-linked accounts |
| `/admin/pages/profile/totp/{start,confirm,remove}` | cookie (POST) | Two-step QR enroll, plus password-gated removal |
| `/admin/connect/:peer_id` | cookie + admin | Web-client SPA shell — opens a browser-based remote-control session in a new tab |
| `/admin/connect/assets/bundle.{js,css}` | cookie + admin | Compiled web-client SPA bundle |
The session cookie (`rd_admin_session`) is HttpOnly + SameSite=Strict.
The middleware accepts the same cookie *or* `Authorization: Bearer …`,
so the same auth covers `/api/*` for the desktop client and `/admin/*`
for the dashboard with no separate session model.
---
## Web client
Admins can remote-control any peer directly from the browser — no desktop
RustDesk install required. The Devices page row dropdown surfaces a
**Connect** button that opens `/admin/connect/<peer_id>` in a new tab.
The page is a TypeScript SPA bundled into hbbs (`include_bytes!` from
`web_client/dist/`), so there's no separate process or service to run.
### Routes
| Route | Auth | Purpose |
|---|---|---|
| `/admin/connect/:peer_id` | cookie + admin | Server-rendered HTML wrapper that injects per-request config (rendezvous host, relay host, server pk, peer id, admin display name) into a `<script id="custom-config">` tag, then loads `bundle.js`. |
| `/admin/connect/assets/bundle.js` | cookie + admin | The compiled SPA. ~535 KB, ~75 KB gzipped. |
| `/admin/connect/assets/bundle.css` | cookie + admin | Minimal dark theme + reconnect overlay. |
### How it talks to peers
The browser opens **WebSockets directly** to the existing rendezvous
(`hbbs:21118`, the `ws_port`) and relay (`hbbr:21119`, `port + 2`)
sockets. The wire shape — protobuf `RendezvousMessage` + secretbox-
encrypted `Message` stream — is identical to what the desktop client
uses; the SPA is just a from-scratch reimplementation of the protocol
talking through WS frames instead of TCP.
Browsers can't do UDP NAT-punching, so the SPA always sets `force_relay
= true` in `PunchHoleRequest` and follows the `RelayResponse` branch
unconditionally. **Direct peer-to-peer is never attempted.**
### Browser requirements
- **Secure context** — page must be served over HTTPS or `http://localhost`. WebCodecs `VideoDecoder` / `AudioDecoder` are gated to secure contexts. Plain `http://lan-ip:21114` will fail; either use TLS in front of hbbs or do `ssh -L 21114:localhost:21114 hbbs-host` and connect via `http://localhost:21114`.
- **Browser version** — Chrome 94+, Edge 94+, Firefox 130+, Safari 16.4+. Older browsers lack WebCodecs and will throw "VideoDecoder unavailable" on session start.
- **Clipboard permission** — Firefox refuses `navigator.clipboard.readText()` by default, so admin → host paste is silently no-op there. Host → admin paste works (it uses `writeText`, which only needs a recent click). Chrome/Safari prompt once.
### Network requirements
- The **relay host** advertised to clients (`--relay-servers=<HOSTS>` on hbbs) must resolve and be reachable from the end-user's browser on port 21119. The relay is what carries the actual session bytes — if a user's browser can't open `ws://<relay-host>:21119/`, the session dies after the rendezvous step. A common gotcha: setting `--relay-servers=hbbr-internal.local` works for desktop clients on the LAN but breaks for browsers off-LAN.
- Audit rows are written under the admin's cookie via the existing `/api/audit/conn` endpoint; no new server endpoint.
### TLS deployment with nginx
The dashboard and the two browser-facing WebSocket ports (21118 = rendezvous, 21119 = relay) all need TLS in front of them when accessed from a browser, since the page is served over HTTPS and mixed-content `ws://` is blocked. nginx is the canonical setup; Caddy works similarly with much less ceremony.
#### Port plan
| Public port | TLS terminator | Backed by |
|---|---|---|
| 443/tcp | nginx | `127.0.0.1:21114` (hbbs HTTP API + dashboard) |
| 21118/tcp | nginx | `127.0.0.1:21118` (hbbs WS rendezvous) |
| 21119/tcp | nginx | `127.0.0.1:21119` (hbbr WS relay) |
| 21115/tcp | — | hbbs (NAT test, plain TCP, desktop clients only) |
| 21116/tcp+udp | — | hbbs (main rendezvous, desktop clients only) |
| 21117/tcp | — | hbbr (relay for desktop clients, plain TCP) |
Desktop clients use plain TCP/UDP on 21115 / 21116 / 21117 and bring their own framing + secretbox encryption — no TLS needed. Only browsers go through nginx.
#### Pin hbbs / hbbr to localhost
By default both binaries bind every port to the wildcard (`[::]`), which collides with nginx wanting to take the same public port. Use the bind flags so nginx can claim the public port and forward to localhost:
```sh
# hbbs — desktop-client ports stay on the wildcard, browser ports go local
./hbbs --port 21116 \
--http-port 21114 --http-listen 127.0.0.1 \
--ws-listen 127.0.0.1 \
--relay-servers rd.example.com \
--public-base-url https://rd.example.com \
# ... rest of your flags
# hbbr — TCP relay (21117) stays public, WS relay (21119) goes local
./hbbr --port 21117 --ws-listen 127.0.0.1
```
After restart, `ss -tlnp` should show:
```
LISTEN 127.0.0.1:21114 <-- hbbs HTTP, fronted by nginx 443
LISTEN 127.0.0.1:21118 <-- hbbs WS, fronted by nginx 21118
LISTEN 127.0.0.1:21119 <-- hbbr WS, fronted by nginx 21119
LISTEN 0.0.0.0:21115 <-- hbbs NAT test (public)
LISTEN 0.0.0.0:21116 <-- hbbs rendezvous tcp (public)
LISTEN 0.0.0.0:21117 <-- hbbr relay tcp (public)
# plus 0.0.0.0:21116/udp
```
#### nginx site config
Three `server { }` blocks. The dashboard one is normal HTTP/2 + reverse-proxy; the two WS blocks need the `Upgrade`/`Connection` headers and a long `proxy_read_timeout` so idle web sessions don't get severed mid-screen-share.
```nginx
# /etc/nginx/sites-available/rustdesk
# Helper for WS upgrade — referenced by both WS blocks below.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# 1. Dashboard + admin API on 443
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name rd.example.com;
ssl_certificate /etc/letsencrypt/live/rd.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/rd.example.com/privkey.pem;
# The dashboard streams audit logs / device events via plain HTTP today
# but we still need WS-upgrade pass-through here for the /admin/connect/
# SPA's own asset requests are HTTP, but if you ever proxy ws under
# /ws/* in the future, this stays correct.
location / {
proxy_pass http://127.0.0.1:21114;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
}
}
# Force HTTPS — drop this block if you don't need port 80 at all.
server {
listen 80;
listen [::]:80;
server_name rd.example.com;
return 301 https://$host$request_uri;
}
# 2. WSS rendezvous on 21118
server {
listen 21118 ssl http2;
listen [::]:21118 ssl http2;
server_name rd.example.com;
ssl_certificate /etc/letsencrypt/live/rd.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/rd.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:21118;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Web sessions can sit idle on the rendezvous WS; bump the read
# timeout so nginx doesn't reset the connection before the relay
# leg finishes negotiating.
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
# 3. WSS relay on 21119
server {
listen 21119 ssl http2;
listen [::]:21119 ssl http2;
server_name rd.example.com;
ssl_certificate /etc/letsencrypt/live/rd.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/rd.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:21119;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Relay carries the live session for as long as the user is
# remote-controlling. Pick a value larger than the longest
# session you expect (24h here).
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}
```
The Let's Encrypt cert covers all three ports — same hostname, just different listen ports. With certbot's nginx plugin the cert was already obtained for the 443 block; the other two blocks just point at the same files.
Open the firewall for **80, 443, 21115, 21116, 21117, 21118, 21119** (TCP) and **21116** (UDP). Everything else can stay closed.
Verify after reload: in DevTools → Network, `wss://rd.example.com:21118/` and `wss://rd.example.com:21119/` should each show status `101 Switching Protocols`.
Common failure modes:
- **`ERR_SSL_PROTOCOL_ERROR`** on 21118 or 21119 — nginx isn't terminating TLS on that port. Check the listener block + cert paths.
- **`ERR_CONNECTION_REFUSED`** — firewall is blocking the public port, OR nginx itself isn't listening on it (check `ss -tlnp`).
- **`502 Bad Gateway`** at the dashboard — hbbs isn't running, or `--http-listen` doesn't match what nginx is `proxy_pass`ing to.
- **WS upgrade hangs / 200 instead of 101** — `Upgrade` / `Connection` headers aren't being forwarded. The `$connection_upgrade` map at the top of the config is what makes this work; without it, `proxy_set_header Connection "upgrade"` would also work but breaks plain HTTP requests.
### Features
| Feature | Status |
|---|---|
| Video (VP8 / VP9 / H.264) | ✅ — preference is VP8 (lightest software encoder; H.264 path also implemented for hosts with hwcodec) |
| Audio (Opus) | ✅ |
| Mouse + keyboard input | ✅ — Legacy keyboard mode; Translate mode silently drops Unicode/ControlKey payloads on the host |
| Text clipboard sync (both directions) | ✅ — handles both single-format `Clipboard` and `MultiClipboards` (peers ≥ 1.3.0) |
| Multi-monitor switching | ✅ — `SwitchDisplay` + `CaptureDisplays` two-message dance for hosts ≥ 1.2.4; mouse coords offset by display's virtual-desktop origin |
| Image quality presets (Low/Balanced/Best) | ✅ |
| Custom FPS (15/30/60) | ✅ — host caps at 30 unless `allow_more_fps` is advertised |
| Mute toggle | ✅ — also tells host to stop encoding audio (saves CPU + relay bandwidth) |
| Ctrl+Alt+Del | ✅ — Windows hosts only (server-side `#[cfg(windows)]`) |
| Auto-reconnect on transient drops | ✅ — up to 10 attempts, exponential backoff 1s → 30s, dim overlay during retry, user options re-applied on success |
| File transfer | ❌ deferred — separate `FileAction` family, double the surface area |
| AV1 / H.265 decode | ❌ deferred — VP8/VP9/H.264 covers the common cases |
| IME / compose input | ❌ deferred — needs `compositionend` + `KeyEvent.seq` |
| Touch gestures | ❌ deferred |
| Cursor sprite rendering | ❌ deferred — host-side cursor visible in the video; we don't draw a separate one |
### Codec selection
The SPA advertises VP8/VP9/H.264 decode and prefers VP8. The host's
codec picker (`libs/scrap/src/common/codec.rs`) honours the preference
when the host has the matching encoder available, else falls back to
its "auto" path (H.265 → H.264 → AV1/VP9/VP8).
VP8 is the default because it's the cheapest software encoder; on a
host without hwcodec, VP9 software-encode caps screen sharing at single
digits FPS, while VP8 keeps headroom for the screen-capture pipeline.
On a host *with* hwcodec H.264 (nvenc/qsv on most Windows boxes), flip
the preference in `web_client/src/transport/session.ts` to
`PreferCodec.H264` — the SPA's H.264 path parses SPS to derive the
correct `avc1.PPCCLL` codec string for WebCodecs.
### Performance gotchas
The HUD shows three live numbers: `recv` (frames/sec arriving from the
relay), `dec` (frames/sec the browser decoded), `draw` (frames/sec
painted to canvas). Use them to localise FPS issues:
- All three low → host is encoding slowly. Either CPU-bound (no
hwcodec, VP9 chosen) or QoS-throttled by host based on `TestDelay`
RTT measurements.
- `recv` high, `dec` low → browser fell back to software decode. Check
the codec string at the end of the HUD line; mismatched profile/level
(e.g. `avc1.42E01E` for a high-profile stream) forces software.
- `dec` high, `draw` low → main thread is overwhelmed (very rare with
hardware decode).
### Building the bundle
The bundle is committed under `web_client/dist/` so a Cargo build
doesn't need a Node toolchain. To regenerate after editing the SPA:
```sh
cd web_client
npm install # one-time
npm run build # → dist/bundle.{js,css}
git add dist/
cd .. && cargo build --release -p hbbs
```
To regenerate protobuf bindings after a `libs/hbb_common/protos/*.proto`
change:
```sh
cd web_client && npm run protogen
```
---
## Database
SQLite, file `db_v2.sqlite3` in hbbs's working directory. Tables created
at startup with `CREATE TABLE IF NOT EXISTS`; column additions use
`ALTER TABLE ADD COLUMN` guarded by a duplicate-column-name swallower
(SQLite < 3.35 lacks `ADD COLUMN IF NOT EXISTS`).
Backup is a plain file copy while hbbs is stopped, or `sqlite3
db_v2.sqlite3 .dump > backup.sql` while running. There is no
multi-instance HA; run a single hbbs against a single SQLite file.
---
## Security checklist before exposing to the internet
- TLS in front of `--http-port` and the WebSocket ports (Caddy / nginx / Traefik). Required for OIDC redirect URIs and for the web client (browsers block mixed `ws://`). See "TLS deployment with nginx" for a worked config.
- Pin browser-facing ports to localhost when a reverse proxy is in front: `--http-listen=127.0.0.1` on hbbs, `--ws-listen=127.0.0.1` on both hbbs and hbbr. Keeps the plain-HTTP / plain-ws surface unreachable from the public internet — the proxy is the only path in.
- `--public-base-url` set to the *externally* reachable URL, including the scheme.
- `--bootstrap-admin-password` rotated immediately after first login (Users page → reset password, or via the admin's own "My profile" page).
- `--key` / `id_ed25519` not committed to source control. Treat the private key as a deploy secret.
- Audit retention (`--audit-retention-days`) set to a value that matches your data-retention policy.
- If running behind a reverse proxy: forward the original `Host:` header so OIDC redirect-URI validation matches, and forward `X-Forwarded-Proto: https` so the dashboard generates `wss://` URLs.
+190
View File
@@ -0,0 +1,190 @@
# RustDesk Server — Docker Compose Deployment
The repo ships a `docker-compose.yml` that **builds the server from source**
(this fork's `pro-features` branch) and runs `hbbs` + `hbbr` as two
containers. No prebuilt image is pulled — every `docker compose build`
clones the configured Git URL and runs `cargo build --release` inside the
build stage.
For the runtime flag reference (CLI options accepted by `hbbs` itself), see
[CONFIGURATION.md](CONFIGURATION.md). This document only covers the Compose
glue.
---
## Quick start
```bash
cp .env.example .env
# edit .env — at minimum set RUSTDESK_DOMAIN, and change
# RUSTDESK_BOOTSTRAP_ADMIN_PASSWORD before the first boot
docker compose up -d --build
```
The bootstrap admin (default `admin` / `changeme`) is seeded into the
`users` table on the **first** boot only — once the row exists, those
flags are ignored. If you boot with the default password and forget to
change it, rotate it via the admin UI; if you forget the password
entirely, delete `./data/db_v2.sqlite3` (loses all server-side state) or
edit the `users` row with `sqlite3` directly.
First build pulls the Rust toolchain image and compiles the workspace; expect
several minutes. Subsequent builds reuse the cargo cache layer unless the
Git ref or build args change.
After it boots:
| Endpoint | Port | Purpose |
|------------------------------------------|---------|------------------------------------------|
| `tcp://<domain>:21115` | 21115 | NAT test |
| `tcp+udp://<domain>:21116` | 21116 | ID / rendezvous (desktop clients) |
| `tcp://<domain>:21117` | 21117 | Relay (hbbr) |
| `ws://<domain>:21118` | 21118 | Browser-facing rendezvous WebSocket |
| `ws://<domain>:21119` | 21119 | Browser-facing relay WebSocket |
| `http://<domain>:21114/admin/` | 21114 | Admin dashboard (pro-features) |
| `http://<domain>:21114/api/*` | 21114 | Management API (pro-features) |
Persistent state — including the auto-generated `id_ed25519` keypair and the
SQLite database — lives in `./data/` (bind-mounted to `/root` in both
containers).
---
## Files
| File | Role |
|-----------------------------|---------------------------------------------------------------------------------------|
| `docker-compose.yml` | Two services (`hbbs`, `hbbr`) sharing one image built from `docker/Dockerfile.source`.|
| `docker/Dockerfile.source` | Multi-stage build: clones the repo, runs `cargo build --release`, copies binaries into a `debian:bookworm-slim` runtime. |
| `.env.example` | Documented template; copy to `.env`. |
| `data/` | Created on first run. Contains keypair + SQLite DB. **Back this up.** |
The legacy single-stage `docker/Dockerfile` (busybox + s6-overlay, expects
prebuilt binaries) and `docker-classic/Dockerfile` are unrelated to this
flow and unused by `docker-compose.yml`.
---
## Environment variables
These are read by `docker-compose.yml` from `.env`. Compose ships them
through to the container as command-line flags or `environment:` entries,
not as raw process env (with the exception of `RUST_LOG` and
`ALWAYS_USE_RELAY`, which `hbbs` reads from env directly).
### Runtime
| Variable | Default | Effect |
|--------------------------------|-----------------|-------------------------------------------------------------------------------------------------------|
| `RUSTDESK_DOMAIN` | **required** | Public hostname clients connect to. Passed as `hbbs -r ${RUSTDESK_DOMAIN}:21117`. |
| `RUSTDESK_BOOTSTRAP_ADMIN_USERNAME` | `admin` | Seeded as the initial admin on **first boot only** (when the `users` table is empty). Ignored on subsequent restarts. Empty disables the bootstrap. |
| `RUSTDESK_BOOTSTRAP_ADMIN_PASSWORD` | `changeme` | Same — bcrypt-hashed at insert. Change this in `.env` before the first `up`, or rotate via the admin UI immediately after. |
| `RUSTDESK_KEY` | `-` | Pre-shared key. `-` = auto-generate on first boot (written to `./data/id_ed25519{,.pub}`); `_` = encrypted-only with auto-key; or paste a base64 public key to pin it. Applied to **both** `hbbs` and `hbbr` via `-k`. |
| `RUSTDESK_HTTP_PORT` | `21114` | Pro-features admin API + dashboard port. Set to `0` to disable HTTP entirely. The host port published is the same value. |
| `RUSTDESK_ALWAYS_USE_RELAY` | `N` | Force every session through the relay even on LAN. Read from env by hbbs (any non-empty/non-`N` value enables). |
| `RUST_LOG` | `info` | Log filter. e.g. `debug`, `hbbs=debug,sqlx=warn`. |
### Build source
| Variable | Default | Effect |
|------------------------|---------------------------------------------------------------|-----------------------------------------------------------------------------------------|
| `RUSTDESK_GIT_URL` | `https://gitea.cstudio.ch/mike/rustdesk-server.git` | Repo cloned inside the builder stage. |
| `RUSTDESK_GIT_BRANCH` | `pro-features` | Branch / tag / commit to check out (`--branch` so it must be a ref name, not a SHA). |
| `DATABASE_URL` | unset (uses the cloned repo's `.env`) | Overrides the `DATABASE_URL` sqlx reads at compile time. Rarely needed — see below. |
Build-arg changes only take effect when the image is rebuilt:
`docker compose build --no-cache hbbs` (or `up -d --build`).
---
## Why `DATABASE_URL` is a build-time concern
`hbbs` uses `sqlx::query!` macros, which verify SQL **at compile time** by
running the queries against a real SQLite database. The repo includes a
checked-in `db_v2.sqlite3` with the schema pre-applied, and a tracked `.env`
file pointing at it (`DATABASE_URL=sqlite://./db_v2.sqlite3`).
Cargo automatically reads `.env` from the project root, so `cargo build`
inside the builder stage Just Works without any explicit configuration.
You only need to set `DATABASE_URL` in the Compose `.env` if you fork the
schema or want to point the compile-time check at a different SQLite file.
At **runtime** the binary opens its own DB under `/root/` (your `./data/`
bind mount) — that path is not configurable via this variable.
---
## Adding extra `hbbs` flags
`docker-compose.yml` only wires the most common flags. To pass others
(SMTP, OIDC, recording dir, audit retention, …), edit the `command:`
block of the `hbbs` service. Example — enable SMTP and set the public
base URL needed for OIDC callbacks:
```yaml
command: >
hbbs
-r ${RUSTDESK_DOMAIN:?...}:21117
-k ${RUSTDESK_KEY:--}
--http-port ${RUSTDESK_HTTP_PORT:-21114}
--admin-ui-dir /opt/rustdesk/admin_ui
--bootstrap-admin-username=${RUSTDESK_BOOTSTRAP_ADMIN_USERNAME:-}
--bootstrap-admin-password=${RUSTDESK_BOOTSTRAP_ADMIN_PASSWORD:-}
--public-base-url https://${RUSTDESK_DOMAIN}:${RUSTDESK_HTTP_PORT:-21114}
--smtp-host ${SMTP_HOST}
--smtp-user ${SMTP_USER}
--smtp-pass ${SMTP_PASS}
--smtp-from ${SMTP_FROM}
```
Then add the matching variables to `.env`. The full flag list lives in
[CONFIGURATION.md](CONFIGURATION.md).
If you mount an `oidc.toml`, drop it into `./data/` and pass
`--oidc-config /root/oidc.toml`.
---
## Operational notes
**Upgrading.** Pull the latest commit on `pro-features` and rebuild:
```bash
docker compose build --pull --no-cache
docker compose up -d
```
The `--pull` refreshes the Rust toolchain base image; `--no-cache` forces a
fresh `git clone` (otherwise Docker will reuse the cached clone layer).
Alternatively, bump `RUSTDESK_GIT_BRANCH` to a tag and rebuild — the changed
build arg invalidates the clone layer automatically.
**Logs.** `docker compose logs -f hbbs` (or `hbbr`). Both containers run
the binary in the foreground.
**Persistence.** Everything that matters is in `./data/`:
`id_ed25519{,.pub}` (the server keypair — losing this invalidates every
existing client) and `db_v2.sqlite3` (users, address books, audit, etc.).
Back up the whole directory.
**TLS.** The server itself speaks plain HTTP on 21114 and plain WebSocket
on 21118 / 21119. Front it with nginx or Caddy for TLS — see the "TLS
deployment" section in `CONFIGURATION.md`. When you do, set
`--http-listen=127.0.0.1` and `--ws-listen=127.0.0.1` in the `command:`
block so the reverse proxy can claim the public ports.
**Building behind a proxy.** Pass `HTTP_PROXY` / `HTTPS_PROXY` build args
through Compose:
```yaml
build:
<<: *rustdesk-build
args:
HTTP_PROXY: http://proxy.internal:3128
HTTPS_PROXY: http://proxy.internal:3128
```
**Resource hint.** Cold compile takes ~35 GB of RAM for the linker step
(LTO + `codegen-units = 1`). On a small VPS, build the image on a beefier
machine, push to a registry, and pull from the VPS instead — set the
`image:` field to a registry tag and drop the `build:` block.
+309
View File
@@ -0,0 +1,309 @@
# Strategies — server-pushed client config
Strategies let an admin push client configuration to peers centrally,
without touching each device. They are managed from the dashboard's
**Strategies** page (`/admin/#strategies`).
---
## How it works
1. **Storage.** A strategy is a row with a name and a `config_options`
JSON object (plus an `extra` object, reserved — currently always
`{}`). The Strategies page validates only that `config_options` is a
valid JSON **object**; it does **not** validate the keys inside it.
2. **Delivery.** On each peer heartbeat the server resolves the
applicable strategy and embeds it in the reply as
`strategy.config_options`. Changes propagate within ~15 s of the
strategy row's `modified_at` changing.
3. **Apply.** The client merges every key/value straight into its own
options map (the same store behind "advanced settings" and the
`--config` deploy blob). A strategy key is therefore *any* key the
RustDesk client reads via `Config::get_option`.
### Resolution order (per peer)
The first match wins:
1. Direct peer-scoped assignment (`strategy_assignments.peer_id`)
2. Device-group assignment, via the peer's owner
3. User assignment
If nothing matches, an empty config is pushed.
### Value conventions
- **All values are strings**, even numbers (`"30"`, not `30`).
- **Boolean keys** use `"Y"` (on) / `"N"` (off). Most default to off
when unset.
- **An empty string `""`** removes the override and lets the client
fall back to its built-in default. (If a built-in default settings
blob is present, an empty value instead keeps the key present.)
- Keys the client version doesn't recognize are stored, pushed, and
silently ignored — there is **no error feedback for typos**. Verify
against the `keys` module in `libs/hbb_common/src/config.rs` of the
client version you deploy.
### Example
```json
{
"enable-keyboard": "N",
"enable-file-transfer": "N",
"access-mode": "view",
"image_quality": "best",
"whitelist": "192.168.1.0/24,10.0.0.0/8",
"verification-method": "use-permanent-password"
}
```
---
## Known config keys
The keys below are the complete set defined in the client's `keys`
module (`libs/hbb_common/src/config.rs`). Not all are equally useful in
a server-managed strategy — UI-state keys (peer tabs, toolbar, floating
window) are listed for completeness but are normally per-user.
### Access control & permissions
| Key | Values | Effect |
|---|---|---|
| `access-mode` | `full` / `view` / `custom` | Overall permission preset for incoming sessions |
| `enable-keyboard` | `Y`/`N` | Allow remote keyboard input |
| `enable-clipboard` | `Y`/`N` | Allow clipboard sync |
| `enable-file-transfer` | `Y`/`N` | Allow file transfer |
| `enable-camera` | `Y`/`N` | Allow camera access |
| `enable-terminal` | `Y`/`N` | Allow terminal access |
| `terminal-persistent` | `Y`/`N` | Keep terminal sessions persistent |
| `enable-audio` | `Y`/`N` | Allow audio |
| `enable-tunnel` | `Y`/`N` | Allow TCP tunnelling |
| `enable-remote-restart` | `Y`/`N` | Allow remote restart of the host |
| `enable-record-session` | `Y`/`N` | Allow session recording |
| `enable-block-input` | `Y`/`N` | Allow blocking local input |
| `enable-privacy-mode` | `Y`/`N` | Allow privacy mode |
| `enable-perm-change-in-accept-window` | `Y`/`N` | Allow changing permissions in the accept dialog |
| `allow-remote-config-modification` | `Y`/`N` | Allow remote side to change this host's config |
| `allow-numeric-one-time-password` | `Y`/`N` | Permit numeric one-time passwords |
| `approve-mode` | `password` / `click` / `password-click` | How incoming connections are approved |
| `verification-method` | `use-temporary-password` / `use-permanent-password` / `use-both-passwords` | Password verification mode |
| `temporary-password-length` | `6` / `8` / `10` | Length of generated one-time passwords |
| `allow-only-conn-window-open` | `Y`/`N` | Only accept connections while the connection window is open |
| `allow-auto-disconnect` | `Y`/`N` | Auto-disconnect idle sessions |
| `auto-disconnect-timeout` | minutes | Idle timeout for auto-disconnect |
| `approve-mode` | see above | (alias note: stored as `approve-mode`) |
| `enable-trusted-devices` | `Y`/`N` | Enable the trusted-devices feature |
| `allow-logon-screen-password` | `Y`/`N` | Allow password entry on the logon screen |
| `disable-change-permanent-password` | `Y`/`N` | Prevent the user changing the permanent password |
| `disable-change-id` | `Y`/`N` | Prevent the user changing the device ID |
| `disable-unlock-pin` | `Y`/`N` | Disable the unlock PIN feature |
| `enable-remote-exec` | `Y`/`N` | Allow admins to dispatch PowerShell scripts to this peer via the dashboard's **Run command** action. Server-side only — the value is checked at dispatch time, never pushed to the client. See [AGENT-API-AUTH.md](AGENT-API-AUTH.md) for the auth model. Off by default; only effective on `peer.managed=1` peers. |
### Network & connectivity
| Key | Values | Effect |
|---|---|---|
| `custom-rendezvous-server` | host | ID/rendezvous server address |
| `relay-server` | host | Relay server address |
| `api-server` | URL | API server address |
| `key` | string | Server public key |
| `ice-servers` | string | ICE servers for WebRTC |
| `direct-server` | `Y`/`N` | Enable direct IP access listener |
| `direct-access-port` | port | Port for direct IP access |
| `enable-udp-punch` | `Y`/`N` | Enable UDP hole punching |
| `enable-ipv6-punch` | `Y`/`N` | Enable IPv6 hole punching |
| `disable-udp` | `Y`/`N` | Disable UDP (force TCP) |
| `allow-websocket` | `Y`/`N` | Allow WebSocket transport |
| `allow-insecure-tls-fallback` | `Y`/`N` | Allow falling back to insecure TLS |
| `enable-lan-discovery` | `Y`/`N` | Allow discovery on the local network |
| `whitelist` | IPs/CIDRs | Comma/space/`;`-separated allow-list; empty = allow all |
| `allow-https-21114` | `Y`/`N` | Use HTTPS on port 21114 |
| `use-raw-tcp-for-api` | `Y`/`N` | Use raw TCP for the API connection |
| `allow-hostname-as-id` | `Y`/`N` | Allow using the hostname as device ID |
| `proxy-url` | URL | Proxy server URL |
| `proxy-username` | string | Proxy username |
| `proxy-password` | string | Proxy password |
### Display, codec & quality
| Key | Values | Effect |
|---|---|---|
| `view_style` | `original` / `adaptive` | Remote display scaling |
| `scroll_style` | `scrollauto` / `scrollbar` | Scroll behaviour |
| `image_quality` | `best` / `balanced` / `low` / `custom` | Image quality preset |
| `custom_image_quality` | `10``4095` | Custom quality value |
| `custom-fps` | `5``120` | Custom frame rate |
| `codec-preference` | `auto` / `vp8` / `vp9` / `av1` / `h264` / `h265` | Preferred video codec |
| `enable-hwcodec` | `Y`/`N` | Enable hardware codec |
| `enable-abr` | `Y`/`N` | Enable adaptive bitrate |
| `i444` | `Y`/`N` | Use YUV 4:4:4 (true colour) |
| `av1-test` | `Y`/`N` | Enable AV1 test path |
| `zoom-cursor` | `Y`/`N` | Zoom the remote cursor |
| `show_remote_cursor` | `Y`/`N` | Show the remote cursor |
| `follow_remote_cursor` | `Y`/`N` | Follow the remote cursor |
| `follow_remote_window` | `Y`/`N` | Follow the remote active window |
| `show_quality_monitor` | `Y`/`N` | Show the quality monitor overlay |
| `show_monitors_toolbar` | `Y`/`N` | Show the monitors toolbar |
| `collapse_toolbar` | `Y`/`N` | Start with the toolbar collapsed |
| `view_only` | `Y`/`N` | Open sessions view-only by default |
| `touch-mode` | `Y`/`N` | Enable touch mode |
| `displays_as_individual_windows` | `Y`/`N` | Show each remote display in its own window |
| `use_all_my_displays_for_the_remote_session` | `Y`/`N` | Use all local displays for the session |
| `use-texture-render` | `Y`/`N` | Use texture rendering |
| `allow-d3d-render` | `Y`/`N` | Allow Direct3D rendering |
| `enable-directx-capture` | `Y`/`N` | Use DirectX for screen capture |
| `allow-always-software-render` | `Y`/`N` | Force software rendering |
| `allow-linux-headless` | `Y`/`N` | Allow headless mode on Linux |
### Input
| Key | Values | Effect |
|---|---|---|
| `reverse_mouse_wheel` | `Y`/`N` | Reverse mouse-wheel direction |
| `swap-left-right-mouse` | `Y`/`N` | Swap mouse buttons |
| `trackpad-speed` | `10``1000` | Trackpad speed (percent) |
| `edge-scroll-edge-thickness` | `20``150` | Edge-scroll trigger thickness |
### Recording, files & clipboard
| Key | Values | Effect |
|---|---|---|
| `allow-auto-record-incoming` | `Y`/`N` | Auto-record incoming sessions |
| `allow-auto-record-outgoing` | `Y`/`N` | Auto-record outgoing sessions |
| `video-save-directory` | path | Directory for recordings |
| `enable-file-copy-paste` | `Y`/`N` | Allow file copy/paste |
| `file-transfer-max-files` | number | Max files per transfer request |
| `one-way-file-transfer` | `Y`/`N` | Restrict file transfer to one direction |
| `sync-init-clipboard` | `Y`/`N` | Sync clipboard at session start |
| `disable_clipboard` | `Y`/`N` | Disable clipboard |
| `disable_audio` | `Y`/`N` | Disable audio |
| `one-way-clipboard-redirection` | `Y`/`N` | One-way clipboard redirection |
| `lock_after_session_end` | `Y`/`N` | Lock the host after a session ends |
| `privacy_mode` | `Y`/`N` | Enable privacy mode for the session |
### Printer
| Key | Values | Effect |
|---|---|---|
| `enable-remote-printer` | `Y`/`N` | Enable remote printing |
| `printer-incomming-job-action` | string | Action for incoming print jobs (note: key is spelled `incomming`) |
| `allow-printer-auto-print` | `Y`/`N` | Allow automatic printing |
| `printer-selected-name` | string | Selected printer name |
### Update behaviour
| Key | Values | Effect |
|---|---|---|
| `enable-check-update` | `Y`/`N` | Check for updates |
| `allow-auto-update` | `Y`/`N` | Allow automatic updates |
### Power & session
| Key | Values | Effect |
|---|---|---|
| `keep-screen-on` | string | Keep the screen on |
| `keep-awake-during-incoming-sessions` | `Y`/`N` | Keep host awake during incoming sessions |
| `keep-awake-during-outgoing-sessions` | `Y`/`N` | Keep client awake during outgoing sessions |
| `allow-ask-for-note` | `Y`/`N` | Prompt for a session note |
| `pre-elevate-service` | `Y`/`N` | Pre-elevate the service |
| `register-device` | `Y`/`N` | Register the device with the API server |
### UI visibility / branding lockdown
| Key | Values | Effect |
|---|---|---|
| `hide-security-settings` | `Y`/`N` | Hide the Security settings tab |
| `hide-network-settings` | `Y`/`N` | Hide the Network settings tab |
| `hide-server-settings` | `Y`/`N` | Hide the Server settings tab |
| `hide-proxy-settings` | `Y`/`N` | Hide the Proxy settings tab |
| `hide-remote-printer-settings` | `Y`/`N` | Hide remote-printer settings |
| `hide-websocket-settings` | `Y`/`N` | Hide WebSocket settings |
| `hide-stop-service` | `Y`/`N` | Hide the "stop service" control |
| `hide-tray` | `Y`/`N` | Hide the system-tray icon |
| `hide-powered-by-me` | `Y`/`N` | Hide "powered by" branding |
| `hide-username-on-card` | `Y`/`N` | Hide username on peer cards |
| `hide-help-cards` | `Y`/`N` | Hide help cards |
| `hideAbTagsPanel` | `Y`/`N` | Hide the address-book tags panel |
| `main-window-always-on-top` | `Y`/`N` | Keep the main window on top |
| `theme` | `light` / `dark` / `system` | UI theme |
| `lang` | language code | UI language |
| `remote-menubar-drag-left` | `Y`/`N` | Allow dragging the left remote menubar |
| `remote-menubar-drag-right` | `Y`/`N` | Allow dragging the right remote menubar |
| `enable-confirm-closing-tabs` | `Y`/`N` | Confirm before closing tabs |
| `enable-open-new-connections-in-tabs` | `Y`/`N` | Open new connections in tabs |
| `disable-group-panel` | `Y`/`N` | Hide the group panel |
| `disable-discovery-panel` | `Y`/`N` | Hide the discovery panel |
### Address book
| Key | Values | Effect |
|---|---|---|
| `sync-ab-with-recent-sessions` | `Y`/`N` | Sync address book with recent sessions |
| `sync-ab-tags` | `Y`/`N` | Sync address-book tags |
| `filter-ab-by-intersection` | `Y`/`N` | Filter address book by tag intersection |
### Deep links
| Key | Values | Effect |
|---|---|---|
| `allow-deep-link-password` | `Y`/`N` | Allow passwords in deep links |
| `allow-deep-link-server-settings` | `Y`/`N` | Allow server settings in deep links |
### Preset values (deploy/provisioning)
These pre-fill fields during enrollment/deploy rather than gating behaviour.
| Key | Effect |
|---|---|
| `display-name` | Device display name |
| `avatar` | Device avatar |
| `default-connect-password` | Default connection password |
| `remove-preset-password-warning` | Suppress the preset-password warning |
| `preset-user-name` | Pre-fill username |
| `preset-strategy-name` | Pre-fill strategy name |
| `preset-device-group-name` | Pre-fill device group |
| `preset-device-username` | Pre-fill device username |
| `preset-device-name` | Pre-fill device name |
| `preset-note` | Pre-fill session note |
| `preset-address-book-name` | Pre-fill address-book name |
| `preset-address-book-tag` | Pre-fill address-book tag |
| `preset-address-book-alias` | Pre-fill address-book alias |
| `preset-address-book-password` | Pre-fill address-book password |
| `preset-address-book-note` | Pre-fill address-book note |
### Android / mobile
| Key | Values | Effect |
|---|---|---|
| `show-virtual-mouse` | `Y`/`N` | Show the virtual mouse |
| `show-virtual-joystick` | `Y`/`N` | Show the virtual joystick (also set `show-virtual-mouse`) |
| `disable-floating-window` | `Y`/`N` | Disable the floating window |
| `floating-window-size` | string | Floating-window size |
| `floating-window-untouchable` | `Y`/`N` | Make the floating window non-interactive |
| `floating-window-transparency` | number | Floating-window transparency |
| `floating-window-svg` | string | Floating-window SVG icon |
| `enable-android-software-encoding-half-scale` | `Y`/`N` | Half-scale Android software encoding |
### UI state (normally per-user — listed for completeness)
`remoteMenubarState`, `peer-sorting`, `peer-tab-index`, `peer-tab-order`,
`peer-tab-visible`, `peer-card-ui-type`, `current-ab-name`,
`enable-flutter-http-on-rust`, `allow-remote-cm-modification`.
---
## Notes & caveats
- The list above reflects the client `keys` module at documentation
time. Newer clients may add keys; older clients will ignore keys they
don't know. There is no negotiation — match the doc to the client
version you actually deploy.
- Some keys also exist as hbbs/hbbr command-line flags
(`custom-rendezvous-server`, `relay-server`, `key`, …). Pushing them
via a strategy overrides the client's local value; it does not change
the server.
- The `extra` object on a strategy row is reserved and currently always
`{}` — there is no UI to populate it.
- For the overall configuration picture see
[CONFIGURATION.md](CONFIGURATION.md#strategies-server-pushed-config).
+126
View File
@@ -0,0 +1,126 @@
//! End-to-end smoke test for the HttpProxyRequest fallback.
//!
//! Mirrors what a logged-in client does when `OPTION_USE_RAW_TCP_FOR_API=Y`:
//! 1. Open TCP to hbbs's rendezvous port.
//! 2. Read the server-initiated `KeyExchange`.
//! 3. Verify the signature with the server's published Ed25519 pubkey.
//! 4. Reply with `KeyExchange { keys: [client_box_pk, sealed_sym_key] }`.
//! 5. Send `HttpProxyRequest { method, path, headers, body }`.
//! 6. Receive `HttpProxyResponse` and print status + body.
//!
//! Run from the same dir as hbbs's `id_ed25519.pub`:
//! cargo run --example http_proxy_test -- 127.0.0.1:21116
use hbb_common::bytes::Bytes;
use hbb_common::protobuf::Message as _;
use hbb_common::rendezvous_proto::{
rendezvous_message, HttpProxyRequest, KeyExchange, RendezvousMessage,
};
use hbb_common::tcp::FramedStream;
use hbb_common::tokio;
use sodiumoxide::crypto::{box_, secretbox, sign};
#[tokio::main(flavor = "current_thread")]
async fn main() {
let addr_arg = std::env::args().nth(1).unwrap_or_else(|| "127.0.0.1:21116".into());
let pubkey_path = std::env::args()
.nth(2)
.unwrap_or_else(|| "id_ed25519.pub".into());
// 1. Connect.
let addr: std::net::SocketAddr = addr_arg.parse().expect("bad addr");
let raw = tokio::net::TcpStream::connect(addr).await.expect("connect");
let mut fs = FramedStream::from(raw, addr);
// 2. Read the server-pushed KeyExchange.
let bytes = fs
.next()
.await
.expect("server closed")
.expect("read err");
let msg = RendezvousMessage::parse_from_bytes(&bytes).expect("parse first frame");
let kx_in = match msg.union {
Some(rendezvous_message::Union::KeyExchange(ex)) => ex,
other => panic!(
"expected KeyExchange as first frame, got {:?}",
other.map(|_| "<some other variant>")
),
};
assert_eq!(
kx_in.keys.len(),
1,
"server KX must carry exactly one signed pubkey"
);
// 3. Verify the signature.
let pk_b64 = std::fs::read_to_string(&pubkey_path)
.expect("read pubkey")
.trim()
.to_string();
let pk_bytes = base64::decode(&pk_b64).expect("base64 pubkey");
assert_eq!(pk_bytes.len(), 32, "Ed25519 pubkey must be 32 bytes");
let rs_pk = sign::PublicKey::from_slice(&pk_bytes).expect("pubkey");
let their_box_pk_bytes =
sign::verify(&kx_in.keys[0], &rs_pk).expect("KX signature mismatch");
assert_eq!(their_box_pk_bytes.len(), 32, "box pk must be 32 bytes");
let their_box_pk =
box_::PublicKey::from_slice(&their_box_pk_bytes).expect("box pk shape");
// 4. Generate ephemeral keypair + sym key, seal the sym key with NaCl box,
// send back KX.
let (our_box_pk, our_box_sk) = box_::gen_keypair();
let sym_key = secretbox::gen_key();
let nonce = box_::Nonce([0u8; 24]);
let sealed = box_::seal(&sym_key.0, &nonce, &their_box_pk, &our_box_sk);
let mut out = RendezvousMessage::new();
out.set_key_exchange(KeyExchange {
keys: vec![Bytes::from(our_box_pk.0.to_vec()), Bytes::from(sealed)],
..Default::default()
});
fs.send(&out).await.expect("send KX");
fs.set_key(sym_key);
println!("[ok] secure_tcp handshake complete");
// 5. HttpProxyRequest — exercise an unauthenticated route first.
let mut req_msg = RendezvousMessage::new();
req_msg.set_http_proxy_request(HttpProxyRequest {
method: "GET".into(),
path: "/api/login-options".into(),
headers: vec![],
body: Bytes::new(),
..Default::default()
});
fs.send(&req_msg).await.expect("send HttpProxyRequest");
println!("[ok] sent HttpProxyRequest GET /api/login-options");
// 6. Receive HttpProxyResponse.
let bytes = fs
.next()
.await
.expect("server closed mid-response")
.expect("read err");
let resp_msg =
RendezvousMessage::parse_from_bytes(&bytes).expect("parse response");
match resp_msg.union {
Some(rendezvous_message::Union::HttpProxyResponse(r)) => {
println!("[ok] response status = {}", r.status);
println!(
"[ok] response body = {}",
std::str::from_utf8(&r.body).unwrap_or("<non-utf8>")
);
for h in &r.headers {
println!(" {}: {}", h.name, h.value);
}
assert_eq!(r.status, 200, "expected HTTP 200 from /api/login-options");
assert!(
std::str::from_utf8(&r.body)
.map(|s| s.contains("account"))
.unwrap_or(false),
"body should mention `account`"
);
println!("[pass] full HTTP-over-rendezvous round trip verified");
}
other => panic!("expected HttpProxyResponse, got {:?}", other.is_some()),
}
}
+161
View File
@@ -0,0 +1,161 @@
//! Legacy single-blob address book — `GET /api/ab` and `POST /api/ab`.
//!
//! Activated when the operator sets `--ab-legacy-mode=on` (which makes
//! `/api/ab/personal` 404 — the documented signal in CONSOLE_API.md §4.2).
//! The wire shape is a JSON-string field `data` whose contents are a second
//! JSON object: `{tags, peers, tag_colors}`. We translate to/from the
//! normalized M2 schema on the personal AB.
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use crate::database::AbPeerRow;
use axum::extract::Extension;
use axum::http::StatusCode;
use axum::Json;
use serde_json::{json, Map, Value};
use std::sync::Arc;
pub async fn get(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
) -> Result<Json<Value>, ApiError> {
let guid = state
.db
.ab_get_or_create_personal(user.user_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
// Pull all peers and all tags. Page size 1000 is fine — legacy clients
// expected a single blob anyway.
let (_total, peers) = state
.db
.ab_list_peers(&guid, 0, 10_000)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let tags = state
.db
.ab_list_tags(&guid)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let mut tag_colors = Map::new();
let tag_names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
for t in &tags {
tag_colors.insert(t.name.clone(), Value::from(t.color));
}
let peer_arr: Vec<Value> = peers
.iter()
.map(|p| {
json!({
"id": p.id,
"alias": p.alias,
"tags": p.tags,
"username": p.username,
"hostname": p.hostname,
"platform": p.platform,
"hash": p.hash,
})
})
.collect();
let inner = json!({
"tags": tag_names,
"peers": peer_arr,
"tag_colors": Value::String(serde_json::to_string(&tag_colors).unwrap_or_default()),
});
Ok(Json(json!({ "data": inner.to_string() })))
}
#[derive(serde::Deserialize)]
pub struct LegacyPostBody {
pub data: String,
}
pub async fn put(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Json(body): Json<LegacyPostBody>,
) -> Result<StatusCode, ApiError> {
let guid = state
.db
.ab_get_or_create_personal(user.user_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let inner: Value = serde_json::from_str(&body.data)
.map_err(|e| ApiError::BadRequest(format!("data is not valid json: {}", e)))?;
// Tag colors are stored as a JSON-encoded string field (Flutter wraps
// the map in another JSON layer). Tolerate either an inline map or the
// doubly-encoded form.
let tag_colors_map: Map<String, Value> = match inner.get("tag_colors") {
Some(Value::String(s)) => serde_json::from_str(s).unwrap_or_default(),
Some(Value::Object(m)) => m.clone(),
_ => Map::new(),
};
let tag_names: Vec<String> = inner
.get("tags")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let tags: Vec<(String, i64)> = tag_names
.iter()
.map(|n| {
let color = tag_colors_map
.get(n)
.and_then(|v| v.as_i64())
.unwrap_or(0);
(n.clone(), color)
})
.collect();
let peer_arr = inner
.get("peers")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut peers: Vec<AbPeerRow> = Vec::with_capacity(peer_arr.len());
for p in peer_arr {
let id = p
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
if id.is_empty() {
continue;
}
let tags = p
.get("tags")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
peers.push(AbPeerRow {
id,
alias: get_str(&p, "alias"),
note: String::new(),
password: String::new(),
hash: get_str(&p, "hash"),
username: get_str(&p, "username"),
hostname: get_str(&p, "hostname"),
platform: get_str(&p, "platform"),
tags,
});
}
state
.db
.ab_legacy_replace(&guid, &tags, &peers)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(StatusCode::OK)
}
fn get_str(v: &Value, k: &str) -> String {
v.get(k)
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_string()
}
+6
View File
@@ -0,0 +1,6 @@
pub mod legacy;
pub mod peers;
pub mod profiles;
pub mod rules;
pub mod settings;
pub mod tags;
+198
View File
@@ -0,0 +1,198 @@
use crate::api::ab::rules::{enforce, Rule};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::pagination::Page;
use crate::api::state::AppState;
use crate::database::AbPeerInsert;
use axum::extract::{Extension, Path, Query};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::Json;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc;
/// `serde_urlencoded` (axum's query decoder) does not honour
/// `#[serde(flatten)]`, so the pagination fields are spelled out inline.
#[derive(Debug, Deserialize)]
pub struct AbQuery {
/// guid sent in the query string for `/api/ab/peers?ab=<guid>`.
pub ab: String,
#[serde(default = "default_current")]
pub current: i64,
#[serde(default = "default_page_size", rename = "pageSize")]
pub page_size: i64,
}
fn default_current() -> i64 {
1
}
fn default_page_size() -> i64 {
100
}
impl AbQuery {
fn offset(&self) -> i64 {
(self.current.max(1) - 1) * self.limit()
}
fn limit(&self) -> i64 {
self.page_size.clamp(1, 1000)
}
}
/// `POST /api/ab/peers?ab=<guid>` — paginated peer list inside an AB.
/// Wire shape matches the Flutter `Peer` decoder; only fields documented in
/// CONSOLE_API.md §4.4 are surfaced.
#[derive(Debug, Serialize)]
struct PeerOut {
id: String,
alias: String,
tags: Vec<String>,
note: String,
#[serde(skip_serializing_if = "String::is_empty")]
password: String,
#[serde(skip_serializing_if = "String::is_empty")]
hash: String,
#[serde(skip_serializing_if = "String::is_empty")]
username: String,
#[serde(skip_serializing_if = "String::is_empty")]
hostname: String,
#[serde(skip_serializing_if = "String::is_empty")]
platform: String,
}
pub async fn list(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Query(q): Query<AbQuery>,
) -> Result<impl IntoResponse, ApiError> {
enforce(&state, user.user_id, &q.ab, Rule::Read).await?;
let (total, rows) = state
.db
.ab_list_peers(&q.ab, q.offset(), q.limit())
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let data: Vec<PeerOut> = rows
.into_iter()
.map(|r| PeerOut {
id: r.id,
alias: r.alias,
tags: r.tags,
note: r.note,
password: r.password,
hash: r.hash,
username: r.username,
hostname: r.hostname,
platform: r.platform,
})
.collect();
Ok((StatusCode::OK, Json(Page { total, data })))
}
#[derive(Debug, Deserialize)]
pub struct PeerAddBody {
pub id: String,
#[serde(default)]
pub alias: Option<String>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub password: Option<String>,
#[serde(default)]
pub hash: Option<String>,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub hostname: Option<String>,
#[serde(default)]
pub platform: Option<String>,
}
/// `POST /api/ab/peer/add/{guid}` — insert one peer. **Returns HTTP 200
/// with an empty body on success**, or `{"error":"..."}` JSON body on failure
/// (also HTTP 200). The Flutter `_jsonDecodeActionResp` at
/// flutter/lib/models/ab_model.dart:2002 treats *any* non-empty success body
/// as an error to surface — including `{}` (which produces the literal string
/// "null"), so action endpoints must reply with truly empty bodies.
pub async fn add(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Path(guid): Path<String>,
Json(body): Json<PeerAddBody>,
) -> Result<StatusCode, ApiError> {
enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?;
if body.id.is_empty() {
return Err(ApiError::BadRequest("id required".into()));
}
let max = state.cfg.ab_max_peers_per_book;
let count = state
.db
.ab_count_peers(&guid)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if count >= max {
return Err(ApiError::Forbidden("exceed_max_devices".into()));
}
state
.db
.ab_peer_insert(
&guid,
AbPeerInsert {
id: &body.id,
alias: body.alias.as_deref(),
note: body.note.as_deref(),
password: body.password.as_deref(),
hash: body.hash.as_deref(),
username: body.username.as_deref(),
hostname: body.hostname.as_deref(),
platform: body.platform.as_deref(),
},
body.tags.as_deref(),
)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(StatusCode::OK)
}
/// `PUT /api/ab/peer/update/{guid}` — partial update. Body always carries
/// `id`, plus any subset of mutable fields. Empty success body, see `add`.
pub async fn update(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Path(guid): Path<String>,
Json(body): Json<Value>,
) -> Result<StatusCode, ApiError> {
enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?;
let id = body
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| ApiError::BadRequest("id required".into()))?;
let updated = state
.db
.ab_peer_partial_update(&guid, id, &body)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if !updated {
return Err(ApiError::Forbidden("peer not found".into()));
}
Ok(StatusCode::OK)
}
/// `DELETE /api/ab/peer/{guid}` — body is a JSON array of peer IDs. Empty
/// success body, see `add`.
pub async fn delete(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Path(guid): Path<String>,
Json(ids): Json<Vec<String>>,
) -> Result<StatusCode, ApiError> {
enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?;
state
.db
.ab_peers_delete(&guid, &ids)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(StatusCode::OK)
}
+71
View File
@@ -0,0 +1,71 @@
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::pagination::{Page, PageQuery};
use crate::api::state::AppState;
use axum::extract::{Extension, Query};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::Json;
use serde::Serialize;
use serde_json::{json, Value};
use std::sync::Arc;
/// `POST /api/ab/personal` — returns the caller's personal AB GUID, creating
/// it if missing. When `--ab-legacy-mode=on` is configured, returns 404 to
/// signal "this server speaks the legacy single-blob protocol" (the client
/// then falls back to GET/POST /api/ab).
pub async fn personal(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
) -> Result<Json<Value>, ApiError> {
if state.cfg.ab_legacy_mode {
return Err(ApiError::NotFound);
}
let guid = state
.db
.ab_get_or_create_personal(user.user_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(json!({ "guid": guid })))
}
/// `POST /api/ab/shared/profiles` — paginated list of shared address books
/// the caller can see. Wire shape matches the Flutter `AbProfile` decoder at
/// flutter/lib/common/hbbs/hbbs.dart:258.
#[derive(Debug, Serialize)]
struct AbProfileOut {
guid: String,
name: String,
owner: String,
note: String,
rule: i64,
#[serde(skip_serializing_if = "Option::is_none")]
info: Option<Value>,
}
pub async fn shared_profiles(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Query(page): Query<PageQuery>,
) -> Result<impl IntoResponse, ApiError> {
let (total, rows) = state
.db
.ab_list_shared_for_user(user.user_id, page.offset(), page.limit())
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let data = rows
.into_iter()
.map(|r| AbProfileOut {
guid: r.guid,
name: r.name,
owner: r.owner,
note: r.note,
rule: r.rule,
info: r
.info_json
.as_deref()
.and_then(|s| serde_json::from_str(s).ok()),
})
.collect();
Ok((StatusCode::OK, Json(Page { total, data })))
}
+49
View File
@@ -0,0 +1,49 @@
use crate::api::error::ApiError;
use crate::api::state::AppState;
/// Share-rule levels for a shared address book. Wire integers match the
/// Flutter client's `ShareRule` enum at flutter/lib/common/hbbs/hbbs.dart:210.
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum Rule {
Read = 1,
ReadWrite = 2,
Full = 3,
}
impl Rule {
pub fn from_i64(v: i64) -> Option<Self> {
match v {
1 => Some(Rule::Read),
2 => Some(Rule::ReadWrite),
3 => Some(Rule::Full),
_ => None,
}
}
}
/// Enforce that `caller` has at least `needed` access on `ab_guid`. Used at
/// the top of every AB handler. Resolution lives in
/// `Database::ab_resolve_rule` and considers (a) AB ownership and (b) the
/// largest matching rule across direct and device-group shares.
pub async fn enforce(
state: &AppState,
caller_user_id: i64,
ab_guid: &str,
needed: Rule,
) -> Result<(), ApiError> {
let resolved = state
.db
.ab_resolve_rule(caller_user_id, ab_guid)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let Some(have) = resolved.and_then(Rule::from_i64) else {
// Either the AB doesn't exist or the caller has no relationship with
// it. Surface as "not allowed" so we don't leak existence.
return Err(ApiError::Forbidden("not allowed".into()));
};
if have >= needed {
Ok(())
} else {
Err(ApiError::Forbidden("read-only".into()))
}
}
+16
View File
@@ -0,0 +1,16 @@
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use axum::extract::Extension;
use axum::Json;
use serde_json::{json, Value};
use std::sync::Arc;
/// `POST /api/ab/settings` — capability/limit probe. The Flutter client
/// (ab_model.dart:230-258) calls this once per pull cycle to learn
/// `max_peer_one_ab`. Auth is required even though there is no body.
pub async fn settings(
Extension(state): Extension<Arc<AppState>>,
_user: AuthedUser,
) -> Json<Value> {
Json(json!({ "max_peer_one_ab": state.cfg.ab_max_peers_per_book }))
}
+122
View File
@@ -0,0 +1,122 @@
use crate::api::ab::rules::{enforce, Rule};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use axum::extract::{Extension, Path};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::Json;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
/// `POST /api/ab/tags/{guid}` — list tags. Wire shape is a bare JSON array
/// `[{name, color}]`, NOT the `Page<T>` envelope.
#[derive(Debug, Serialize)]
struct TagOut {
name: String,
color: i64,
}
pub async fn list(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Path(guid): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
enforce(&state, user.user_id, &guid, Rule::Read).await?;
let rows = state
.db
.ab_list_tags(&guid)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let out: Vec<TagOut> = rows
.into_iter()
.map(|t| TagOut {
name: t.name,
color: t.color,
})
.collect();
Ok((StatusCode::OK, Json(out)))
}
#[derive(Debug, Deserialize)]
pub struct TagAddBody {
pub name: String,
pub color: i64,
}
pub async fn add(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Path(guid): Path<String>,
Json(body): Json<TagAddBody>,
) -> Result<StatusCode, ApiError> {
enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?;
if body.name.is_empty() {
return Err(ApiError::BadRequest("name required".into()));
}
state
.db
.ab_tag_insert(&guid, &body.name, body.color)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(StatusCode::OK)
}
#[derive(Debug, Deserialize)]
pub struct TagRenameBody {
#[serde(rename = "old")]
pub old_name: String,
#[serde(rename = "new")]
pub new_name: String,
}
pub async fn rename(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Path(guid): Path<String>,
Json(body): Json<TagRenameBody>,
) -> Result<StatusCode, ApiError> {
enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?;
state
.db
.ab_tag_rename(&guid, &body.old_name, &body.new_name)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(StatusCode::OK)
}
#[derive(Debug, Deserialize)]
pub struct TagUpdateBody {
pub name: String,
pub color: i64,
}
pub async fn update(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Path(guid): Path<String>,
Json(body): Json<TagUpdateBody>,
) -> Result<StatusCode, ApiError> {
enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?;
state
.db
.ab_tag_update_color(&guid, &body.name, body.color)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(StatusCode::OK)
}
pub async fn delete(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Path(guid): Path<String>,
Json(names): Json<Vec<String>>,
) -> Result<StatusCode, ApiError> {
enforce(&state, user.user_id, &guid, Rule::ReadWrite).await?;
state
.db
.ab_tags_delete(&guid, &names)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(StatusCode::OK)
}
+177
View File
@@ -0,0 +1,177 @@
//! `/admin/login` (POST form) and `/admin/logout` (POST). On success login
//! sets an HttpOnly + SameSite=Strict cookie containing the freshly-minted
//! Bearer token; the browser carries it on every subsequent request to
//! `/admin/*` and `/api/*`. The middleware in `api::middleware` already
//! accepts both `Authorization: Bearer …` and the cookie.
use crate::api::admin::i18n::{t, Lang};
use crate::api::auth::mint_token;
use crate::api::middleware::{sha256_token, SESSION_COOKIE};
use crate::api::state::AppState;
use crate::api::users::verify_password;
use axum::extract::{Extension, Form};
use axum::http::header::{COOKIE, SET_COOKIE};
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use serde::Deserialize;
use std::sync::Arc;
#[derive(Debug, Deserialize)]
pub struct LoginForm {
pub username: String,
pub password: String,
/// 6-digit TOTP code, present on the second leg when the first leg
/// returned `tfa_check`. The HTML input is `name="tfaCode"` (camelCase)
/// to match the rest of the dashboard's form conventions, so we rename
/// the wire field rather than renaming the input.
#[serde(default, rename = "tfaCode")]
pub tfa_code: String,
/// Echo of the TOTP nonce the first-leg response set on the form.
#[serde(default)]
pub secret: String,
}
pub async fn login(
Extension(state): Extension<Arc<AppState>>,
lang: Lang,
Form(form): Form<LoginForm>,
) -> Response {
// First leg: password verify. Same DB call paths as `/api/login` —
// we re-use the existing helpers so the dashboard can't accidentally
// diverge from the API's auth contract.
let user = match state.db.user_find_by_username(&form.username).await {
Ok(Some(u)) => u,
Ok(None) => return error_fragment(t(lang, "login.bad_credentials")),
Err(e) => return error_fragment(&format!("internal: {}", e)),
};
let pw_ok = match verify_password(user.password_hash.clone(), form.password.clone()).await {
Ok(b) => b,
Err(e) => return error_fragment(&format!("internal: {}", e)),
};
if !pw_ok {
return error_fragment(t(lang, "login.bad_credentials"));
}
if user.status == 0 {
return error_fragment(t(lang, "login.account_disabled"));
}
if !user.is_admin {
// Only admins can use the dashboard. Non-admin users still get
// tokens via `/api/login` for the desktop client; they just don't
// see the management surface.
return error_fragment(t(lang, "login.admin_required"));
}
// Optional second leg: TOTP. If the user has a secret enrolled and the
// form didn't carry a code, return a fragment that asks for one.
let totp_secret = state
.db
.totp_get_secret(user.id)
.await
.ok()
.flatten();
if let Some(secret_b32) = totp_secret {
if form.tfa_code.is_empty() {
// Shape used by the JS in login.html to switch to the second
// leg: it watches for the special marker via HX-Trigger and
// reveals the #tfa-section.
let frag = format!(
r#"<span data-tfa-required="1" class="text-amber-300">{msg}</span>
<script>
document.getElementById('tfa-section').classList.remove('hidden');
document.getElementById('tfaCode').focus();
</script>"#,
msg = t(lang, "login.totp_required"),
);
// We don't need a session yet — caller will resubmit with the
// same username/password plus the code. (No nonce involved on
// the dashboard path: the password is already in scope, so
// tfa_check / tfa_code are folded into one form.)
let _ = secret_b32;
return Html(frag).into_response();
}
// Verify the supplied code.
let ok = match crate::api::auth::verify_totp(&secret_b32, &form.tfa_code) {
Ok(b) => b,
Err(_) => return error_fragment(t(lang, "login.internal_totp")),
};
if !ok {
return error_fragment(t(lang, "login.bad_totp"));
}
}
// Mint + persist a token, set the cookie.
let token = mint_token();
let sha = sha256_token(&token);
if let Err(e) = state
.db
.token_insert(
user.id,
&sha,
"",
"",
r#"{"source":"admin-ui"}"#,
state.cfg.session_ttl_secs,
)
.await
{
return error_fragment(&format!("internal: {}", e));
}
let cookie = format!(
"{name}={token}; HttpOnly; Path=/; SameSite=Strict; Max-Age={ttl}",
name = SESSION_COOKIE,
token = token,
ttl = state.cfg.session_ttl_secs,
);
let mut headers = HeaderMap::new();
if let Ok(v) = HeaderValue::from_str(&cookie) {
headers.insert(SET_COOKIE, v);
}
// 200 with empty body; the form's hx-on::after-request redirects on
// success.
(StatusCode::OK, headers, "").into_response()
}
pub async fn logout(
Extension(state): Extension<Arc<AppState>>,
headers: HeaderMap,
) -> Response {
// Best-effort: pull the token out of the cookie, drop the row.
if let Some(tok) = cookie_token(&headers) {
let sha = sha256_token(&tok);
let _ = state.db.token_delete(&sha).await;
}
let mut out = HeaderMap::new();
let clear = format!(
"{name}=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0",
name = SESSION_COOKIE
);
if let Ok(v) = HeaderValue::from_str(&clear) {
out.insert(SET_COOKIE, v);
}
(StatusCode::OK, out, "").into_response()
}
fn cookie_token(headers: &HeaderMap) -> Option<String> {
let s = headers.get(COOKIE)?.to_str().ok()?;
for pair in s.split(';') {
if let Some((name, value)) = pair.trim().split_once('=') {
if name.trim() == SESSION_COOKIE {
let v = value.trim();
if !v.is_empty() {
return Some(v.to_string());
}
}
}
}
None
}
fn error_fragment(msg: &str) -> Response {
let html = format!("<span>{}</span>", html_escape(msg));
(StatusCode::UNAUTHORIZED, Html(html)).into_response()
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
//! `/admin/me` — small HTMX fragment used by the sidebar to show "signed in
//! as <name>". Doubles as a cheap auth-check for the dashboard shell: if
//! the cookie isn't valid, the AuthedUser extractor 401s and the page-level
//! HTMX response handler bounces back to the login form.
use crate::api::admin::i18n::{t, Lang};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use axum::response::Html;
pub async fn me(user: AuthedUser, lang: Lang) -> Result<Html<String>, ApiError> {
Ok(Html(format!(
"{label} <span class=\"text-slate-300\">{name}</span>",
label = t(lang, "nav.signed_in_as"),
name = html_escape(&user.name),
)))
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
+418
View File
@@ -0,0 +1,418 @@
//! Admin dashboard router. Mounted at `/admin/*` by `api::router` when
//! the operator hasn't disabled it via `--admin-ui-dir=` (empty).
//!
//! Static HTML/CSS lives in `admin_ui/` next to the source tree and is
//! embedded into the binary at build time via `include_str!` — no separate
//! deploy artifact, no ServeDir wildcard route conflicting with the
//! literal /admin/login etc. The ASSETS table at the bottom is the
//! authoritative list of files we ship.
//!
//! Layout served at runtime:
//! /admin/ ← index.html (the SPA shell)
//! /admin/login.html ← login form
//! /admin/login POST handler (form-encoded, sets session cookie)
//! /admin/logout POST handler (clears session cookie)
//! /admin/me GET fragment (current user, sidebar widget)
//! /admin/pages/* GET fragments (one per page)
pub mod auth;
pub mod i18n;
pub mod me;
pub mod oidc_login;
pub mod pages;
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::routing::{get, post};
use axum::Router;
use std::sync::Arc;
use crate::api::admin::i18n::{lang_from_headers, t, Lang};
/// Files embedded into the binary. Paths are relative to this source file
/// per `include_str!`. Adding a new HTML asset = one new entry here.
const INDEX_HTML: &str = include_str!("../../../admin_ui/index.html");
const LOGIN_HTML: &str = include_str!("../../../admin_ui/login.html");
/// Third-party JS dependencies vendored under `admin_ui/assets/` so the
/// dashboard doesn't fetch from cdn.tailwindcss.com / unpkg.com at runtime.
/// See docs/CONFIGURATION.md "Web client" for the upgrade procedure.
const TAILWIND_JS: &[u8] = include_bytes!("../../../admin_ui/assets/tailwindcss.js");
const HTMX_JS: &[u8] = include_bytes!("../../../admin_ui/assets/htmx.min.js");
pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
if state.cfg.admin_ui_dir.is_empty() {
// Operator opted out by setting the flag to empty.
return None;
}
let r = Router::new()
// Static HTML pages — explicit routes per file, no wildcard.
.route("/admin", get(serve_index))
.route("/admin/", get(serve_index))
.route("/admin/index.html", get(serve_index))
.route("/admin/login.html", get(serve_login))
// Vendored third-party JS — versions pinned in source, so we can
// cache aggressively (immutable + 1-year max-age).
.route("/admin/assets/tailwindcss.js", get(serve_tailwind))
.route("/admin/assets/htmx.min.js", get(serve_htmx))
// Dynamic dashboard endpoints.
.route("/admin/login", post(auth::login))
.route("/admin/logout", post(auth::logout))
.route("/admin/me", get(me::me))
// OIDC entry points consumed by login.html (unauthenticated — they
// *initiate* a sign-in). The matching /oidc/callback is mounted by
// the public api router and finishes both desktop and admin flows.
.route("/admin/oidc/providers", get(oidc_login::list_providers))
.route("/admin/login/oidc/:provider", get(oidc_login::start_login))
// Page fragments — one per sidebar entry.
.route("/admin/pages/users", get(pages::users::index))
.route(
"/admin/pages/users/table-fragment",
get(pages::users::table_fragment),
)
.route(
"/admin/pages/users/columns",
post(pages::users::set_columns),
)
.route(
"/admin/pages/users/page-size",
post(pages::users::set_page_size),
)
.route("/admin/pages/users/new", get(pages::users::new_form))
.route("/admin/pages/users/create", post(pages::users::create))
.route(
"/admin/pages/users/:id/update-info",
post(pages::users::update_info),
)
.route(
"/admin/pages/users/:id/password-reset",
post(pages::users::reset_password),
)
.route(
"/admin/pages/users/:id/toggle-admin",
post(pages::users::toggle_admin),
)
.route(
"/admin/pages/users/:id/toggle-status",
post(pages::users::toggle_status),
)
.route(
"/admin/pages/users/:id/totp-enroll",
post(pages::users::totp_enroll),
)
.route(
"/admin/pages/users/:id/totp-unenroll",
post(pages::users::totp_unenroll),
)
.route("/admin/pages/users/:id/delete", post(pages::users::delete))
// Devices
.route(
"/admin/pages/devices/list-fragment",
get(pages::devices::list_fragment),
)
.route(
"/admin/pages/devices/columns",
post(pages::devices::set_columns),
)
.route(
"/admin/pages/devices/page-size",
post(pages::devices::set_page_size),
)
.route(
"/admin/pages/devices/:peer_id/detail",
get(pages::devices::detail),
)
.route(
"/admin/pages/devices/:peer_id/disconnect",
post(pages::devices::force_disconnect),
)
.route(
"/admin/pages/devices/:peer_id/sysinfo-refresh",
post(pages::devices::force_sysinfo),
)
.route(
"/admin/pages/devices/:peer_id/delete",
post(pages::devices::delete),
)
.route(
"/admin/pages/devices/:peer_id/toggle-managed",
post(pages::devices::toggle_managed),
)
.route(
"/admin/pages/devices/:peer_id/add-to-group",
post(pages::devices::add_to_group),
)
.route(
"/admin/pages/devices/:peer_id/add-to-address-book",
post(pages::devices::add_to_address_book),
)
.route(
"/admin/pages/devices/:peer_id/add-to-strategy",
post(pages::devices::add_to_strategy),
)
.route(
"/admin/pages/devices/:peer_id/exec",
get(pages::exec::index).post(pages::exec::dispatch),
)
.route(
"/admin/pages/devices/:peer_id/exec/:cmd_id/poll",
get(pages::exec::poll),
)
// Groups
.route("/admin/pages/groups/create", post(pages::groups::create))
.route("/admin/pages/groups/:id/delete", post(pages::groups::delete))
.route(
"/admin/pages/groups/:id/members/add",
post(pages::groups::add_member),
)
.route(
"/admin/pages/groups/:id/members/:user_id/remove",
post(pages::groups::remove_member),
)
.route(
"/admin/pages/groups/:id/peers/add",
post(pages::groups::add_peer),
)
.route(
"/admin/pages/groups/:id/peers/:peer_id/remove",
post(pages::groups::remove_peer),
)
// Strategies
.route(
"/admin/pages/strategies/create",
post(pages::strategies::create),
)
.route(
"/admin/pages/strategies/:id/update",
post(pages::strategies::update),
)
.route(
"/admin/pages/strategies/:id/delete",
post(pages::strategies::delete),
)
.route(
"/admin/pages/strategies/:id/assignments/group",
post(pages::strategies::assign_group),
)
.route(
"/admin/pages/strategies/:id/assignments/peer",
post(pages::strategies::assign_peer),
)
.route(
"/admin/pages/strategies/:id/assignments/:assignment_id/delete",
post(pages::strategies::unassign),
)
.route("/admin/pages/deploy", get(pages::deploy::index))
.route(
"/admin/pages/deploy/generate",
post(pages::deploy::generate),
)
// Web client (M6) — full-page SPA, NOT an HTMX fragment. Mounted
// outside /admin/pages/ because it's a standalone document the
// operator opens in a new tab from the Devices action menu.
.route(
"/admin/connect/:peer_id",
get(pages::connect::index),
)
.route(
"/admin/connect/assets/bundle.js",
get(pages::connect::bundle_js),
)
.route(
"/admin/connect/assets/bundle.css",
get(pages::connect::bundle_css),
)
.route("/admin/pages/devices", get(pages::devices::index))
.route("/admin/pages/groups", get(pages::groups::index))
.route("/admin/pages/strategies", get(pages::strategies::index))
.route(
"/admin/pages/address-books",
get(pages::address_books::index),
)
.route(
"/admin/pages/address-books/create",
post(pages::address_books::create),
)
.route(
"/admin/pages/address-books/:guid/delete",
post(pages::address_books::delete),
)
.route(
"/admin/pages/address-books/:guid/manage",
get(pages::address_books::manage),
)
.route(
"/admin/pages/address-books/:guid/shares/add",
post(pages::address_books::share_add),
)
.route(
"/admin/pages/address-books/:guid/shares/:user_id/remove",
post(pages::address_books::share_remove),
)
// Self-service profile — cookie-only, no admin gate.
.route("/admin/pages/profile", get(pages::profile::index))
.route(
"/admin/pages/profile/update-info",
post(pages::profile::update_info),
)
.route(
"/admin/pages/profile/change-password",
post(pages::profile::change_password),
)
.route(
"/admin/pages/profile/totp/start",
post(pages::profile::totp_start),
)
.route(
"/admin/pages/profile/totp/confirm",
post(pages::profile::totp_confirm),
)
.route(
"/admin/pages/profile/totp/remove",
post(pages::profile::totp_remove),
)
.route("/admin/pages/audit", get(pages::audit::index));
hbb_common::log::info!(
"admin dashboard mounted at /admin (HTML embedded; --admin-ui-dir is informational)"
);
Some(r)
}
async fn serve_index(headers: HeaderMap) -> Response {
let lang = lang_from_headers(&headers);
html_response_owned(render_index(lang))
}
async fn serve_login(headers: HeaderMap) -> Response {
let lang = lang_from_headers(&headers);
html_response_owned(render_login(lang))
}
/// Apply i18n placeholders to the embedded `index.html` template.
/// `APP_VERSION` is intentionally distinct from the `T_*` translation
/// tokens — it's a server-side constant, not a localizable string.
fn render_index(lang: Lang) -> String {
let body = INDEX_HTML
.replace("{{LANG_CODE}}", lang.code())
.replace("{{T_APP_TITLE}}", t(lang, "shell.app_title"))
.replace("{{T_NAV_USERS}}", t(lang, "nav.users"))
.replace("{{T_NAV_DEVICES}}", t(lang, "nav.devices"))
.replace("{{T_NAV_GROUPS}}", t(lang, "nav.groups"))
.replace("{{T_NAV_STRATEGIES}}", t(lang, "nav.strategies"))
.replace("{{T_NAV_AB}}", t(lang, "nav.address_books"))
.replace("{{T_NAV_AUDIT}}", t(lang, "nav.audit"))
.replace("{{T_NAV_DEPLOY}}", t(lang, "nav.deploy"))
.replace("{{T_NAV_PROFILE}}", t(lang, "nav.profile"))
.replace("{{T_NAV_SIGNOUT}}", t(lang, "nav.signout"))
.replace("{{T_LANGUAGE}}", t(lang, "common.language"))
.replace("{{T_LOADING}}", t(lang, "common.loading"))
.replace("{{APP_VERSION}}", crate::version::VERSION);
apply_lang_selected(body, lang)
}
/// Apply i18n placeholders to the embedded `login.html` template.
fn render_login(lang: Lang) -> String {
let body = LOGIN_HTML
.replace("{{LANG_CODE}}", lang.code())
.replace("{{T_TITLE}}", t(lang, "login.title"))
.replace("{{T_SUBTITLE}}", t(lang, "login.subtitle"))
.replace("{{T_USERNAME}}", t(lang, "login.username"))
.replace("{{T_PASSWORD}}", t(lang, "login.password"))
.replace("{{T_TOTP_LABEL}}", t(lang, "login.totp_label"))
.replace("{{T_SIGNIN}}", t(lang, "login.signin"))
.replace("{{T_OR}}", t(lang, "login.or"))
.replace("{{T_LANGUAGE}}", t(lang, "common.language"))
.replace(
"{{T_SIGNIN_WITH_JSON}}",
&json_string(t(lang, "login.signin_with")),
)
.replace("{{APP_VERSION}}", crate::version::VERSION);
apply_lang_selected(body, lang)
}
/// Inject `selected` into the matching `<option>` for the active language and
/// blank out the others. Both templates use the same `{{LANG_SEL_XX}}` markers.
fn apply_lang_selected(body: String, lang: Lang) -> String {
let mut sel_en = "";
let mut sel_de = "";
let mut sel_fr = "";
let mut sel_ro = "";
let mut sel_es = "";
match lang {
Lang::En => sel_en = " selected",
Lang::De => sel_de = " selected",
Lang::Fr => sel_fr = " selected",
Lang::Ro => sel_ro = " selected",
Lang::Es => sel_es = " selected",
}
body.replace("{{LANG_SEL_EN}}", sel_en)
.replace("{{LANG_SEL_DE}}", sel_de)
.replace("{{LANG_SEL_FR}}", sel_fr)
.replace("{{LANG_SEL_RO}}", sel_ro)
.replace("{{LANG_SEL_ES}}", sel_es)
}
/// JSON-encode a string so it can be embedded inside a `<script>` block as a
/// JS string literal. We only need to escape `"`, `\`, and the control chars
/// that show up in our translations — none of them realistically contain
/// newlines or `</script>`, but escape them defensively anyway.
fn json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'<' => out.push_str("\\u003c"),
'>' => out.push_str("\\u003e"),
'&' => out.push_str("\\u0026"),
c if (c as u32) < 0x20 => {
use std::fmt::Write as _;
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
out
}
fn html_response_owned(body: String) -> Response {
// Cache-Control: no-cache so the operator sees fresh HTML after a
// server upgrade without having to bump asset URLs.
let mut resp = Html(body).into_response();
resp.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("no-cache"),
);
resp
}
async fn serve_tailwind() -> Response {
js_response(TAILWIND_JS)
}
async fn serve_htmx() -> Response {
js_response(HTMX_JS)
}
fn js_response(body: &'static [u8]) -> Response {
let mut resp = (StatusCode::OK, body).into_response();
let h = resp.headers_mut();
h.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/javascript; charset=utf-8"),
);
// Vendored at a pinned version — safe to cache for a year. If we
// ever bump the version we should also bump the asset path so
// browsers don't keep stale copies; for now the path-pinned version
// is implicit in the binary build.
h.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
);
resp
}
+107
View File
@@ -0,0 +1,107 @@
//! OIDC login entry points for the admin dashboard.
//!
//! Two unauthenticated GET endpoints used by `admin_ui/login.html`:
//!
//! - `GET /admin/oidc/providers` returns the enabled providers as JSON so
//! the login page can render a button per provider.
//! - `GET /admin/login/oidc/:provider` creates an OIDC session marked as
//! admin-flow (via the sentinel below) and 302-redirects the browser to
//! the IdP authorization URL. After the IdP redirects to
//! `/oidc/callback`, the existing callback handler detects the sentinel
//! and finishes by setting `rd_admin_session` + redirecting to `/admin/`
//! (see api/oidc/callback.rs).
//!
//! We keep this module separate from the desktop-client OIDC flow so the
//! "device polls /api/oidc/auth-query" semantics stay untouched.
use crate::api::error::ApiError;
use crate::api::oidc::{discovery, random_token, require_provider, OIDC_SESSION_TTL_SECS};
use crate::api::state::AppState;
use crate::database::OidcSessionInsert;
use axum::extract::{Extension, Path};
use axum::response::Redirect;
use axum::Json;
use serde_json::{json, Value};
use std::sync::Arc;
/// Sentinel stuffed into `client_id_str` / `client_uuid` of an OidcSession
/// so the callback can tell admin-UI flows apart from desktop-client flows.
/// Real device UUIDs from the desktop client are hex-formatted GUIDs and
/// won't collide.
pub const ADMIN_SENTINEL: &str = "__admin_ui__";
pub async fn list_providers(
Extension(state): Extension<Arc<AppState>>,
) -> Json<Value> {
let mut out: Vec<Value> = Vec::new();
if !state.cfg.public_base_url.is_empty() {
if let Ok(providers) = state.db.oidc_provider_list_enabled().await {
for p in providers {
out.push(json!({
"name": p.name,
"display_name": p.display_name.unwrap_or_else(|| p.name.clone()),
"icon_url": p.icon_url,
}));
}
}
}
Json(json!(out))
}
pub async fn start_login(
Extension(state): Extension<Arc<AppState>>,
Path(provider_name): Path<String>,
) -> Result<Redirect, ApiError> {
if state.cfg.public_base_url.is_empty() {
return Err(ApiError::Internal(
"OIDC requires --public-base-url to be set".into(),
));
}
let provider = require_provider(&state, &provider_name).await?;
let disc = discovery::discover(&provider.issuer_url)
.await
.map_err(ApiError::Internal)?;
let code = random_token();
let csrf_state = random_token();
let expires_at = chrono::Utc::now().timestamp() + OIDC_SESSION_TTL_SECS;
state
.db
.oidc_session_create(&OidcSessionInsert {
code: &code,
provider: &provider.name,
state: &csrf_state,
client_id_str: ADMIN_SENTINEL,
client_uuid: ADMIN_SENTINEL,
device_info_json: r#"{"source":"admin-ui"}"#,
expires_at,
})
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let url = format!(
"{auth}?response_type=code&client_id={cid}&redirect_uri={ru}&scope={scope}&state={st}",
auth = disc.authorization_endpoint,
cid = url_encode(&provider.client_id),
ru = url_encode(&provider.redirect_url),
scope = url_encode(&provider.scopes),
st = url_encode(&csrf_state),
);
Ok(Redirect::temporary(&url))
}
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*b as char);
}
_ => {
use std::fmt::Write;
let _ = write!(out, "%{:02X}", b);
}
}
}
out
}
+475
View File
@@ -0,0 +1,475 @@
//! Address books — list, create shared books, manage shares, delete.
//! Personal books are owned by individual users and managed from the
//! desktop client (the dashboard refuses to mutate them). Shared books
//! are server-side artifacts: an admin creates one, then grants users
//! `read` / `read+write` / `full` access; the desktop client picks them
//! up via `/api/ab/shared/profiles` on the next AB sync (~30 s).
use super::shared::{fmt_unix, html_escape, notice_html, require_admin};
use crate::api::admin::i18n::{t, tf1, Lang};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use axum::extract::{Extension, Form, Path};
use axum::response::Html;
use serde::Deserialize;
use std::fmt::Write as _;
use std::sync::Arc;
pub async fn index(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
Ok(Html(render_index(&state, lang, None).await?))
}
#[derive(Debug, Deserialize)]
pub struct CreateForm {
pub name: String,
}
pub async fn create(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Form(f): Form<CreateForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let name = f.name.trim();
if name.is_empty() {
return Ok(Html(
render_index(&state, lang, Some(("error", t(lang, "ab.name_required")))).await?,
));
}
let res = state.db.ab_create_shared(admin.user_id, name).await;
let notice = match res {
Ok(_) => Some(("ok", tf1(lang, "ab.created", name))),
Err(e) => {
// The unique index trips when the same admin creates two books
// with the same name. Surface that cleanly instead of leaking
// the raw SQL error.
let msg = if e.to_string().to_lowercase().contains("unique") {
t(lang, "ab.exists").to_string()
} else {
tf1(lang, "ab.create_failed", &e.to_string())
};
Some(("error", msg))
}
};
let n = notice.as_ref().map(|(k, m)| (*k, m.as_str()));
Ok(Html(render_index(&state, lang, n).await?))
}
pub async fn delete(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(guid): Path<String>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let ok = state
.db
.ab_delete(&guid)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let notice = if ok {
("ok", t(lang, "ab.deleted"))
} else {
("error", t(lang, "ab.not_found"))
};
Ok(Html(render_index(&state, lang, Some(notice)).await?))
}
pub async fn manage(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(guid): Path<String>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
Ok(Html(render_manage(&state, lang, &guid, None).await?))
}
#[derive(Debug, Deserialize)]
pub struct ShareForm {
pub user_id: i64,
pub rule: i64,
}
pub async fn share_add(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(guid): Path<String>,
Form(f): Form<ShareForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
if !(1..=3).contains(&f.rule) {
return Ok(Html(
render_manage(&state, lang, &guid, Some(("error", t(lang, "ab.invalid_rule")))).await?,
));
}
state
.db
.ab_share_set(&guid, f.user_id, f.rule)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(
render_manage(&state, lang, &guid, Some(("ok", t(lang, "ab.share_saved")))).await?,
))
}
pub async fn share_remove(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path((guid, user_id)): Path<(String, i64)>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let _ = state
.db
.ab_share_remove(&guid, user_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(
render_manage(&state, lang, &guid, Some(("ok", t(lang, "ab.share_removed")))).await?,
))
}
// ---------- rendering ----------
async fn render_index(
state: &Arc<AppState>,
lang: Lang,
notice: Option<(&str, &str)>,
) -> Result<String, ApiError> {
let books = state
.db
.ab_list_all_with_owner()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let notice_html = notice.map(|(k, m)| notice_html(k, m)).unwrap_or_default();
let mut s = String::new();
let _ = write!(
s,
r##"<div id="ab-region" class="space-y-6">
<header>
<h2 class="text-lg font-semibold">{heading}</h2>
<p class="text-xs text-slate-500 mt-1">{tagline}</p>
</header>
{notice_html}
<form
class="flex items-end gap-2 bg-slate-900 border border-slate-800 rounded-lg p-3"
hx-post="/admin/pages/address-books/create"
hx-target="#ab-region" hx-swap="outerHTML"
>
<div class="flex-1">
<label class="block text-xs font-medium text-slate-400 mb-1" for="ab-name">{new_shared}</label>
<input id="ab-name" name="name" type="text" required
placeholder="Engineering laptops"
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500" />
</div>
<button type="submit"
class="bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition">
{create}
</button>
</form>
"##,
heading = t(lang, "ab.heading"),
tagline = t(lang, "ab.tagline"),
new_shared = t(lang, "ab.new_shared"),
create = t(lang, "common.create"),
);
if books.is_empty() {
let _ = write!(
s,
r##"<p class="text-slate-500 text-sm">{}</p></div>"##,
t(lang, "ab.no_books"),
);
return Ok(s);
}
let _ = write!(
s,
r##"<div class="rounded-md border border-slate-800 bg-slate-900">
<table class="w-full text-sm">
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr>
<th class="text-left font-medium px-3 py-2">{c_owner}</th>
<th class="text-left font-medium px-3 py-2">{c_kind}</th>
<th class="text-left font-medium px-3 py-2">{c_name}</th>
<th class="text-left font-medium px-3 py-2">{c_peers}</th>
<th class="text-left font-medium px-3 py-2">{c_guid}</th>
<th class="text-left font-medium px-3 py-2">{c_created}</th>
<th class="text-right font-medium px-3 py-2 w-1">{c_actions}</th>
</tr></thead>
<tbody class="divide-y divide-slate-800">"##,
c_owner = t(lang, "ab.col_owner"),
c_kind = t(lang, "ab.col_kind"),
c_name = t(lang, "ab.col_name"),
c_peers = t(lang, "ab.col_peers"),
c_guid = t(lang, "ab.col_guid"),
c_created = t(lang, "ab.col_created"),
c_actions = t(lang, "common.actions"),
);
for b in &books {
let kind_pill = match b.kind {
0 => format!(
r#"<span class="text-xs px-1.5 py-0.5 rounded bg-slate-800 border border-slate-700 text-slate-300">{}</span>"#,
t(lang, "ab.kind_personal"),
),
1 => format!(
r#"<span class="text-xs px-1.5 py-0.5 rounded bg-violet-900/40 border border-violet-700/50 text-violet-300">{}</span>"#,
t(lang, "ab.kind_shared"),
),
_ => String::new(),
};
// Both kinds get a delete action. Shared books additionally get
// "Manage shares". Personal books carry an extra warning in the
// confirm because the owning user's desktop client may resync
// and recreate the book on next AB tick — deletion is "reset to
// empty", not "permanently revoked".
let actions = if b.kind == 1 {
format!(
r##"<details class="text-right relative">
<summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary>
<div class="absolute right-2 mt-1 z-10 w-48 bg-slate-900 border border-slate-700 rounded shadow-lg p-2 space-y-1 text-left">
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-get="/admin/pages/address-books/{guid}/manage"
hx-target="#ab-region" hx-swap="outerHTML">
{manage}
</button>
<hr class="border-slate-700 my-1" />
<button class="w-full text-left px-2 py-1 text-xs text-rose-300 hover:bg-rose-900/40 rounded"
hx-post="/admin/pages/address-books/{guid}/delete"
hx-target="#ab-region" hx-swap="outerHTML"
hx-confirm="{confirm}">
{delete}
</button>
</div>
</details>"##,
guid = html_escape(&b.guid),
manage = t(lang, "ab.manage_shares"),
confirm = html_escape(&tf1(lang, "ab.confirm_delete_shared", &b.name)),
delete = t(lang, "ab.delete_book"),
)
} else {
format!(
r##"<details class="text-right relative">
<summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary>
<div class="absolute right-2 mt-1 z-10 w-56 bg-slate-900 border border-slate-700 rounded shadow-lg p-2 space-y-1 text-left">
<button class="w-full text-left px-2 py-1 text-xs text-rose-300 hover:bg-rose-900/40 rounded"
hx-post="/admin/pages/address-books/{guid}/delete"
hx-target="#ab-region" hx-swap="outerHTML"
hx-confirm="{confirm}">
{delete}
</button>
</div>
</details>"##,
guid = html_escape(&b.guid),
confirm = html_escape(&tf1(lang, "ab.confirm_delete_personal", &b.owner_username)),
delete = t(lang, "ab.delete_book"),
)
};
let _ = write!(
s,
r##"<tr>
<td class="px-3 py-2 text-slate-200">{owner}</td>
<td class="px-3 py-2">{kind}</td>
<td class="px-3 py-2 text-slate-300">{name}</td>
<td class="px-3 py-2 text-slate-400">{count}</td>
<td class="px-3 py-2 font-mono text-xs text-slate-500">{guid}</td>
<td class="px-3 py-2 text-slate-500 text-xs">{created}</td>
<td class="px-3 py-2">{actions}</td>
</tr>"##,
owner = html_escape(&b.owner_username),
kind = kind_pill,
name = html_escape(&b.name),
count = b.peer_count,
guid = html_escape(&b.guid),
created = html_escape(&fmt_unix(b.created_at)),
actions = actions,
);
}
s.push_str("</tbody></table></div></div>");
Ok(s)
}
async fn render_manage(
state: &Arc<AppState>,
lang: Lang,
guid: &str,
notice: Option<(&str, &str)>,
) -> Result<String, ApiError> {
let owner_kind = state
.db
.ab_get_owner_kind(guid)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let Some((_owner_id, kind)) = owner_kind else {
return Ok(format!(
r##"<div id="ab-region">{notice}</div>"##,
notice = notice_html("error", t(lang, "ab.not_found")),
));
};
if kind != 1 {
return Ok(format!(
r##"<div id="ab-region">{notice}</div>"##,
notice = notice_html("error", t(lang, "ab.personal_managed_at_client")),
));
}
let shares = state
.db
.ab_list_shares(guid)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let (_total, users) = state
.db
.users_list_all(0, 1000)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let already_shared: std::collections::HashSet<i64> =
shares.iter().map(|s| s.user_id).collect();
let notice_html = notice.map(|(k, m)| notice_html(k, m)).unwrap_or_default();
let mut s = String::new();
let _ = write!(
s,
r##"<div id="ab-region" class="space-y-6">
<header class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold">{heading}</h2>
<p class="text-xs text-slate-500 mt-1 font-mono">{guid}</p>
</div>
<button
class="text-xs text-slate-300 hover:text-slate-100 px-2 py-1 rounded border border-slate-700 hover:border-slate-500"
hx-get="/admin/pages/address-books"
hx-target="#ab-region" hx-swap="outerHTML">
{back}
</button>
</header>
{notice_html}
<section class="rounded-md border border-slate-800 bg-slate-900 p-4 space-y-3">
<h3 class="text-sm font-semibold text-slate-200">{add_or_update}</h3>
<form
class="flex flex-wrap items-end gap-2"
hx-post="/admin/pages/address-books/{guid}/shares/add"
hx-target="#ab-region" hx-swap="outerHTML"
>
<div class="flex-1 min-w-[200px]">
<label class="block text-xs font-medium text-slate-400 mb-1" for="share-user">{user_label}</label>
<select id="share-user" name="user_id" required
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500">"##,
heading = t(lang, "ab.manage_heading"),
back = t(lang, "ab.back"),
add_or_update = t(lang, "ab.add_or_update"),
user_label = t(lang, "ab.user_label"),
guid = html_escape(guid),
notice_html = notice_html,
);
if users.is_empty() {
let _ = write!(
s,
r#"<option disabled>{}</option>"#,
t(lang, "ab.no_users"),
);
}
let already_text = t(lang, "ab.existing_will_update");
for u in &users {
let already = if already_shared.contains(&u.id) { already_text } else { "" };
let _ = write!(
s,
r#"<option value="{id}">{name}{already}</option>"#,
id = u.id,
name = html_escape(&u.username),
already = html_escape(already),
);
}
let _ = write!(
s,
r##"</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1" for="share-rule">{rule}</label>
<select id="share-rule" name="rule"
class="bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500">
<option value="1">{r_read}</option>
<option value="2" selected>{r_rw}</option>
<option value="3">{r_full}</option>
</select>
</div>
<button type="submit"
class="bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition">
{save}
</button>
</form>
</section>
<section class="rounded-md border border-slate-800 bg-slate-900">
<header class="px-4 py-2 text-xs uppercase text-slate-500 border-b border-slate-800">
{current_shares}
</header>
"##,
rule = t(lang, "ab.rule"),
r_read = t(lang, "ab.rule_read"),
r_rw = t(lang, "ab.rule_read_write"),
r_full = t(lang, "ab.rule_full"),
save = t(lang, "common.save"),
current_shares = tf1(lang, "ab.current_shares", &shares.len().to_string()),
);
if shares.is_empty() {
let _ = write!(
s,
r##"<p class="px-4 py-3 text-slate-500 text-sm">{}</p></section></div>"##,
t(lang, "ab.no_shares"),
);
return Ok(s);
}
let _ = write!(
s,
r##"<table class="w-full text-sm">
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr>
<th class="text-left font-medium px-3 py-2">{user_l}</th>
<th class="text-left font-medium px-3 py-2">{rule_l}</th>
<th class="text-right font-medium px-3 py-2 w-1"></th>
</tr></thead>
<tbody class="divide-y divide-slate-800">"##,
user_l = t(lang, "ab.user_label"),
rule_l = t(lang, "ab.rule"),
);
for sh in &shares {
let rule = match sh.rule {
1 => t(lang, "ab.rule_read"),
2 => t(lang, "ab.rule_read_write"),
3 => t(lang, "ab.rule_full"),
_ => "?",
};
let _ = write!(
s,
r##"<tr>
<td class="px-3 py-2 text-slate-200">{user}</td>
<td class="px-3 py-2 text-slate-400">{rule}</td>
<td class="px-3 py-2 text-right">
<button class="text-xs text-rose-300 hover:text-rose-200 px-2 py-1 rounded hover:bg-rose-900/40"
hx-post="/admin/pages/address-books/{guid}/shares/{uid}/remove"
hx-target="#ab-region" hx-swap="outerHTML"
hx-confirm="{confirm}">
{remove}
</button>
</td>
</tr>"##,
user = html_escape(&sh.username),
rule = rule,
guid = html_escape(guid),
uid = sh.user_id,
confirm = html_escape(&tf1(lang, "ab.confirm_remove", &sh.username)),
remove = t(lang, "common.remove"),
);
}
s.push_str("</tbody></table></section></div>");
Ok(s)
}
+234
View File
@@ -0,0 +1,234 @@
//! Audit log browser — three tabs (conn / file / alarm), each capped at the
//! latest 200 rows. M5c MVP. Pagination/filtering by date range can come in
//! a follow-up if the operator outgrows this view.
use super::shared::{fmt_unix, html_escape, require_admin};
use crate::api::admin::i18n::{t, tf1, Lang};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use axum::extract::{Extension, Query};
use axum::response::Html;
use serde::Deserialize;
use std::fmt::Write as _;
use std::sync::Arc;
const PAGE_SIZE: i64 = 200;
#[derive(Debug, Deserialize)]
pub struct TabQuery {
#[serde(default)]
pub tab: Option<String>,
}
pub async fn index(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Query(q): Query<TabQuery>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let tab = q.tab.as_deref().unwrap_or("conn");
let body = match tab {
"file" => render_file(&state, lang).await?,
"alarm" => render_alarm(&state, lang).await?,
_ => render_conn(&state, lang).await?,
};
let pill = |id: &str, label: &str| {
let active = id == tab;
let cls = if active {
"bg-slate-800 text-sky-300 border-sky-800"
} else {
"bg-slate-900 text-slate-400 border-slate-800 hover:text-slate-200"
};
format!(
r##"<a href="#audit" hx-get="/admin/pages/audit?tab={id}" hx-target="#main" class="px-3 py-1 rounded border {cls}">{label}</a>"##,
id = id,
cls = cls,
label = label,
)
};
Ok(Html(format!(
r##"<div class="space-y-4">
<header class="flex items-center justify-between">
<h2 class="text-lg font-semibold">{heading}</h2>
<p class="text-xs text-slate-500">{latest}</p>
</header>
<div class="flex gap-2 text-xs">{pill_conn}{pill_file}{pill_alarm}</div>
{body}
</div>"##,
heading = t(lang, "audit.heading"),
latest = tf1(lang, "audit.latest", &PAGE_SIZE.to_string()),
pill_conn = pill("conn", t(lang, "audit.tab_conn")),
pill_file = pill("file", t(lang, "audit.tab_file")),
pill_alarm = pill("alarm", t(lang, "audit.tab_alarm")),
body = body,
)))
}
async fn render_conn(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
let rows = state
.db
.audit_conn_list(PAGE_SIZE)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if rows.is_empty() {
return Ok(empty_table(t(lang, "audit.no_conn")));
}
let mut s = String::new();
let _ = write!(
s,
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
<table class="w-full text-sm">
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr>
<th class="text-left font-medium px-3 py-2">{c_when}</th>
<th class="text-left font-medium px-3 py-2">{c_peer}</th>
<th class="text-left font-medium px-3 py-2">{c_conn}</th>
<th class="text-left font-medium px-3 py-2">{c_ip}</th>
<th class="text-left font-medium px-3 py-2">{c_action}</th>
<th class="text-left font-medium px-3 py-2">{c_note}</th>
</tr></thead>
<tbody class="divide-y divide-slate-800">"##,
c_when = t(lang, "audit.col_when"),
c_peer = t(lang, "audit.col_peer"),
c_conn = t(lang, "audit.col_conn_session"),
c_ip = t(lang, "audit.col_ip"),
c_action = t(lang, "audit.col_action"),
c_note = t(lang, "audit.col_note"),
);
for r in &rows {
let _ = write!(
s,
r##"<tr>
<td class="px-3 py-2 text-slate-500 text-xs">{when}</td>
<td class="px-3 py-2 font-mono text-slate-200">{peer}</td>
<td class="px-3 py-2 text-slate-400">{conn} / {sess}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{ip}</td>
<td class="px-3 py-2 text-slate-300">{action}</td>
<td class="px-3 py-2 text-slate-400">{note}</td>
</tr>"##,
when = html_escape(&fmt_unix(r.started_at)),
peer = html_escape(&r.peer_id),
conn = r.conn_id,
sess = r.session_id,
ip = html_escape(&r.ip),
action = html_escape(&r.action),
note = html_escape(&r.note)
);
}
s.push_str("</tbody></table></div>");
Ok(s)
}
async fn render_file(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
let rows = state
.db
.audit_file_list(PAGE_SIZE)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if rows.is_empty() {
return Ok(empty_table(t(lang, "audit.no_file")));
}
let mut s = String::new();
let _ = write!(
s,
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
<table class="w-full text-sm">
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr>
<th class="text-left font-medium px-3 py-2">{c_when}</th>
<th class="text-left font-medium px-3 py-2">{c_peer}</th>
<th class="text-left font-medium px-3 py-2">{c_dir}</th>
<th class="text-left font-medium px-3 py-2">{c_path}</th>
<th class="text-left font-medium px-3 py-2">{c_remote}</th>
</tr></thead>
<tbody class="divide-y divide-slate-800">"##,
c_when = t(lang, "audit.col_when"),
c_peer = t(lang, "audit.col_peer"),
c_dir = t(lang, "audit.col_direction"),
c_path = t(lang, "audit.col_path"),
c_remote = t(lang, "audit.col_remote"),
);
for r in &rows {
let dir = match r.direction {
0 => t(lang, "audit.dir_to_remote"),
1 => t(lang, "audit.dir_from_remote"),
_ => "?",
};
let _ = write!(
s,
r##"<tr>
<td class="px-3 py-2 text-slate-500 text-xs">{when}</td>
<td class="px-3 py-2 font-mono text-slate-200">{peer}</td>
<td class="px-3 py-2 text-slate-400">{dir}</td>
<td class="px-3 py-2 text-slate-300 font-mono text-xs">{path}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs">{remote}</td>
</tr>"##,
when = html_escape(&fmt_unix(r.at)),
peer = html_escape(&r.peer_id),
dir = dir,
path = html_escape(&r.path),
remote = html_escape(&r.remote_peer)
);
}
s.push_str("</tbody></table></div>");
Ok(s)
}
async fn render_alarm(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
let rows = state
.db
.audit_alarm_list(PAGE_SIZE)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if rows.is_empty() {
return Ok(empty_table(t(lang, "audit.no_alarm")));
}
let mut s = String::new();
let _ = write!(
s,
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
<table class="w-full text-sm">
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr>
<th class="text-left font-medium px-3 py-2">{c_when}</th>
<th class="text-left font-medium px-3 py-2">{c_peer}</th>
<th class="text-left font-medium px-3 py-2">{c_type}</th>
<th class="text-left font-medium px-3 py-2">{c_info}</th>
</tr></thead>
<tbody class="divide-y divide-slate-800">"##,
c_when = t(lang, "audit.col_when"),
c_peer = t(lang, "audit.col_peer"),
c_type = t(lang, "audit.col_type"),
c_info = t(lang, "audit.col_info"),
);
for r in &rows {
let typ = match r.typ {
0 => "IpWhitelist",
1 => "ExceedThirtyAttempts",
2 => "SixAttemptsWithinOneMinute",
6 => "ExceedIPv6PrefixAttempts",
n => return Ok(format!("(unknown alarm type {})", n)),
};
let _ = write!(
s,
r##"<tr>
<td class="px-3 py-2 text-slate-500 text-xs">{when}</td>
<td class="px-3 py-2 font-mono text-slate-200">{peer}</td>
<td class="px-3 py-2 text-amber-300">{typ}</td>
<td class="px-3 py-2 text-slate-400 font-mono text-xs break-all">{info}</td>
</tr>"##,
when = html_escape(&fmt_unix(r.at)),
peer = html_escape(&r.peer_id),
typ = typ,
info = html_escape(&r.info_json)
);
}
s.push_str("</tbody></table></div>");
Ok(s)
}
fn empty_table(msg: &str) -> String {
format!(
r##"<div class="rounded-md border border-slate-800 bg-slate-900 p-6 text-center text-sm text-slate-500">{}</div>"##,
html_escape(msg)
)
}
+136
View File
@@ -0,0 +1,136 @@
//! `/admin/connect/:peer_id` — serves the embedded web client SPA.
//!
//! Architecture: the SPA at web_client/src/main.ts opens WebSockets directly
//! to the existing rendezvous (hbbs:21118) and relay (hbbr:21119) endpoints
//! and speaks the same protocol the desktop client speaks. The role of this
//! handler is to (a) gate access via the AuthedUser cookie middleware,
//! (b) inject per-request config (rendezvous host, relay host, server pubkey,
//! peer id, admin name) into the SPA, and (c) serve the bundled JS/CSS via
//! `include_bytes!` so the binary is self-contained.
//!
//! Same `{{CUSTOM_CONFIG}}` template substitution pattern as deploy.rs.
use super::shared::{html_escape, require_admin};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use axum::extract::Path;
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use serde_json::json;
const CONNECT_HTML: &str = include_str!("../../../../admin_ui/connect.html");
const BUNDLE_JS: &[u8] = include_bytes!("../../../../web_client/dist/bundle.js");
const BUNDLE_CSS: &[u8] = include_bytes!("../../../../web_client/dist/bundle.css");
/// `GET /admin/connect/:peer_id` — render the SPA shell with config injected.
pub async fn index(
admin: AuthedUser,
headers: HeaderMap,
Path(peer_id): Path<String>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
// Derive default rendezvous/relay hosts from the request Host header so
// operators don't need to configure separately for the common case where
// hbbs and hbbr live on the same machine the browser is currently talking
// to. Same approach as the deploy page.
let host = headers
.get(header::HOST)
.and_then(|v| v.to_str().ok())
.map(host_only)
.unwrap_or("")
.to_string();
let pubkey = read_pubkey();
let api_server = format!(
"{}://{}",
if is_https(&headers) { "https" } else { "http" },
headers
.get(header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
);
let cfg = json!({
"api_server": api_server,
"rendezvous_server": host,
"relay_server": host,
"key": pubkey,
"peer_id": peer_id,
"admin_name": admin.name.clone(),
});
let cfg_str = cfg.to_string();
// The placeholder is inside <script id="custom-config" type="application/json">
// so the JSON content is parsed verbatim by JSON.parse — no further escaping
// beyond ensuring no literal "</script>" appears (which a JSON serializer
// never produces) and HTML-escaping any peer_id we substitute elsewhere.
let html = CONNECT_HTML.replace("{{CUSTOM_CONFIG}}", &cfg_str);
// Defensive: if a peer_id ever ends up reflected outside the JSON tag
// (the template doesn't currently do this, but future edits might),
// having html_escape called as part of the page-build flow is a habit
// worth preserving.
let _ = html_escape;
Ok(Html(html))
}
/// `GET /admin/connect/assets/bundle.js` — serve the SPA bundle.
pub async fn bundle_js() -> Response {
asset_response(BUNDLE_JS, "application/javascript; charset=utf-8")
}
/// `GET /admin/connect/assets/bundle.css` — serve the SPA stylesheet.
pub async fn bundle_css() -> Response {
asset_response(BUNDLE_CSS, "text/css; charset=utf-8")
}
fn asset_response(body: &'static [u8], content_type: &'static str) -> Response {
let mut resp = (StatusCode::OK, body).into_response();
let headers = resp.headers_mut();
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
// Bundles are content-addressed by SHA in name? Not yet — until we add
// hashed filenames, force fresh fetches so admin upgrades pick up new JS.
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("no-cache"),
);
resp
}
// ---------- helpers ----------
/// Read the server's Ed25519 public key from `id_ed25519.pub` in CWD —
/// same path `common::gen_sk` writes it to and what the deploy page reads.
fn read_pubkey() -> String {
std::fs::read_to_string("id_ed25519.pub")
.ok()
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
/// Strip `:port` (and IPv6 brackets) from a Host-header value. Borrowed
/// from the deploy page; kept inline here rather than promoting to shared
/// to avoid a cross-module dep on a one-liner.
fn host_only(s: &str) -> &str {
if let Some(rest) = s.strip_prefix('[') {
if let Some(end) = rest.find(']') {
return &rest[..end];
}
}
s.rsplit_once(':').map(|(h, _)| h).unwrap_or(s)
}
/// Heuristic: were we reached via HTTPS? The presence of any
/// `X-Forwarded-Proto: https` from a reverse proxy is the standard signal.
/// Falls back to false; the SPA only uses this to construct the displayed
/// API URL, the actual WebSockets pick `ws://` vs `wss://` based on the
/// page's own protocol.
fn is_https(headers: &HeaderMap) -> bool {
headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.map(|s| s.eq_ignore_ascii_case("https"))
.unwrap_or(false)
}
+333
View File
@@ -0,0 +1,333 @@
//! Deploy page — generates a `CustomServer` blob the Windows / macOS / Linux
//! client accepts via `rustdesk --config <blob>`, plus the equivalent
//! rename-the-installer filename. The blob format is documented at
//! `<rustdesk>/src/custom_server.rs`: JSON → URL-safe-no-pad base64 →
//! reverse-the-string. The client tries the unsigned JSON path before
//! signature verification, so we don't need the Pro private key.
use super::shared::{html_escape, require_admin};
use crate::api::admin::i18n::{t, tf1, Lang};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use axum::extract::Form;
use axum::http::HeaderMap;
use axum::response::Html;
use serde::Deserialize;
use serde_json::json;
pub async fn index(
admin: AuthedUser,
lang: Lang,
headers: HeaderMap,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let pubkey = read_pubkey();
// Best-effort prefill: the Host the admin's browser is currently
// talking to is almost always the same machine running hbbs, so it's
// the right default for the rendezvous-host field. Reverse proxies
// forward the original Host through unless explicitly stripped, so
// this works behind nginx/Caddy/Traefik too. Operator can edit if
// hbbr lives on a different host.
let host_default = headers
.get(axum::http::header::HOST)
.and_then(|v| v.to_str().ok())
.map(host_only)
.unwrap_or("")
.to_string();
let (api_default, relay_default) = if host_default.is_empty() {
(String::new(), String::new())
} else {
(format!("https://{}", host_default), host_default.clone())
};
Ok(Html(render_form(
lang,
&pubkey,
&host_default,
&api_default,
&relay_default,
"",
None,
)))
}
/// Strip an optional `:port` (and IPv6 brackets) from a Host-header value.
/// "rustdesk.example.com:21114" -> "rustdesk.example.com"
/// "[::1]:21114" -> "::1"
/// "10.196.83.110" -> "10.196.83.110"
fn host_only(s: &str) -> &str {
if let Some(rest) = s.strip_prefix('[') {
if let Some(end) = rest.find(']') {
return &rest[..end];
}
}
s.rsplit_once(':').map(|(h, _)| h).unwrap_or(s)
}
#[derive(Debug, Deserialize)]
pub struct DeployForm {
#[serde(default)]
pub host: String,
#[serde(default)]
pub api: String,
#[serde(default)]
pub relay: String,
#[serde(default)]
pub key: String,
}
pub async fn generate(
admin: AuthedUser,
lang: Lang,
Form(f): Form<DeployForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
if f.host.trim().is_empty() {
return Ok(Html(render_form(
lang,
&f.key,
&f.host,
&f.api,
&f.relay,
"",
Some(("error", t(lang, "deploy.host_required"))),
)));
}
let blob = encode_blob(&f.host, &f.key, &f.api, &f.relay);
let result = render_result(lang, &f.host, &f.key, &f.api, &f.relay, &blob);
Ok(Html(render_form(
lang,
&f.key,
&f.host,
&f.api,
&f.relay,
&result,
None,
)))
}
// ---------- helpers ----------
/// Best-effort read of the server's public key from `id_ed25519.pub` in CWD —
/// the same path `common::gen_sk` writes it to. If the file is missing
/// (operator passed `--key` explicitly, or the binary runs from a directory
/// they can't read), the field is left blank for them to paste.
fn read_pubkey() -> String {
std::fs::read_to_string("id_ed25519.pub")
.ok()
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
/// Encode a `CustomServer` payload the way the client's
/// `get_custom_server_from_config_string` expects: JSON → URL-safe-no-pad
/// base64 → reverse the resulting string. The client reverses it back, base64
/// decodes, then JSON parses.
fn encode_blob(host: &str, key: &str, api: &str, relay: &str) -> String {
let payload = json!({
"host": host,
"key": key,
"api": api,
"relay": relay,
});
let b64 = base64::encode_config(payload.to_string().as_bytes(), base64::URL_SAFE_NO_PAD);
b64.chars().rev().collect()
}
fn render_form(
lang: Lang,
key: &str,
host: &str,
api: &str,
relay: &str,
result_html: &str,
notice: Option<(&str, &str)>,
) -> String {
let notice_html = match notice {
Some((kind, msg)) => super::shared::notice_html(kind, msg),
None => String::new(),
};
format!(
r##"<div class="space-y-6">
<header>
<h2 class="text-lg font-semibold">{heading}</h2>
<p class="text-xs text-slate-500 mt-1">{intro}</p>
</header>
{notice_html}
<form
class="space-y-3 bg-slate-900 border border-slate-800 rounded-lg p-4"
hx-post="/admin/pages/deploy/generate"
hx-target="#main"
hx-swap="innerHTML"
>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1" for="host">{host_label}</label>
<input id="host" name="host" type="text" required value="{host}"
placeholder="rustdesk.example.com or 203.0.113.10"
oninput="
const h = this.value.trim();
const api = document.getElementById('api');
const relay = document.getElementById('relay');
if (api.dataset.derived !== '0') api.value = h ? 'https://' + h : '';
if (relay.dataset.derived !== '0') relay.value = h;
api.placeholder = h ? 'https://' + h : 'https://rustdesk.example.com';
"
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500" />
<p class="text-xs text-slate-500 mt-1">{host_hint}</p>
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1" for="api">{api_label}</label>
<input id="api" name="api" type="text" value="{api}"
placeholder="https://rustdesk.example.com"
oninput="this.dataset.derived = '0';"
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500" />
<p class="text-xs text-slate-500 mt-1">{api_hint}</p>
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1" for="relay">{relay_label}</label>
<input id="relay" name="relay" type="text" value="{relay}"
placeholder="rustdesk.example.com"
oninput="this.dataset.derived = '0';"
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500" />
<p class="text-xs text-slate-500 mt-1">{relay_hint}</p>
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1" for="key">{key_label}</label>
<textarea id="key" name="key" rows="2"
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-xs font-mono focus:outline-none focus:border-sky-500">{key}</textarea>
<p class="text-xs text-slate-500 mt-1">{key_hint}</p>
</div>
<button type="submit"
class="bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition">
{generate}
</button>
</form>
{result_html}
</div>"##,
heading = t(lang, "deploy.heading"),
intro = t(lang, "deploy.intro"),
host_label = t(lang, "deploy.host_label"),
host_hint = t(lang, "deploy.host_hint"),
api_label = t(lang, "deploy.api_label"),
api_hint = t(lang, "deploy.api_hint"),
relay_label = t(lang, "deploy.relay_label"),
relay_hint = t(lang, "deploy.relay_hint"),
key_label = t(lang, "deploy.key_label"),
key_hint = t(lang, "deploy.key_hint"),
generate = t(lang, "deploy.generate"),
host = html_escape(host),
api = html_escape(api),
relay = html_escape(relay),
key = html_escape(key),
notice_html = notice_html,
result_html = result_html,
)
}
fn render_result(lang: Lang, host: &str, key: &str, api: &str, relay: &str, blob: &str) -> String {
// Build the rename-the-installer alternative. Windows filenames disallow
// `:` and `/`, which the API URL is full of (`http://host:21114`). The
// client falls back to `http://<host>:21114` when `api` is empty
// (rustdesk/src/common.rs:get_api_server_), so we can omit the API field
// from the filename whenever it matches that default. If the operator
// supplied a non-default API URL we still build a "renamed" string for
// reference but mark it as unusable on Windows and steer them to the
// --config path.
let default_api = format!("http://{}:21114", host);
let api_is_default = api.is_empty() || api == default_api;
let unsafe_chars = str_has_filename_unsafe_chars(api) && !api_is_default;
let mut renamed = format!("rustdesk-host={}", host);
if !key.is_empty() {
renamed.push_str(&format!(",key={}", key));
}
if !api_is_default && !unsafe_chars {
renamed.push_str(&format!(",api={}", api));
}
if !relay.is_empty() && !str_has_filename_unsafe_chars(relay) {
renamed.push_str(&format!(",relay={}", relay));
}
renamed.push_str(".exe");
let renamed_note = if unsafe_chars {
r##"<p class="text-xs text-rose-300 mt-1">⚠ Your API URL contains <code>:</code> or <code>/</code>, which Windows forbids in filenames. The renamed-installer approach cannot carry it — use approach A above instead.</p>"##.to_string()
} else if !api_is_default {
format!(
r##"<p class="text-xs text-amber-300 mt-1">⚠ Your API URL ({api_disp}) is not the default <code>http://&lt;host&gt;:21114</code>. The client will auto-derive the default from the rendezvous host on first launch, so this filename will deploy with the wrong API URL. Use approach A instead.</p>"##,
api_disp = html_escape(api)
)
} else if !api.is_empty() {
r##"<p class="text-xs text-slate-500 mt-1">API URL omitted from filename (Windows can't store <code>:</code> / <code>/</code>); the client auto-derives <code>http://&lt;host&gt;:21114</code> from the rendezvous host.</p>"##.to_string()
} else {
String::new()
};
let licensed = format!("rustdesk-licensed-{}", blob);
let cmd_win = format!(
r#""C:\Program Files\RustDesk\rustdesk.exe" --config {licensed}"#,
licensed = licensed
);
let cmd_unix = format!("rustdesk --config {}", licensed);
let cmd_hello = format!("hello-agent.exe --install --config {}", blob);
format!(
r##"<section class="space-y-4 bg-slate-900 border border-slate-800 rounded-lg p-4">
<header>
<h3 class="text-sm font-semibold text-slate-200">{artifact_heading}</h3>
<p class="text-xs text-slate-500 mt-1">{artifact_intro}</p>
</header>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1">{a_label}</label>
<pre class="text-xs bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{cmd_win}</pre>
<p class="text-xs text-slate-500 mt-1">{a_hint}</p>
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1">{b_label}</label>
<pre class="text-xs bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{renamed}</pre>
<p class="text-xs text-slate-500 mt-1">{b_hint}</p>
{renamed_note}
</div>
<div>
<label class="block text-xs font-medium text-slate-400 mb-1">{c_label}</label>
<pre class="text-xs bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{cmd_hello}</pre>
<p class="text-xs text-slate-500 mt-1">{c_hint}</p>
</div>
<details class="text-xs text-slate-400">
<summary class="cursor-pointer text-slate-300 select-none">{raw_blob}</summary>
<pre class="mt-2 bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{blob}</pre>
</details>
</section>"##,
artifact_heading = t(lang, "deploy.artifact_heading"),
artifact_intro = t(lang, "deploy.artifact_intro"),
a_label = t(lang, "deploy.cmd_a_label"),
a_hint = tf1(lang, "deploy.cmd_a_hint", &html_escape(&cmd_unix)),
b_label = t(lang, "deploy.cmd_b_label"),
b_hint = t(lang, "deploy.cmd_b_hint"),
c_label = t(lang, "deploy.cmd_c_label"),
c_hint = t(lang, "deploy.cmd_c_hint"),
raw_blob = t(lang, "deploy.raw_blob"),
cmd_win = html_escape(&cmd_win),
cmd_hello = html_escape(&cmd_hello),
renamed = html_escape(&renamed),
renamed_note = renamed_note,
blob = html_escape(blob),
)
}
/// Rough check for characters Windows disallows in filenames. We don't try
/// to be exhaustive (NUL, control chars etc. won't realistically appear in a
/// hostname or URL), just the ones a typical URL/relay value will trip on.
fn str_has_filename_unsafe_chars(s: &str) -> bool {
s.chars()
.any(|c| matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|'))
}
File diff suppressed because it is too large Load Diff
+482
View File
@@ -0,0 +1,482 @@
//! Per-device PowerShell remote-exec page.
//!
//! Layout:
//! GET /admin/pages/devices/:peer_id/exec — full page
//! POST /admin/pages/devices/:peer_id/exec — dispatch
//! GET /admin/pages/devices/:peer_id/exec/:cmd_id/poll — single-row fragment, auto-refreshes
//!
//! Gates:
//! - AuthedUser.is_admin
//! - peer.managed = 1 (no exec on legacy/unsigned peers)
//! - strategy.config_options."enable-remote-exec" = "Y"
//! - no other in-flight exec for this peer
use crate::api::admin::i18n::{t, tf1, tf2, Lang};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use crate::api::strategy;
use crate::database::ExecRow;
use axum::extract::{Extension, Form, Path};
use axum::response::Html;
use serde::Deserialize;
use std::fmt::Write as _;
use std::sync::Arc;
const HISTORY_LIMIT: i64 = 20;
const MAX_SCRIPT_BYTES: usize = 32 * 1024;
/// Wall-clock cap, mirrored from heartbeat.rs::EXEC_MAX_SECS so the dispatch
/// confirm dialog can surface the same number. Kept duplicated rather than
/// shared as a pub const because the two values legitimately differ in
/// future (per-strategy override is a likely next step).
const UI_MAX_SECS: u64 = 300;
const UI_MAX_BYTES: u64 = 1024 * 1024;
#[derive(Debug, Deserialize)]
pub struct DispatchForm {
pub script: String,
}
/// Main page (full content for `#main`). Renders the gate banner, the
/// script form (only when allowed), and the recent-history table.
pub async fn index(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(peer_id): Path<String>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
Ok(Html(render_page(&state, lang, &peer_id, None).await?))
}
/// Dispatch handler. Re-checks all gates server-side (the UI also gates
/// the form, but the form is just HTML — never trust the client). On
/// success: insert into exec_history with status='queued', return the
/// page with a success notice; the next heartbeat will flip it to
/// 'running' and the history row picks up auto-refresh.
pub async fn dispatch(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(peer_id): Path<String>,
Form(form): Form<DispatchForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let script = form.script.trim_end_matches(['\r', '\n']).to_string();
// Validation runs BEFORE the gate checks so an empty script doesn't
// get a confusing "managed required" error. Order: shape → policy.
if script.trim().is_empty() {
return Ok(Html(
render_page(&state, lang, &peer_id, Some(("error", t(lang, "exec.error_empty").to_string()))).await?,
));
}
if script.len() > MAX_SCRIPT_BYTES {
return Ok(Html(
render_page(
&state,
lang,
&peer_id,
Some((
"error",
tf2(
lang,
"exec.error_too_large",
&script.len().to_string(),
&MAX_SCRIPT_BYTES.to_string(),
),
)),
)
.await?,
));
}
let gate = check_gate(&state, &peer_id).await?;
if !gate.allowed {
return Ok(Html(
render_page(
&state,
lang,
&peer_id,
Some(("error", gate_reason_message(lang, &gate))),
)
.await?,
));
}
if gate.in_flight > 0 {
return Ok(Html(
render_page(
&state,
lang,
&peer_id,
Some(("error", t(lang, "exec.error_in_flight").to_string())),
)
.await?,
));
}
let cmd_id = uuid::Uuid::new_v4().to_string();
state
.db
.exec_create(&cmd_id, &peer_id, admin.user_id, &script)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
hbb_common::log::info!(
"admin {} queued exec cmd_id={} for peer {} ({} bytes)",
admin.name,
cmd_id,
peer_id,
script.len()
);
Ok(Html(
render_page(
&state,
lang,
&peer_id,
Some(("ok", tf1(lang, "exec.queued", &cmd_id))),
)
.await?,
))
}
/// Single-row poll fragment. Returned by an HTMX `hx-get` whose
/// `hx-trigger` keeps firing until the row reaches a terminal state.
pub async fn poll(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path((peer_id, cmd_id)): Path<(String, String)>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let row = state
.db
.exec_get_by_cmd_id(&cmd_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let row = match row {
Some(r) if r.peer_id == peer_id => r,
_ => {
return Ok(Html(format!(
r##"<tr><td colspan="5" class="px-3 py-2 text-rose-300 text-xs">{}</td></tr>"##,
t(lang, "exec.not_found"),
)));
}
};
Ok(Html(render_history_row(lang, &row)))
}
// ───────────────────────── helpers ─────────────────────────
async fn check_gate(state: &Arc<AppState>, peer_id: &str) -> Result<GateCheck, ApiError> {
let auth = state
.db
.peer_get_auth(peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let managed = matches!(auth, Some((_, true)));
let strategy_allows = strategy::allows_remote_exec(state, peer_id).await;
let in_flight = state
.db
.exec_in_flight_count(peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(GateCheck {
allowed: managed && strategy_allows,
managed,
strategy_allows,
in_flight,
})
}
struct GateCheck {
allowed: bool,
managed: bool,
strategy_allows: bool,
in_flight: i64,
}
fn gate_reason_message(lang: Lang, g: &GateCheck) -> String {
if !g.managed {
return t(lang, "exec.reason_not_managed").to_string();
}
if !g.strategy_allows {
return t(lang, "exec.reason_strategy").to_string();
}
t(lang, "exec.reason_unknown").to_string()
}
async fn render_page(
state: &Arc<AppState>,
lang: Lang,
peer_id: &str,
notice: Option<(&'static str, String)>,
) -> Result<String, ApiError> {
let gate = check_gate(state, peer_id).await?;
let history = state
.db
.exec_list_for_peer(peer_id, HISTORY_LIMIT)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let mut out = String::new();
let _ = write!(
&mut out,
r##"<div class="space-y-4">
<header class="flex items-center justify-between">
<h2 class="text-lg font-semibold">{heading} <code class="font-mono text-sky-300">{id}</code></h2>
<button class="text-xs text-sky-300 hover:text-sky-200"
hx-get="/admin/pages/devices/list-fragment"
hx-target="#devices-region" hx-swap="innerHTML"
hx-push-url="#devices">{back}</button>
</header>"##,
heading = t(lang, "exec.heading"),
id = html_escape(peer_id),
back = t(lang, "devices.back"),
);
if let Some((kind, msg)) = notice {
let _ = write!(&mut out, "{}", notice_html(kind, &msg));
}
// Gate banner
let _ = write!(&mut out, "{}", render_gate_banner(lang, &gate));
// Script form — only rendered when gate is open
if gate.allowed {
let _ = write!(
&mut out,
r##"<form class="space-y-2" hx-post="/admin/pages/devices/{id}/exec"
hx-target="#devices-region" hx-swap="innerHTML"
hx-confirm="{confirm}">
<label class="block text-xs text-slate-400">{label}</label>
<textarea name="script" rows="8" required
class="w-full font-mono text-sm rounded border border-slate-700 bg-slate-950 text-slate-200 p-2"
placeholder="Get-Service hello-agent | Select-Object Status, Name"></textarea>
<div class="flex items-center justify-between">
<p class="text-xs text-slate-500">{caps}</p>
<button type="submit" class="rounded bg-sky-700 hover:bg-sky-600 text-sky-100 text-xs px-3 py-1.5">{run}</button>
</div>
</form>"##,
id = html_escape(peer_id),
label = t(lang, "exec.script_label"),
confirm = html_escape(&tf1(lang, "exec.confirm_dispatch", peer_id)),
caps = html_escape(&tf2(
lang,
"exec.caps_note",
&UI_MAX_SECS.to_string(),
&(UI_MAX_BYTES / 1024 / 1024).to_string(),
)),
run = t(lang, "exec.run"),
);
}
// History table
let _ = write!(
&mut out,
r##"<section>
<h3 class="text-sm font-semibold text-slate-300 mb-2">{hist}</h3>
<div class="rounded-md border border-slate-800 bg-slate-900">
<table class="w-full text-sm">
<thead class="text-xs uppercase text-slate-500 bg-slate-950">
<tr>
<th class="text-left font-medium px-3 py-2">{c_when}</th>
<th class="text-left font-medium px-3 py-2">{c_who}</th>
<th class="text-left font-medium px-3 py-2">{c_status}</th>
<th class="text-left font-medium px-3 py-2">{c_script}</th>
<th class="text-left font-medium px-3 py-2">{c_output}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">"##,
hist = t(lang, "exec.history"),
c_when = t(lang, "exec.col_when"),
c_who = t(lang, "exec.col_who"),
c_status = t(lang, "exec.col_status"),
c_script = t(lang, "exec.col_script"),
c_output = t(lang, "exec.col_output"),
);
if history.is_empty() {
let _ = write!(
&mut out,
r##"<tr><td colspan="5" class="px-3 py-3 text-center text-xs text-slate-500">{}</td></tr>"##,
t(lang, "exec.no_history"),
);
} else {
for row in &history {
out.push_str(&render_history_row(lang, row));
}
}
out.push_str(r##" </tbody>
</table>
</div>
</section>
</div>"##);
Ok(out)
}
fn render_gate_banner(lang: Lang, g: &GateCheck) -> String {
let (kind, msg) = if g.allowed {
("ok", t(lang, "exec.gate_open").to_string())
} else if !g.managed {
("error", t(lang, "exec.reason_not_managed").to_string())
} else if !g.strategy_allows {
("error", t(lang, "exec.reason_strategy").to_string())
} else {
("error", t(lang, "exec.reason_unknown").to_string())
};
notice_html(kind, &msg)
}
fn render_history_row(lang: Lang, r: &ExecRow) -> String {
// Auto-refresh while the row is non-terminal so the operator sees
// running → finished without manual reload. HTMX `hx-trigger` with
// `every 1s` fires until the SERVER emits a row missing the trigger
// (i.e. the row reached finished/timed_out/errored).
let row_id = format!("exec-row-{}", html_escape(&r.cmd_id));
let polling_attrs = if matches!(r.status.as_str(), "queued" | "running") {
format!(
r##"hx-get="/admin/pages/devices/{peer}/exec/{cmd}/poll" hx-trigger="load delay:1s" hx-target="this" hx-swap="outerHTML""##,
peer = html_escape(&r.peer_id),
cmd = html_escape(&r.cmd_id),
)
} else {
String::new()
};
let status_cell = render_status_badge(lang, r);
let when = fmt_unix(r.issued_at);
let script_preview = preview(&r.script, 80);
let output_block = render_output_block(lang, r);
format!(
r##"<tr id="{row_id}" {polling}>
<td class="px-3 py-2 text-xs text-slate-500 whitespace-nowrap">{when}</td>
<td class="px-3 py-2 text-xs text-slate-400">#{user}</td>
<td class="px-3 py-2 whitespace-nowrap">{status}</td>
<td class="px-3 py-2"><code class="font-mono text-xs text-slate-300">{script}</code></td>
<td class="px-3 py-2">{output}</td>
</tr>"##,
row_id = row_id,
polling = polling_attrs,
when = html_escape(&when),
user = r.issued_by_user_id,
status = status_cell,
script = html_escape(&script_preview),
output = output_block,
)
}
fn render_status_badge(lang: Lang, r: &ExecRow) -> String {
let (border, bg, text_color, label) = match r.status.as_str() {
"queued" => ("slate-700", "slate-800/40", "slate-300", t(lang, "exec.status_queued")),
"running" => ("amber-700/50", "amber-900/30", "amber-300", t(lang, "exec.status_running")),
"finished" => {
if r.exit_code == Some(0) {
("emerald-700/50", "emerald-900/30", "emerald-300", t(lang, "exec.status_finished_ok"))
} else {
("rose-700/50", "rose-900/30", "rose-300", t(lang, "exec.status_finished_err"))
}
}
"timed_out" => ("rose-700/50", "rose-900/30", "rose-300", t(lang, "exec.status_timed_out")),
_ => ("rose-700/50", "rose-900/30", "rose-300", t(lang, "exec.status_errored")),
};
let exit_suffix = match (r.status.as_str(), r.exit_code) {
("finished", Some(c)) => format!(" (exit {c})"),
_ => String::new(),
};
format!(
r##"<span class="inline-flex items-center gap-1 rounded border border-{b} bg-{bg} px-2 py-0.5 text-xs text-{t}">{label}{exit}</span>"##,
b = border,
bg = bg,
t = text_color,
label = html_escape(label),
exit = exit_suffix,
)
}
fn render_output_block(lang: Lang, r: &ExecRow) -> String {
if matches!(r.status.as_str(), "queued" | "running") {
return format!(
r##"<span class="text-xs text-slate-500">{}</span>"##,
t(lang, "exec.output_pending"),
);
}
let mut s = String::new();
if !r.stdout.is_empty() {
let _ = write!(
&mut s,
r##"<details class="text-xs"><summary class="cursor-pointer text-slate-400 hover:text-slate-200">stdout ({n} bytes)</summary>
<pre class="mt-1 max-h-80 overflow-auto rounded bg-slate-950 border border-slate-800 p-2 font-mono text-slate-300 whitespace-pre-wrap">{out}</pre>
</details>"##,
n = r.stdout.len(),
out = html_escape(&r.stdout),
);
}
if !r.stderr.is_empty() {
let _ = write!(
&mut s,
r##"<details class="text-xs mt-1"><summary class="cursor-pointer text-rose-400 hover:text-rose-300">stderr ({n} bytes)</summary>
<pre class="mt-1 max-h-80 overflow-auto rounded bg-slate-950 border border-slate-800 p-2 font-mono text-rose-300 whitespace-pre-wrap">{out}</pre>
</details>"##,
n = r.stderr.len(),
out = html_escape(&r.stderr),
);
}
if r.truncated {
let _ = write!(
&mut s,
r##"<p class="text-xs text-amber-300 mt-1">{}</p>"##,
t(lang, "exec.output_truncated"),
);
}
if s.is_empty() {
s = format!(r##"<span class="text-xs text-slate-500">{}</span>"##, t(lang, "exec.output_empty"));
}
s
}
fn fmt_unix(ts: i64) -> String {
use chrono::TimeZone;
chrono::Utc
.timestamp_opt(ts, 0)
.single()
.map(|d| d.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| "".to_string())
}
fn preview(s: &str, max: usize) -> String {
let first_line = s.lines().next().unwrap_or("");
if first_line.len() <= max {
first_line.to_string()
} else {
format!("{}", &first_line[..max])
}
}
fn notice_html(kind: &str, msg: &str) -> String {
let (border, bg, text) = match kind {
"ok" => ("emerald-700/50", "emerald-900/30", "emerald-300"),
_ => ("rose-700/50", "rose-900/30", "rose-300"),
};
format!(
r##"<div class="rounded border border-{border} bg-{bg} p-3 text-sm text-{text}">{msg}</div>"##,
border = border,
bg = bg,
text = text,
msg = html_escape(msg),
)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn require_admin(u: &AuthedUser) -> Result<(), ApiError> {
if u.is_admin {
Ok(())
} else {
Err(ApiError::Forbidden("admin required".into()))
}
}
+379
View File
@@ -0,0 +1,379 @@
//! Device-groups page — list, create, delete, add/remove member.
//! Strategies and AB shares hang off device-group membership, so this is
//! the canonical place to manage who can see whose devices.
use super::shared::{html_escape, notice_html, require_admin};
use crate::api::admin::i18n::{t, tf1, Lang};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use axum::extract::{Extension, Form, Path};
use axum::response::Html;
use serde::Deserialize;
use std::fmt::Write as _;
use std::sync::Arc;
pub async fn index(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
Ok(Html(render_full(&state, lang).await?))
}
#[derive(Debug, Deserialize)]
pub struct CreateForm {
pub name: String,
}
pub async fn create(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Form(form): Form<CreateForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
if form.name.trim().is_empty() {
return notice_then(&state, lang, "error", t(lang, "groups.name_required")).await;
}
state
.db
.device_group_create(form.name.trim())
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then(&state, lang, "ok", &tf1(lang, "groups.created", &form.name)).await
}
pub async fn delete(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let ok = state
.db
.device_group_delete(id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then(
&state,
lang,
if ok { "ok" } else { "error" },
if ok { t(lang, "groups.deleted") } else { t(lang, "common.already_gone") },
)
.await
}
#[derive(Debug, Deserialize)]
pub struct MemberForm {
pub user_id: i64,
}
pub async fn add_member(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>,
Form(form): Form<MemberForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
state
.db
.device_group_add_member(id, form.user_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(render_full(&state, lang).await?))
}
pub async fn remove_member(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path((id, user_id)): Path<(i64, i64)>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
state
.db
.device_group_remove_member(id, user_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(render_full(&state, lang).await?))
}
#[derive(Debug, Deserialize)]
pub struct PeerForm {
pub peer_id: String,
}
pub async fn add_peer(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>,
Form(form): Form<PeerForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let peer_id = form.peer_id.trim();
if peer_id.is_empty() {
return notice_then(&state, lang, "error", t(lang, "groups.peer_id_required")).await;
}
let exists = state
.db
.peer_exists(peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if !exists {
return notice_then(
&state,
lang,
"error",
&tf1(lang, "groups.no_device_yet", peer_id),
)
.await;
}
state
.db
.device_group_add_peer(id, peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(render_full(&state, lang).await?))
}
pub async fn remove_peer(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path((id, peer_id)): Path<(i64, String)>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
state
.db
.device_group_remove_peer(id, &peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(render_full(&state, lang).await?))
}
// ---------- rendering ----------
/// Minimal percent-encoder for path segments. Peer IDs are usually digits,
/// but the schema allows arbitrary text — encode anything outside the
/// unreserved set so a literal `/` or `?` in a peer id can't break routing.
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*b as char);
}
_ => {
use std::fmt::Write;
let _ = write!(out, "%{:02X}", b);
}
}
}
out
}
async fn notice_then(
state: &Arc<AppState>,
lang: Lang,
kind: &str,
msg: &str,
) -> Result<Html<String>, ApiError> {
let mut html = notice_html(kind, msg);
html.push_str(&render_full(state, lang).await?);
Ok(Html(html))
}
async fn render_full(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
let groups = state
.db
.device_groups_list_all()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let (_, all_users) = state
.db
.users_list_all(0, 1000)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let mut s = String::new();
let _ = write!(
s,
r##"<div id="groups-region" class="space-y-6">
<header><h2 class="text-lg font-semibold">{heading}</h2></header>
<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
<h3 class="text-sm font-semibold text-slate-300 mb-3">{create_heading}</h3>
<form class="flex gap-2 text-sm" hx-post="/admin/pages/groups/create" hx-target="#groups-region" hx-swap="outerHTML">
<input name="name" placeholder="{ph}" required class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
<button class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 font-medium text-white">{create}</button>
</form>
</section>
"##,
heading = t(lang, "groups.heading"),
create_heading = t(lang, "groups.create_heading"),
ph = t(lang, "groups.group_name"),
create = t(lang, "common.create"),
);
if groups.is_empty() {
let _ = write!(
s,
r##"<p class="text-slate-500 text-sm">{}</p>"##,
t(lang, "groups.no_groups"),
);
}
for g in &groups {
let members = state
.db
.device_group_members(g.id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let peer_members = state
.db
.device_group_peer_members(g.id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let _ = write!(
s,
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4 space-y-4">
<header class="flex items-center justify-between">
<h3 class="font-semibold">{name}</h3>
<button class="text-xs text-rose-400 hover:text-rose-300"
hx-post="/admin/pages/groups/{id}/delete"
hx-confirm="{confirm}"
hx-target="#groups-region" hx-swap="outerHTML">{delete_group}</button>
</header>
<div>
<h4 class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1">{users}</h4>
<ul class="text-sm divide-y divide-slate-800">"##,
id = g.id,
name = html_escape(&g.name),
confirm = html_escape(&tf1(lang, "groups.confirm_delete", &g.name)),
delete_group = t(lang, "groups.delete_group"),
users = t(lang, "groups.users"),
);
if members.is_empty() {
let _ = write!(
s,
r##"<li class="py-2 text-slate-500 text-xs">{}</li>"##,
t(lang, "groups.no_user_members"),
);
}
for u in &members {
let _ = write!(
s,
r##"<li class="py-2 flex items-center justify-between">
<span class="text-slate-200">{username}</span>
<button class="text-xs text-slate-400 hover:text-rose-300"
hx-post="/admin/pages/groups/{gid}/members/{uid}/remove"
hx-target="#groups-region" hx-swap="outerHTML">{remove}</button>
</li>"##,
username = html_escape(&u.username),
gid = g.id,
uid = u.id,
remove = t(lang, "common.remove"),
);
}
s.push_str("</ul>");
// Add-member form: dropdown of users not currently in the group.
let in_group: std::collections::HashSet<i64> =
members.iter().map(|u| u.id).collect();
let candidates: Vec<_> =
all_users.iter().filter(|u| !in_group.contains(&u.id)).collect();
if !candidates.is_empty() {
let _ = write!(
s,
r##"<form class="flex gap-2 text-sm pt-2 border-t border-slate-800"
hx-post="/admin/pages/groups/{id}/members/add"
hx-target="#groups-region" hx-swap="outerHTML">
<select name="user_id" class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5">
"##,
id = g.id
);
for u in &candidates {
let _ = write!(
s,
r##"<option value="{uid}">{username}</option>"##,
uid = u.id,
username = html_escape(&u.username)
);
}
let _ = write!(
s,
r##"</select>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">{add_user}</button>
</form>"##,
add_user = t(lang, "groups.add_user"),
);
}
s.push_str("</div>");
// ---- Devices section ----
let _ = write!(
s,
r##"<div>
<h4 class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1">{devices}</h4>
<ul class="text-sm divide-y divide-slate-800">"##,
devices = t(lang, "groups.devices_section"),
);
if peer_members.is_empty() {
let _ = write!(
s,
r##"<li class="py-2 text-slate-500 text-xs">{}</li>"##,
t(lang, "groups.no_peer_members"),
);
}
for (peer_id, owner) in &peer_members {
let owner_label = if owner.is_empty() {
format!(
r##"<span class="text-slate-500">{}</span>"##,
t(lang, "groups.unowned"),
)
} else {
format!(
r##"<span class="text-slate-500">{}</span>"##,
html_escape(&tf1(lang, "groups.owner_label", owner)),
)
};
let _ = write!(
s,
r##"<li class="py-2 flex items-center justify-between">
<span class="font-mono text-slate-200">{pid}</span>
<span class="text-xs flex items-center gap-3">
{owner_label}
<button class="text-slate-400 hover:text-rose-300"
hx-post="/admin/pages/groups/{gid}/peers/{pid_url}/remove"
hx-target="#groups-region" hx-swap="outerHTML">{remove}</button>
</span>
</li>"##,
pid = html_escape(peer_id),
pid_url = url_encode(peer_id),
gid = g.id,
owner_label = owner_label,
remove = t(lang, "common.remove"),
);
}
s.push_str("</ul>");
let _ = write!(
s,
r##"<form class="flex gap-2 text-sm pt-2 border-t border-slate-800"
hx-post="/admin/pages/groups/{id}/peers/add"
hx-target="#groups-region" hx-swap="outerHTML">
<input name="peer_id" placeholder="{ph}" required
class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5 font-mono"/>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">{add_device}</button>
</form></div>"##,
id = g.id,
ph = t(lang, "groups.peer_id_placeholder"),
add_device = t(lang, "groups.add_device"),
);
s.push_str("</section>");
}
s.push_str("</div>");
Ok(s)
}
+26
View File
@@ -0,0 +1,26 @@
//! Per-page HTMX fragment handlers. Each page returns a chunk of HTML that
//! the dashboard shell drops into `#main`. Filled in across M5b/M5c.
pub mod address_books;
pub mod audit;
pub mod connect;
pub mod deploy;
pub mod devices;
pub mod exec;
pub mod groups;
pub mod profile;
pub mod shared;
pub mod strategies;
pub mod users;
use axum::response::Html;
/// Tiny placeholder fragment — replaced by the real page handlers in M5b.
pub fn placeholder(title: &str) -> Html<String> {
Html(format!(
r##"<div class="space-y-2">
<h2 class="text-lg font-semibold">{title}</h2>
<p class="text-slate-400 text-sm">This page is part of M5b — the dashboard shell, login, and per-page navigation are wired in M5a; the actual table + form for <strong>{title}</strong> lands in the next slice.</p>
</div>"##
))
}
+623
View File
@@ -0,0 +1,623 @@
//! `/admin/pages/profile` — self-service profile page for the
//! currently-signed-in user. Anyone with a valid dashboard cookie can
//! reach this; no admin gate (the user-management page elsewhere is
//! the admin-side equivalent for editing OTHER users).
//!
//! Flow for each section:
//! - Profile info → POST update-info (display_name, email)
//! - Password → POST change-password (current_pw, new_pw, confirm)
//! - TOTP enroll → POST totp/start (generate secret + QR)
//! → POST totp/confirm (verify 6-digit code)
//! - TOTP remove → POST totp/remove (current_pw)
//!
//! TOTP enrollment is two-step: a freshly-generated secret is shown to
//! the user as a QR code AND echoed in a hidden form field. Until the
//! user submits a valid 6-digit code derived from that secret, nothing
//! is written to `user_totp_secrets`. This means a half-finished enroll
//! (user closes the tab) leaves no garbage state.
use crate::api::admin::i18n::{t, Lang};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use crate::api::users::{hash_password, verify_password};
use axum::extract::{Extension, Form};
use axum::response::Html;
use serde::Deserialize;
use std::fmt::Write as _;
use std::sync::Arc;
use totp_rs::Secret;
// ---------- index ----------
pub async fn index(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
lang: Lang,
) -> Result<Html<String>, ApiError> {
Ok(Html(render_full_page(&state, lang, &user, None).await?))
}
// ---------- update profile info ----------
#[derive(Debug, Deserialize)]
pub struct InfoForm {
#[serde(default)]
pub display_name: String,
#[serde(default)]
pub email: String,
}
pub async fn update_info(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
lang: Lang,
Form(form): Form<InfoForm>,
) -> Result<Html<String>, ApiError> {
state
.db
.user_set_display_name(user.user_id, form.display_name.trim())
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
state
.db
.raw_update_user_email(user.user_id, form.email.trim())
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(
render_full_page(
&state,
lang,
&user,
Some(("ok", t(lang, "profile.profile_updated"))),
)
.await?,
))
}
// ---------- change password ----------
#[derive(Debug, Deserialize)]
pub struct PasswordForm {
pub current_password: String,
pub new_password: String,
pub confirm_password: String,
}
pub async fn change_password(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
lang: Lang,
Form(form): Form<PasswordForm>,
) -> Result<Html<String>, ApiError> {
let row = state
.db
.user_find_by_id(user.user_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::NotFound)?;
if row.is_oidc_linked() {
return Ok(Html(
render_full_page(
&state,
lang,
&user,
Some(("error", t(lang, "profile.password_oidc_change"))),
)
.await?,
));
}
if form.new_password.len() < 4 {
return Ok(Html(
render_full_page(
&state,
lang,
&user,
Some(("error", t(lang, "profile.password_min"))),
)
.await?,
));
}
if form.new_password != form.confirm_password {
return Ok(Html(
render_full_page(
&state,
lang,
&user,
Some(("error", t(lang, "profile.password_mismatch"))),
)
.await?,
));
}
let pw_ok = verify_password(row.password_hash.clone(), form.current_password)
.await
.unwrap_or(false);
if !pw_ok {
return Ok(Html(
render_full_page(
&state,
lang,
&user,
Some(("error", t(lang, "profile.current_incorrect"))),
)
.await?,
));
}
let hash = hash_password(form.new_password)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
state
.db
.user_set_password(user.user_id, &hash)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(
render_full_page(
&state,
lang,
&user,
Some(("ok", t(lang, "profile.password_updated"))),
)
.await?,
))
}
// ---------- TOTP: start ----------
/// `POST /admin/pages/profile/totp/start` — generate a fresh secret
/// and render the QR + confirm form. Nothing is written to the DB
/// yet; the secret rides in a hidden form field until confirm.
pub async fn totp_start(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
lang: Lang,
) -> Result<Html<String>, ApiError> {
// Reject if the user already has TOTP — they should remove it first.
let already = state
.db
.user_has_totp(user.user_id)
.await
.unwrap_or(false);
if already {
return Ok(Html(
render_full_page(
&state,
lang,
&user,
Some(("error", t(lang, "profile.tfa_already"))),
)
.await?,
));
}
let raw = sodiumoxide::randombytes::randombytes(20);
let secret_b32 = Secret::Raw(raw).to_encoded().to_string();
let issuer = "RustDesk";
let label = format!("{}:{}", issuer, user.name);
let otpauth = format!(
"otpauth://totp/{label}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits=6&period=30",
label = url_encode(&label),
secret = url_encode(&secret_b32),
issuer = url_encode(issuer),
);
let qr_svg = render_qr_svg(&otpauth);
Ok(Html(render_totp_enroll_panel(
&state,
lang,
&user,
&secret_b32,
&qr_svg,
None,
)
.await?))
}
// ---------- TOTP: confirm ----------
#[derive(Debug, Deserialize)]
pub struct TotpConfirmForm {
pub secret_b32: String,
pub code: String,
}
pub async fn totp_confirm(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
lang: Lang,
Form(form): Form<TotpConfirmForm>,
) -> Result<Html<String>, ApiError> {
let code = form.code.trim();
let secret = form.secret_b32.trim();
if secret.is_empty() {
return Ok(Html(
render_full_page(
&state,
lang,
&user,
Some(("error", t(lang, "profile.tfa_missing_secret"))),
)
.await?,
));
}
let valid = crate::api::auth::verify_totp(secret, code).unwrap_or(false);
if !valid {
// Re-render the enroll panel with the same secret so the user
// can try again — losing the QR forces them to start over and
// re-scan, which is annoying when the only error was a typo'd
// code.
let issuer = "RustDesk";
let label = format!("{}:{}", issuer, user.name);
let otpauth = format!(
"otpauth://totp/{label}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits=6&period=30",
label = url_encode(&label),
secret = url_encode(secret),
issuer = url_encode(issuer),
);
let qr_svg = render_qr_svg(&otpauth);
return Ok(Html(
render_totp_enroll_panel(
&state,
lang,
&user,
secret,
&qr_svg,
Some(("error", t(lang, "profile.tfa_bad_code"))),
)
.await?,
));
}
state
.db
.totp_enroll(user.user_id, secret)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(
render_full_page(
&state,
lang,
&user,
Some(("ok", t(lang, "profile.tfa_enrolled"))),
)
.await?,
))
}
// ---------- TOTP: remove ----------
#[derive(Debug, Deserialize)]
pub struct TotpRemoveForm {
pub current_password: String,
}
pub async fn totp_remove(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
lang: Lang,
Form(form): Form<TotpRemoveForm>,
) -> Result<Html<String>, ApiError> {
let row = state
.db
.user_find_by_id(user.user_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::NotFound)?;
let pw_ok = verify_password(row.password_hash.clone(), form.current_password)
.await
.unwrap_or(false);
if !pw_ok {
return Ok(Html(
render_full_page(
&state,
lang,
&user,
Some(("error", t(lang, "profile.tfa_current_pw_incorrect"))),
)
.await?,
));
}
state
.db
.totp_unenroll(user.user_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Html(
render_full_page(
&state,
lang,
&user,
Some(("ok", t(lang, "profile.tfa_removed"))),
)
.await?,
))
}
// ---------- rendering ----------
async fn render_full_page(
state: &Arc<AppState>,
lang: Lang,
user: &AuthedUser,
notice: Option<(&str, &str)>,
) -> Result<String, ApiError> {
render_full_page_with_totp_override(state, lang, user, notice, None).await
}
/// `totp_panel_override = Some(html)` swaps in a custom TOTP block —
/// used during enrollment confirm so the QR code panel sits where the
/// status badge would normally be, and the password / profile sections
/// stay visible above it.
async fn render_full_page_with_totp_override(
state: &Arc<AppState>,
lang: Lang,
user: &AuthedUser,
notice: Option<(&str, &str)>,
totp_panel_override: Option<String>,
) -> Result<String, ApiError> {
let row = state
.db
.user_find_by_id(user.user_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::NotFound)?;
let has_totp = state
.db
.user_has_totp(user.user_id)
.await
.unwrap_or(false);
let notice_block = notice
.map(|(k, m)| notice_html(k, m))
.unwrap_or_default();
// OIDC-linked accounts sign in via the IdP — local password and
// local TOTP both moot. Replace those sections with a short note.
let oidc_linked = row.is_oidc_linked();
let password_section = if oidc_linked {
format!(
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
<h3 class="text-sm font-semibold text-slate-300 mb-2">{heading}</h3>
<p class="text-sm text-slate-400">
{msg}
</p>
</section>"##,
heading = t(lang, "profile.password_oidc"),
msg = t(lang, "profile.password_oidc_msg"),
)
} else {
format!(
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
<h3 class="text-sm font-semibold text-slate-300 mb-3">{heading}</h3>
<form
class="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm"
hx-post="/admin/pages/profile/change-password"
hx-target="#main"
hx-swap="innerHTML"
hx-on::after-request="if (event.detail.successful) this.reset()"
>
<input name="current_password" type="password" required placeholder="{cur}" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
<input name="new_password" type="password" required minlength="4" placeholder="{new}" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
<input name="confirm_password" type="password" required minlength="4" placeholder="{conf}" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
<button type="submit" class="sm:col-span-3 justify-self-start bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 text-white text-sm">{btn}</button>
</form>
</section>"##,
heading = t(lang, "profile.password_heading"),
cur = t(lang, "profile.current_password"),
new = t(lang, "profile.new_password"),
conf = t(lang, "profile.confirm_new"),
btn = t(lang, "profile.update_password"),
)
};
let totp_section = if let Some(panel) = totp_panel_override {
panel
} else if oidc_linked {
format!(
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
<h3 class="text-sm font-semibold text-slate-300 mb-2">{heading}</h3>
<p class="text-sm text-slate-400">
{msg}
</p>
</section>"##,
heading = t(lang, "profile.tfa"),
msg = t(lang, "profile.tfa_oidc_msg"),
)
} else if has_totp {
format!(
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
<h3 class="text-sm font-semibold text-slate-300 mb-2">{heading}</h3>
<div class="flex items-center gap-3 mb-3">
<span class="inline-flex px-1.5 py-0.5 rounded bg-violet-900/50 border border-violet-700/50 text-violet-300 text-xs">{enrolled}</span>
<span class="text-xs text-slate-400">{tfa_msg}</span>
</div>
<form
class="flex gap-2 items-center text-sm"
hx-post="/admin/pages/profile/totp/remove"
hx-target="#main"
hx-swap="innerHTML"
hx-confirm="{confirm}"
>
<input name="current_password" type="password" required placeholder="{cur}" class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5 flex-1 max-w-xs"/>
<button class="bg-rose-700 hover:bg-rose-600 rounded px-3 py-1.5 text-white text-xs">{btn}</button>
</form>
</section>"##,
heading = t(lang, "profile.tfa"),
enrolled = t(lang, "users.totp_enrolled"),
tfa_msg = t(lang, "profile.tfa_enrolled_msg"),
confirm = t(lang, "profile.tfa_confirm_remove"),
cur = t(lang, "profile.current_password"),
btn = t(lang, "profile.tfa_disable"),
)
} else {
format!(
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
<h3 class="text-sm font-semibold text-slate-300 mb-2">{heading}</h3>
<p class="text-sm text-slate-400 mb-3">
{intro}
</p>
<button
class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 text-white text-sm"
hx-post="/admin/pages/profile/totp/start"
hx-target="#main"
hx-swap="innerHTML"
>{btn}</button>
</section>"##,
heading = t(lang, "profile.tfa"),
intro = t(lang, "profile.tfa_intro"),
btn = t(lang, "profile.tfa_enroll"),
)
};
Ok(format!(
r##"<div class="space-y-6 max-w-3xl">
<header>
<h2 class="text-lg font-semibold">{heading}</h2>
<p class="text-xs text-slate-500 mt-0.5">{signed_in_as} <span class="text-slate-300">{username}</span></p>
</header>
{notice}
<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
<h3 class="text-sm font-semibold text-slate-300 mb-3">{info_heading}</h3>
<form
class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm"
hx-post="/admin/pages/profile/update-info"
hx-target="#main"
hx-swap="innerHTML"
>
<label class="block">
<span class="text-xs text-slate-400">{display_name_l}</span>
<input name="display_name" value="{display_name}" class="mt-1 w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
</label>
<label class="block">
<span class="text-xs text-slate-400">{email_l}</span>
<input name="email" type="email" value="{email}" class="mt-1 w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
</label>
<button type="submit" class="sm:col-span-2 justify-self-start bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 text-white text-sm">{save}</button>
</form>
</section>
{password_section}
{totp_section}
</div>"##,
heading = t(lang, "profile.heading"),
signed_in_as = t(lang, "profile.signed_in_as"),
info_heading = t(lang, "profile.info_heading"),
display_name_l = t(lang, "profile.display_name"),
email_l = t(lang, "profile.email"),
save = t(lang, "common.save"),
username = html_escape(&user.name),
display_name = html_escape(&row.display_name),
email = html_escape(&row.email),
notice = notice_block,
password_section = password_section,
totp_section = totp_section,
))
}
async fn render_totp_enroll_panel(
state: &Arc<AppState>,
lang: Lang,
user: &AuthedUser,
secret_b32: &str,
qr_svg: &str,
notice: Option<(&str, &str)>,
) -> Result<String, ApiError> {
let panel = format!(
r##"<section class="rounded-md border border-sky-700/60 bg-sky-900/20 p-4">
<h3 class="text-sm font-semibold text-sky-200 mb-2">{heading}</h3>
<p class="text-xs text-sky-200/80 mb-3">
{intro}
</p>
<div class="flex flex-col sm:flex-row gap-4 items-start">
<div class="bg-white p-2 rounded inline-block">{qr}</div>
<div class="flex-1 space-y-2 text-sm">
<div>
<span class="text-xs text-slate-400">{secret_label}</span>
<code class="block mt-1 break-all text-emerald-200 bg-slate-950 px-2 py-1.5 rounded text-xs">{secret}</code>
</div>
<form
class="flex gap-2 items-stretch"
hx-post="/admin/pages/profile/totp/confirm"
hx-target="#main"
hx-swap="innerHTML"
>
<input type="hidden" name="secret_b32" value="{secret}"/>
<input
name="code"
type="text"
inputmode="numeric"
pattern="[0-9]{{6}}"
required
maxlength="6"
autocomplete="one-time-code"
placeholder="123456"
class="bg-slate-800 border border-slate-700 rounded px-2 py-1.5 w-32 font-mono tracking-widest"
/>
<button type="submit" class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 text-white text-sm">{confirm}</button>
</form>
</div>
</div>
</section>"##,
heading = t(lang, "profile.tfa_confirm_heading"),
intro = t(lang, "profile.tfa_confirm_intro"),
secret_label = t(lang, "profile.tfa_secret_manual"),
confirm = t(lang, "common.confirm"),
qr = qr_svg,
secret = html_escape(secret_b32),
);
render_full_page_with_totp_override(state, lang, user, notice, Some(panel)).await
}
fn render_qr_svg(payload: &str) -> String {
// qrcode 0.14: build the QR, then render with the SVG renderer.
// .min_dimensions caps how big the SVG-pixel grid is; the actual
// CSS size is handled by inline width/height, but the underlying
// module size needs to be reasonable so it stays crisp on retina.
use qrcode::render::svg;
use qrcode::QrCode;
match QrCode::new(payload.as_bytes()) {
Ok(code) => code
.render::<svg::Color<'_>>()
.min_dimensions(180, 180)
.dark_color(svg::Color("#000"))
.light_color(svg::Color("#fff"))
.build(),
Err(_) => "<div class=\"text-rose-300 text-xs\">QR encode failed</div>".to_string(),
}
}
fn notice_html(kind: &str, msg: &str) -> String {
let (border, bg, text) = match kind {
"ok" => ("emerald-700/50", "emerald-900/30", "emerald-300"),
_ => ("rose-700/50", "rose-900/30", "rose-300"),
};
format!(
r##"<div class="rounded border border-{border} bg-{bg} p-3 mb-4 text-sm text-{text}">{msg}</div>"##,
border = border,
bg = bg,
text = text,
msg = html_escape(msg),
)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*b as char);
}
_ => {
let _ = write!(out, "%{:02X}", b);
}
}
}
out
}
+46
View File
@@ -0,0 +1,46 @@
//! Tiny rendering helpers shared by every admin page. Splitting these out
//! keeps each page module under ~200 LOC.
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
pub fn require_admin(u: &AuthedUser) -> Result<(), ApiError> {
if u.is_admin {
Ok(())
} else {
Err(ApiError::Forbidden("admin required".into()))
}
}
pub fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
pub fn notice_html(kind: &str, msg: &str) -> String {
let (border, bg, text) = match kind {
"ok" => ("emerald-700/50", "emerald-900/30", "emerald-300"),
_ => ("rose-700/50", "rose-900/30", "rose-300"),
};
format!(
r##"<div class="rounded border border-{border} bg-{bg} p-3 mb-4 text-sm text-{text}">{msg}</div>"##,
border = border,
bg = bg,
text = text,
msg = html_escape(msg),
)
}
/// Format a unix timestamp as a short ISO-ish string for table cells.
pub fn fmt_unix(ts: i64) -> String {
if ts <= 0 {
return "".into();
}
use chrono::{TimeZone, Utc};
Utc.timestamp_opt(ts, 0)
.single()
.map(|t| t.format("%Y-%m-%d %H:%M:%SZ").to_string())
.unwrap_or_else(|| ts.to_string())
}
+468
View File
@@ -0,0 +1,468 @@
//! Strategies page — list / create / edit-config / delete, plus the
//! assignment matrix that decides which clients receive each strategy.
//! Assignments are scoped to device groups or individual peers; user-level
//! assignments are still SQL-driven (rare in practice).
use super::shared::{html_escape, notice_html, require_admin};
use crate::api::admin::i18n::{t, tf1, tf2, Lang};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use axum::extract::{Extension, Form, Path};
use axum::response::Html;
use serde::Deserialize;
use std::fmt::Write as _;
use std::sync::Arc;
pub async fn index(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
Ok(Html(render_full(&state, lang).await?))
}
#[derive(Debug, Deserialize)]
pub struct CreateForm {
pub name: String,
#[serde(default)]
pub config_options_json: String,
}
pub async fn create(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Form(form): Form<CreateForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
if form.name.trim().is_empty() {
return notice_then(&state, lang, "error", t(lang, "groups.name_required")).await;
}
let cfg = if form.config_options_json.trim().is_empty() {
"{}".to_string()
} else {
// Validate it's a JSON object — empty object is fine, anything else
// gets rejected with a friendly message.
match serde_json::from_str::<serde_json::Value>(&form.config_options_json) {
Ok(v) if v.is_object() => form.config_options_json.clone(),
Ok(_) => {
return notice_then(&state, lang, "error", t(lang, "strategies.json_obj_required")).await
}
Err(e) => {
return notice_then(
&state,
lang,
"error",
&tf1(lang, "strategies.invalid_json", &e.to_string()),
)
.await
}
}
};
state
.db
.strategy_create(form.name.trim(), &cfg, "{}")
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then(
&state,
lang,
"ok",
&tf1(lang, "strategies.created", &form.name),
)
.await
}
#[derive(Debug, Deserialize)]
pub struct UpdateForm {
pub config_options_json: String,
}
pub async fn update(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>,
Form(form): Form<UpdateForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let cfg = match serde_json::from_str::<serde_json::Value>(&form.config_options_json) {
Ok(v) if v.is_object() => form.config_options_json.clone(),
_ => {
return notice_then(&state, lang, "error", t(lang, "strategies.json_obj_required")).await
}
};
state
.db
.strategy_update_config(id, &cfg)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then(&state, lang, "ok", t(lang, "strategies.updated")).await
}
pub async fn delete(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let ok = state
.db
.strategy_delete(id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then(
&state,
lang,
if ok { "ok" } else { "error" },
if ok { t(lang, "strategies.deleted") } else { t(lang, "common.already_gone") },
)
.await
}
#[derive(Debug, Deserialize)]
pub struct AssignGroupForm {
pub device_group_id: i64,
}
pub async fn assign_group(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>,
Form(form): Form<AssignGroupForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
state
.db
.strategy_assign_group(id, form.device_group_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then(&state, lang, "ok", t(lang, "strategies.group_assigned")).await
}
#[derive(Debug, Deserialize)]
pub struct AssignPeerForm {
pub peer_id: String,
}
pub async fn assign_peer(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(id): Path<i64>,
Form(form): Form<AssignPeerForm>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let peer_id = form.peer_id.trim();
if peer_id.is_empty() {
return notice_then(&state, lang, "error", t(lang, "groups.peer_id_required")).await;
}
let exists = state
.db
.peer_exists(peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if !exists {
return notice_then(
&state,
lang,
"error",
&tf1(lang, "groups.no_device_yet", peer_id),
)
.await;
}
state
.db
.strategy_assign_peer_replace(id, peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then(
&state,
lang,
"ok",
&tf1(lang, "strategies.peer_assigned", peer_id),
)
.await
}
pub async fn unassign(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path((id, assignment_id)): Path<(i64, i64)>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let ok = state
.db
.strategy_assignment_delete(id, assignment_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then(
&state,
lang,
if ok { "ok" } else { "error" },
if ok { t(lang, "strategies.unassigned") } else { t(lang, "common.already_gone") },
)
.await
}
// ---------- rendering ----------
async fn notice_then(
state: &Arc<AppState>,
lang: Lang,
kind: &str,
msg: &str,
) -> Result<Html<String>, ApiError> {
let mut html = notice_html(kind, msg);
html.push_str(&render_full(state, lang).await?);
Ok(Html(html))
}
async fn render_full(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiError> {
let strategies = state
.db
.strategies_list_all()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let all_groups = state
.db
.device_groups_list_all()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let mut s = String::new();
let _ = write!(
s,
r##"<div id="strategies-region" class="space-y-6">
<header>
<h2 class="text-lg font-semibold">{heading}</h2>
<p class="text-xs text-slate-500 mt-1">{tagline}</p>
</header>
<section class="rounded-md border border-slate-800 bg-slate-900 p-4">
<h3 class="text-sm font-semibold text-slate-300 mb-3">{create_heading}</h3>
<form class="space-y-2 text-sm" hx-post="/admin/pages/strategies/create" hx-target="#strategies-region" hx-swap="outerHTML">
<input name="name" placeholder="{ph}" required class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5"/>
<textarea name="config_options_json" rows="3" placeholder='{{"enable-udp": "N", "whitelist": ""}}'
class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5 font-mono text-xs"></textarea>
<button class="bg-sky-600 hover:bg-sky-500 rounded px-3 py-1.5 font-medium text-white">{create}</button>
</form>
</section>
"##,
heading = t(lang, "strategies.heading"),
tagline = t(lang, "strategies.tagline"),
create_heading = t(lang, "strategies.create_heading"),
ph = t(lang, "strategies.name_unique"),
create = t(lang, "common.create"),
);
if strategies.is_empty() {
let _ = write!(
s,
r##"<p class="text-slate-500 text-sm">{}</p>"##,
t(lang, "strategies.no_strategies"),
);
}
for str_ in &strategies {
let assignments = state
.db
.strategy_assignments_for(str_.id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let _ = write!(
s,
r##"<section class="rounded-md border border-slate-800 bg-slate-900 p-4 space-y-3">
<header class="flex items-center justify-between">
<div>
<h3 class="font-semibold">{name}</h3>
<p class="text-xs text-slate-500">{meta}</p>
</div>
<button class="text-xs text-rose-400 hover:text-rose-300"
hx-post="/admin/pages/strategies/{id}/delete"
hx-confirm="{confirm}"
hx-target="#strategies-region" hx-swap="outerHTML">{delete}</button>
</header>
<form class="space-y-2 text-sm"
hx-post="/admin/pages/strategies/{id}/update"
hx-target="#strategies-region" hx-swap="outerHTML">
<label class="block text-xs text-slate-400">{cfg_label}</label>
<textarea name="config_options_json" rows="4"
class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5 font-mono text-xs">{cfg}</textarea>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">{save}</button>
</form>"##,
id = str_.id,
name = html_escape(&str_.name),
meta = tf2(lang, "strategies.id_modified", &str_.id.to_string(), &str_.modified_at.to_string()),
cfg = html_escape(&str_.config_options_json),
confirm = html_escape(&tf1(lang, "strategies.confirm_delete", &str_.name)),
delete = t(lang, "common.delete"),
cfg_label = t(lang, "strategies.config_label"),
save = t(lang, "common.save"),
);
// ---- Assignments section ----
render_assignments(&mut s, lang, str_.id, &assignments, &all_groups);
s.push_str("</section>");
}
s.push_str("</div>");
Ok(s)
}
fn render_assignments(
s: &mut String,
lang: Lang,
strategy_id: i64,
assignments: &[crate::database::StrategyAssignmentRow],
all_groups: &[crate::database::DeviceGroupRow],
) {
let group_assigned: std::collections::HashSet<i64> = assignments
.iter()
.filter_map(|a| a.device_group_id)
.collect();
let group_rows: Vec<&crate::database::StrategyAssignmentRow> = assignments
.iter()
.filter(|a| a.device_group_id.is_some())
.collect();
let peer_rows: Vec<&crate::database::StrategyAssignmentRow> = assignments
.iter()
.filter(|a| a.peer_id.is_some())
.collect();
let _ = write!(
s,
r##"<div class="pt-3 border-t border-slate-800 space-y-3">
<h4 class="text-xs font-semibold text-slate-400 uppercase tracking-wide">{filter_heading}</h4>
<p class="text-[11px] text-slate-500">{filter_hint}</p>"##,
filter_heading = t(lang, "strategies.filter_heading"),
filter_hint = t(lang, "strategies.filter_hint"),
);
// ---- Device groups ----
let _ = write!(
s,
r##"<div>
<div class="text-[11px] font-semibold text-slate-400 mb-1">{groups_label}</div>
<ul class="text-sm divide-y divide-slate-800">"##,
groups_label = t(lang, "strategies.groups_label"),
);
if group_rows.is_empty() {
let _ = write!(
s,
r##"<li class="py-2 text-slate-500 text-xs">{}</li>"##,
t(lang, "strategies.no_group_assignments"),
);
}
for a in &group_rows {
let name = a
.device_group_name
.as_deref()
.unwrap_or("(deleted group)");
let _ = write!(
s,
r##"<li class="py-2 flex items-center justify-between">
<span class="text-slate-200">{name}</span>
<button class="text-xs text-slate-400 hover:text-rose-300"
hx-post="/admin/pages/strategies/{sid}/assignments/{aid}/delete"
hx-target="#strategies-region" hx-swap="outerHTML">{remove}</button>
</li>"##,
name = html_escape(name),
sid = strategy_id,
aid = a.id,
remove = t(lang, "common.remove"),
);
}
s.push_str("</ul>");
let candidates: Vec<&crate::database::DeviceGroupRow> = all_groups
.iter()
.filter(|g| !group_assigned.contains(&g.id))
.collect();
if !candidates.is_empty() {
let _ = write!(
s,
r##"<form class="flex gap-2 text-sm pt-2"
hx-post="/admin/pages/strategies/{sid}/assignments/group"
hx-target="#strategies-region" hx-swap="outerHTML">
<select name="device_group_id" class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5">
"##,
sid = strategy_id,
);
for g in &candidates {
let _ = write!(
s,
r##"<option value="{gid}">{name}</option>"##,
gid = g.id,
name = html_escape(&g.name),
);
}
let _ = write!(
s,
r##"</select>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">{assign}</button>
</form>"##,
assign = t(lang, "strategies.assign_group"),
);
} else if !all_groups.is_empty() {
let _ = write!(
s,
r##"<p class="text-[11px] text-slate-500 pt-1">{}</p>"##,
t(lang, "strategies.all_groups_assigned"),
);
} else {
let _ = write!(
s,
r##"<p class="text-[11px] text-slate-500 pt-1">{}</p>"##,
t(lang, "strategies.no_groups_exist"),
);
}
s.push_str("</div>");
// ---- Peers ----
let _ = write!(
s,
r##"<div>
<div class="text-[11px] font-semibold text-slate-400 mb-1">{peers_label}</div>
<ul class="text-sm divide-y divide-slate-800">"##,
peers_label = t(lang, "strategies.peers_label"),
);
if peer_rows.is_empty() {
let _ = write!(
s,
r##"<li class="py-2 text-slate-500 text-xs">{}</li>"##,
t(lang, "strategies.no_peer_assignments"),
);
}
for a in &peer_rows {
let pid = a.peer_id.as_deref().unwrap_or("");
let _ = write!(
s,
r##"<li class="py-2 flex items-center justify-between">
<span class="font-mono text-slate-200">{pid}</span>
<button class="text-xs text-slate-400 hover:text-rose-300"
hx-post="/admin/pages/strategies/{sid}/assignments/{aid}/delete"
hx-target="#strategies-region" hx-swap="outerHTML">{remove}</button>
</li>"##,
pid = html_escape(pid),
sid = strategy_id,
aid = a.id,
remove = t(lang, "common.remove"),
);
}
s.push_str("</ul>");
let _ = write!(
s,
r##"<form class="flex gap-2 text-sm pt-2"
hx-post="/admin/pages/strategies/{sid}/assignments/peer"
hx-target="#strategies-region" hx-swap="outerHTML">
<input name="peer_id" placeholder="{ph}" required
class="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1.5 font-mono"/>
<button class="bg-sky-700 hover:bg-sky-600 rounded px-3 py-1.5 text-xs">{assign}</button>
</form>"##,
sid = strategy_id,
ph = t(lang, "groups.peer_id_placeholder"),
assign = t(lang, "strategies.assign_peer"),
);
s.push_str("</div></div>");
}
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
//! `POST /api/agent/exec-result` — agent posts back the result of a
//! PowerShell command queued via the heartbeat reply.
//!
//! Auth: same per-peer signed-API gate as the other agent endpoints
//! ([`crate::api::device_auth`]). Because remote exec is only ever
//! dispatched against `peer.managed = 1` peers, *this* endpoint
//! additionally refuses unsigned posts even when the peer happens to be
//! `managed=0` — there's no legacy compatibility story for exec, so we
//! fail closed.
use crate::api::device_auth::{self, AuthOutcome};
use crate::api::error::ApiError;
use crate::api::state::AppState;
use axum::body::Bytes;
use axum::extract::Extension;
use axum::http::HeaderMap;
use serde::Deserialize;
use std::sync::Arc;
#[derive(Debug, Deserialize)]
pub struct ExecResultBody {
pub id: String,
pub uuid: String,
pub cmd_id: String,
pub exit_code: i64,
#[serde(default)]
pub stdout: String,
#[serde(default)]
pub stderr: String,
#[serde(default)]
pub timed_out: bool,
#[serde(default)]
pub truncated: bool,
}
pub async fn exec_result(
Extension(state): Extension<Arc<AppState>>,
headers: HeaderMap,
body: Bytes,
) -> Result<String, ApiError> {
let outcome =
device_auth::verify(&state, "POST", "/api/agent/exec-result", &headers, &body).await?;
let payload: ExecResultBody = serde_json::from_slice(&body)
.map_err(|_| ApiError::BadRequest("invalid json".into()))?;
if payload.id.is_empty() || payload.uuid.is_empty() || payload.cmd_id.is_empty() {
return Err(ApiError::BadRequest(
"id, uuid, and cmd_id required".into(),
));
}
// Bind identity to body. Unsigned posts are flat-out rejected here
// even when the peer is currently managed=0 — exec is a signed-only
// feature, no legacy path.
let id = match outcome {
AuthOutcome::Verified { id: signed_id } => {
if payload.id != signed_id {
return Err(ApiError::Unauthorized);
}
signed_id
}
AuthOutcome::LegacyUnsigned => return Err(ApiError::Unauthorized),
};
let updated = state
.db
.exec_finish(
&payload.cmd_id,
&id,
payload.exit_code,
&payload.stdout,
&payload.stderr,
payload.timed_out,
payload.truncated,
)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if !updated {
// Either the cmd_id doesn't exist, belongs to another peer, or
// is already in a terminal state. The agent doesn't need to
// distinguish — log on our side and return OK so it doesn't
// retry forever.
hbb_common::log::warn!(
"exec-result: no-op update for cmd_id={} peer={} (already finalized or wrong peer)",
payload.cmd_id,
id
);
}
Ok("OK".to_string())
}
+38
View File
@@ -0,0 +1,38 @@
//! `POST /api/audit/alarm` — security alarm (IP whitelist hit, brute-force
//! thresholds). Wire shape from CONSOLE_API.md §7.3:
//! `{ id, uuid, typ: int, info: stringified-JSON }`.
use crate::api::error::ApiError;
use crate::api::state::AppState;
use axum::extract::Extension;
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use std::sync::Arc;
#[derive(Debug, Deserialize)]
pub struct AlarmAuditBody {
#[serde(default)]
pub id: String,
#[serde(default)]
pub uuid: String,
#[serde(default)]
pub typ: i64,
#[serde(default)]
pub info: String,
}
pub async fn alarm(
Extension(state): Extension<Arc<AppState>>,
Json(body): Json<AlarmAuditBody>,
) -> Result<StatusCode, ApiError> {
if body.id.is_empty() {
return Err(ApiError::BadRequest("id required".into()));
}
state
.db
.audit_alarm_insert(&body.id, body.typ, &body.info)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(StatusCode::OK)
}
+49
View File
@@ -0,0 +1,49 @@
//! `POST /api/audit/conn` — fire-and-forget connection log entry. The client
//! ([src/server/connection.rs:1248-1279](file:///Users/sn0/Desktop/rustdesk/src/server/connection.rs#L1248))
//! emits this on every accepted session, no Authorization header. We answer
//! with `{"guid":"..."}` so the client can pass that guid back later in
//! `PUT /api/audit` (CONSOLE_API.md §7.1).
use crate::api::error::ApiError;
use crate::api::state::AppState;
use axum::extract::Extension;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use std::sync::Arc;
#[derive(Debug, Deserialize)]
pub struct ConnAuditBody {
#[serde(default)]
pub id: String,
#[serde(default)]
pub uuid: String,
#[serde(default)]
pub conn_id: i64,
#[serde(default)]
pub session_id: i64,
#[serde(default)]
pub ip: String,
#[serde(default)]
pub action: String,
}
pub async fn conn(
Extension(state): Extension<Arc<AppState>>,
Json(body): Json<ConnAuditBody>,
) -> Result<Json<Value>, ApiError> {
if body.id.is_empty() {
return Err(ApiError::BadRequest("id required".into()));
}
let action = if body.action.is_empty() {
"new"
} else {
body.action.as_str()
};
let guid = state
.db
.audit_conn_insert(&body.id, body.conn_id, body.session_id, &body.ip, action)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(json!({ "guid": guid })))
}
+50
View File
@@ -0,0 +1,50 @@
//! `POST /api/audit/file` — file transfer log entry (CONSOLE_API.md §7.2).
//! `info` arrives as a stringified JSON object; we store it verbatim.
use crate::api::error::ApiError;
use crate::api::state::AppState;
use axum::extract::Extension;
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use std::sync::Arc;
#[derive(Debug, Deserialize)]
pub struct FileAuditBody {
#[serde(default)]
pub id: String,
#[serde(default)]
pub uuid: String,
#[serde(default)]
pub peer_id: String,
#[serde(default, rename = "type")]
pub direction: i64,
#[serde(default)]
pub path: String,
#[serde(default)]
pub is_file: bool,
#[serde(default)]
pub info: String,
}
pub async fn file(
Extension(state): Extension<Arc<AppState>>,
Json(body): Json<FileAuditBody>,
) -> Result<StatusCode, ApiError> {
if body.id.is_empty() {
return Err(ApiError::BadRequest("id required".into()));
}
state
.db
.audit_file_insert(
&body.id,
&body.peer_id,
body.direction,
&body.path,
body.is_file,
&body.info,
)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(StatusCode::OK)
}
+4
View File
@@ -0,0 +1,4 @@
pub mod alarm;
pub mod conn;
pub mod file;
pub mod note;
+39
View File
@@ -0,0 +1,39 @@
//! `PUT /api/audit` — operator end-of-session note. Sent from the Flutter
//! `_showConnEndAuditDialogCloseCanceled` flow at
//! [flutter/lib/common/widgets/dialog.dart:1656](file:///Users/sn0/Desktop/rustdesk/flutter/lib/common/widgets/dialog.dart#L1656).
//! Bearer-authenticated.
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use axum::extract::Extension;
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use std::sync::Arc;
#[derive(Debug, Deserialize)]
pub struct NoteBody {
pub guid: String,
#[serde(default)]
pub note: String,
}
pub async fn note(
Extension(state): Extension<Arc<AppState>>,
_user: AuthedUser,
Json(body): Json<NoteBody>,
) -> Result<StatusCode, ApiError> {
if body.guid.is_empty() {
return Err(ApiError::BadRequest("guid required".into()));
}
let updated = state
.db
.audit_conn_update_note(&body.guid, &body.note)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if !updated {
return Err(ApiError::NotFound);
}
Ok(StatusCode::OK)
}
+376
View File
@@ -0,0 +1,376 @@
use crate::api::email;
use crate::api::error::ApiError;
use crate::api::middleware::{sha256_token, AuthedUser};
use crate::api::state::AppState;
use crate::api::users::{verify_password, UserPayload};
use crate::database::UserRow;
use axum::extract::Extension;
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use std::sync::Arc;
use totp_rs::{Algorithm, Secret, TOTP};
const EMAIL_CODE_TTL_SECS: i64 = 600;
/// `LoginRequest` mirrors the Flutter client at
/// flutter/lib/common/hbbs/hbbs.dart:133. M1 only consults `username`,
/// `password`, and `type`; the other fields are tolerated for forward-compat.
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub password: Option<String>,
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub uuid: Option<String>,
#[serde(default, rename = "type")]
pub kind: Option<String>,
#[serde(default, rename = "deviceInfo")]
pub device_info: Option<Value>,
// Tolerated, ignored in M1:
#[serde(default)]
pub auto_login: Option<bool>,
#[serde(default, rename = "verificationCode")]
pub verification_code: Option<String>,
#[serde(default, rename = "tfaCode")]
pub tfa_code: Option<String>,
#[serde(default)]
pub secret: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct IdUuidBody {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub uuid: Option<String>,
}
pub async fn login_options_head() -> StatusCode {
StatusCode::OK
}
pub async fn login_options(Extension(state): Extension<Arc<AppState>>) -> Json<Vec<String>> {
// Static base set from config (account / email_code), plus a dynamic
// `oidc/<name>` entry per enabled provider in the DB. Recomputed per
// request so adding a provider via SQL takes effect without a restart.
let mut out = state.cfg.login_options.clone();
if !state.cfg.public_base_url.is_empty() {
if let Ok(providers) = state.db.oidc_provider_list_enabled().await {
for p in providers {
out.push(format!("oidc/{}", p.name));
}
}
}
Json(out)
}
const TFA_CHALLENGE_TTL_SECS: i64 = 300;
pub async fn login(
Extension(state): Extension<Arc<AppState>>,
Json(req): Json<LoginRequest>,
) -> Result<Json<Value>, ApiError> {
// The desktop client reuses the email-code dialog for the TOTP second
// leg: it POSTs `type: "email_code"` with `tfaCode` set (and the email
// `verificationCode` field also set, but we ignore that when tfaCode is
// present). Detect that shape up-front and route to the TOTP verifier;
// otherwise dispatch on the declared `type`.
let has_tfa = req.tfa_code.as_deref().is_some_and(|s| !s.is_empty())
&& req.secret.as_deref().is_some_and(|s| !s.is_empty());
if has_tfa {
return login_tfa_code(state, req).await;
}
let kind = req.kind.as_deref().unwrap_or("account");
match kind {
"account" | "" => login_account(state, req).await,
"tfa_code" => login_tfa_code(state, req).await,
"email_code" => login_email_code(state, req).await,
other => Err(ApiError::BadRequest(format!(
"unsupported login type: {}",
other
))),
}
}
/// Two-leg passwordless login by email. Leg 1 (no `verificationCode`) mints a
/// fresh 6-digit code and emails it to the user (or logs to stdout when SMTP
/// is unconfigured). Leg 2 (with `verificationCode`) verifies the code,
/// consumes it, and issues an access token.
async fn login_email_code(
state: Arc<AppState>,
req: LoginRequest,
) -> Result<Json<Value>, ApiError> {
// The Flutter client passes the email/username in the `username` field;
// accept it either as a literal email or as a username we can map to one.
let identifier = req
.username
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| ApiError::BadRequest("username (email) required".into()))?;
let user = resolve_user_by_identifier(&state, identifier).await?;
let email = if !user.email.is_empty() {
user.email.clone()
} else if user.username.contains('@') {
// Operator bootstraps users with email-as-username — accept that.
user.username.clone()
} else {
return Err(ApiError::BadRequest(
"user has no email address on file".into(),
));
};
if let Some(code) = req
.verification_code
.as_deref()
.filter(|s| !s.is_empty())
{
// Leg 2: verify.
let supplied_hash = sodiumoxide::crypto::hash::sha256::hash(code.as_bytes())
.as_ref()
.to_vec();
let ok = state
.db
.email_code_verify(&email, &supplied_hash)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if !ok {
return Err(ApiError::BadCredentials);
}
if user.status == 0 {
return Err(ApiError::AccountDisabled);
}
if user.status == -1 {
return Err(ApiError::Unverified);
}
return issue_session(&state, &req, &user).await;
}
// Leg 1: mint + send a fresh code.
let (code, code_hash) = email::mint_code();
state
.db
.email_code_create(&email, &code_hash, EMAIL_CODE_TTL_SECS)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if let Err(e) = email::send_login_code(state.cfg.email.as_ref(), &email, &code).await {
return Err(ApiError::Internal(e));
}
Ok(Json(json!({ "type": "email_check" })))
}
async fn resolve_user_by_identifier(
state: &AppState,
identifier: &str,
) -> Result<UserRow, ApiError> {
if identifier.contains('@') {
if let Some(u) = state
.db
.user_find_by_email(identifier)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
{
return Ok(u);
}
}
state
.db
.user_find_by_username(identifier)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::BadCredentials)
}
async fn login_account(
state: Arc<AppState>,
req: LoginRequest,
) -> Result<Json<Value>, ApiError> {
let username = req
.username
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| ApiError::BadRequest("username required".into()))?;
let password = req
.password
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| ApiError::BadRequest("password required".into()))?;
let user = state
.db
.user_find_by_username(username)
.await?
.ok_or(ApiError::BadCredentials)?;
let ok = verify_password(user.password_hash.clone(), password.to_string()).await?;
if !ok {
return Err(ApiError::BadCredentials);
}
if user.status == 0 {
return Err(ApiError::AccountDisabled);
}
if user.status == -1 {
return Err(ApiError::Unverified);
}
// 2FA gate: if the user has TOTP enrolled, mint a short-lived nonce and
// tell the client we want the TOTP code in a follow-up POST. The client
// echoes the nonce back as `secret`.
//
// Wire shape matches the Flutter client's expectations
// (flutter/lib/common/widgets/login.dart:485): the outer `type` is the
// generic `email_check` envelope (the dialog the client opens for any
// second-leg challenge), and `tfa_type` distinguishes TOTP (`tfa_check`)
// from email (`email_check`). Returning `type:"tfa_check"` directly
// would miss the switch's only branch and surface as the unhelpful
// "bad response from server" toast.
if state.db.totp_get_secret(user.id).await?.is_some() {
let nonce = state
.db
.tfa_challenge_create(user.id, TFA_CHALLENGE_TTL_SECS)
.await?;
return Ok(Json(json!({
"type": "email_check",
"tfa_type": "tfa_check",
"secret": nonce,
})));
}
issue_session(&state, &req, &user).await
}
async fn login_tfa_code(
state: Arc<AppState>,
req: LoginRequest,
) -> Result<Json<Value>, ApiError> {
let nonce = req
.secret
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| ApiError::BadRequest("secret required".into()))?;
let code = req
.tfa_code
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| ApiError::BadRequest("tfaCode required".into()))?;
let user_id = state
.db
.tfa_challenge_lookup(nonce)
.await?
.ok_or_else(|| ApiError::BadRequest("invalid or expired challenge".into()))?;
let secret_b32 = state
.db
.totp_get_secret(user_id)
.await?
.ok_or_else(|| ApiError::BadRequest("TOTP not enrolled".into()))?;
if !verify_totp(&secret_b32, code)? {
// Leave the challenge row alive — operators may want short retries.
return Err(ApiError::BadCredentials);
}
state.db.tfa_challenge_consume(nonce).await?;
let user = state
.db
.user_find_by_id(user_id)
.await?
.ok_or(ApiError::Unauthorized)?;
issue_session(&state, &req, &user).await
}
/// Build and persist a fresh access token, claim the calling device, and
/// return the standard logged-in response shape. Shared by the password,
/// post-TOTP, post-email-code, and (later) post-OIDC paths.
async fn issue_session(
state: &AppState,
req: &LoginRequest,
user: &UserRow,
) -> Result<Json<Value>, ApiError> {
let token = mint_token();
let sha = sha256_token(&token);
let device_info_json = req
.device_info
.as_ref()
.map(|v| v.to_string())
.unwrap_or_default();
state
.db
.token_insert(
user.id,
&sha,
req.id.as_deref().unwrap_or_default(),
req.uuid.as_deref().unwrap_or_default(),
&device_info_json,
state.cfg.session_ttl_secs,
)
.await?;
// Bind the calling device to this user so /api/peers shows it correctly.
state
.db
.device_claim(
user.id,
req.id.as_deref().unwrap_or_default(),
req.uuid.as_deref().unwrap_or_default(),
)
.await;
Ok(Json(json!({
"access_token": token,
"type": "access_token",
"user": UserPayload::from(user),
})))
}
pub(crate) fn verify_totp(secret_b32: &str, code: &str) -> Result<bool, ApiError> {
let secret = Secret::Encoded(secret_b32.to_string())
.to_bytes()
.map_err(|e| ApiError::Internal(format!("bad TOTP secret: {:?}", e)))?;
let totp = TOTP::new(Algorithm::SHA1, 6, 1, 30, secret)
.map_err(|e| ApiError::Internal(format!("TOTP init: {}", e)))?;
totp.check_current(code)
.map_err(|e| ApiError::Internal(format!("TOTP check: {}", e)))
}
pub async fn current_user(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
// Body is required by the client but its content is purely advisory.
Json(_body): Json<IdUuidBody>,
) -> Result<Json<UserPayload>, ApiError> {
let row = state
.db
.user_find_by_id(user.user_id)
.await?
.ok_or(ApiError::Unauthorized)?;
Ok(Json(UserPayload::from(&row)))
}
pub async fn logout(
Extension(state): Extension<Arc<AppState>>,
headers: axum::http::HeaderMap,
Json(_body): Json<IdUuidBody>,
) -> StatusCode {
// Best-effort: parse the bearer ourselves so a missing/invalid token still
// returns 200 (matches the client's fire-and-forget logout flow).
if let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) {
if let Ok(s) = auth.to_str() {
if let Some(tok) = s.strip_prefix("Bearer ").map(str::trim) {
if !tok.is_empty() {
let sha = sha256_token(tok);
let _ = state.db.token_delete(&sha).await;
}
}
}
}
StatusCode::OK
}
pub(crate) fn mint_token() -> String {
let bytes = sodiumoxide::randombytes::randombytes(32);
base64::encode_config(bytes, base64::URL_SAFE_NO_PAD)
}
+280
View File
@@ -0,0 +1,280 @@
//! Ed25519-signature gate for the agent-facing HTTP API
//! (`/api/heartbeat`, `/api/sysinfo`).
//!
//! Trust root: the device's Ed25519 public key is already written into
//! `peer.pk` during the rendezvous `RegisterPk` handshake (TCP/protobuf,
//! port 21116). That handshake proves possession of the matching private key
//! to the rendezvous server — so any later HTTP request signed by the same
//! key is provably from the same device.
//!
//! Cutover: per-peer. `peer.managed = 0` (default) keeps stock-client
//! behaviour — no signature required. `managed = 1` requires a valid sig on
//! every request. The flag flips from 0→1 on the first valid signature we
//! observe (TOFU) or via the admin endpoint. It never flips back from a
//! request — only an admin can downgrade.
//!
//! Wire format (both headers required on signed requests):
//! X-RD-Device-Id: <id>
//! X-RD-Signature: v1.<unix_ts>.<base64(ed25519_sig)>
//! where the signed message is:
//! "rd-api-v1\n" || METHOD || "\n" || PATH || "\n" || TS || "\n" || sha256(body)
use crate::api::error::ApiError;
use crate::api::state::AppState;
use axum::http::HeaderMap;
use lazy_static::lazy_static;
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::Arc;
const SIG_VERSION: &str = "v1";
const HEADER_ID: &str = "x-rd-device-id";
const HEADER_SIG: &str = "x-rd-signature";
const SKEW_TOLERANCE_SECS: i64 = 300;
const REPLAY_WINDOW_SECS: i64 = 600;
const REPLAY_CACHE_MAX: usize = 16_384;
/// Outcome of running the gate. The handler uses this to decide which `id`
/// to trust as the device identity:
/// - `Verified` → caller is cryptographically that device.
/// - `LegacyUnsigned` → managed=0 peer that sent no sig headers; the
/// handler may proceed but the body `id` is trusted only weakly
/// (same risk as today). The handler still calls `get_peer` to confirm
/// the id is known.
pub enum AuthOutcome {
Verified { id: String },
LegacyUnsigned,
}
lazy_static! {
/// Replay cache. Key: "<id>|<ts>|<sig_first32>". Value: expiry unix ts.
/// Small enough that the sweep-on-insert cost is negligible.
static ref REPLAY: Mutex<HashMap<String, i64>> = Mutex::new(HashMap::new());
}
pub async fn verify(
state: &Arc<AppState>,
method: &str,
path: &str,
headers: &HeaderMap,
body: &[u8],
) -> Result<AuthOutcome, ApiError> {
let sig_hdr = headers.get(HEADER_SIG).and_then(|v| v.to_str().ok());
let id_hdr = headers.get(HEADER_ID).and_then(|v| v.to_str().ok());
// No signature headers at all → legacy path. Even then we still need to
// check that the peer (if it claims an id in the body) isn't marked
// `managed=1`. The handler doesn't know the body id yet, so we defer
// the managed-check to a second call (`enforce_managed_for_id`) after
// the handler has parsed the body. Returning LegacyUnsigned here just
// means "no sig present, you must call enforce_managed_for_id next".
let (sig_hdr, id_hdr) = match (sig_hdr, id_hdr) {
(Some(s), Some(i)) if !s.is_empty() && !i.is_empty() => (s, i),
(None, None) => return Ok(AuthOutcome::LegacyUnsigned),
// Partial headers: someone tried to sign but messed up the request.
// Don't fall through to legacy — treat as an outright failure so we
// don't silently downgrade a misconfigured agent.
_ => {
hbb_common::log::warn!(
"signed API {}: partial headers (id={:?}, sig_present={})",
path,
id_hdr,
sig_hdr.map(|s| !s.is_empty()).unwrap_or(false),
);
return Err(ApiError::Unauthorized);
}
};
// Parse "v1.<ts>.<b64>".
let mut parts = sig_hdr.splitn(3, '.');
let ver = parts.next().unwrap_or("");
let ts_s = parts.next().unwrap_or("");
let sig_b64 = parts.next().unwrap_or("");
if ver != SIG_VERSION || ts_s.is_empty() || sig_b64.is_empty() {
hbb_common::log::warn!(
"signed API {} from {}: malformed signature header (ver={:?})",
path, id_hdr, ver,
);
return Err(ApiError::Unauthorized);
}
let ts: i64 = match ts_s.parse() {
Ok(v) => v,
Err(_) => {
hbb_common::log::warn!(
"signed API {} from {}: bad timestamp {:?}",
path, id_hdr, ts_s,
);
return Err(ApiError::Unauthorized);
}
};
let now = chrono::Utc::now().timestamp();
if (now - ts).abs() > SKEW_TOLERANCE_SECS {
hbb_common::log::warn!(
"signed API {} from {}: clock skew {}s exceeds {}s tolerance",
path, id_hdr, (now - ts).abs(), SKEW_TOLERANCE_SECS,
);
return Err(ApiError::Unauthorized);
}
let sig_bytes = match base64::decode(sig_b64) {
Ok(b) => b,
Err(e) => {
hbb_common::log::warn!(
"signed API {} from {}: base64 decode failed: {}",
path, id_hdr, e,
);
return Err(ApiError::Unauthorized);
}
};
// Replay check before the expensive crypto. The (id, ts, sig-prefix)
// tuple is unique per request from a non-broken agent.
let replay_key = {
let prefix: String = sig_b64.chars().take(32).collect();
format!("{}|{}|{}", id_hdr, ts, prefix)
};
{
let mut cache = REPLAY.lock().unwrap();
cache.retain(|_, exp| *exp > now);
if cache.contains_key(&replay_key) {
hbb_common::log::warn!(
"signed API {} from {}: replay rejected",
path, id_hdr,
);
return Err(ApiError::Unauthorized);
}
if cache.len() < REPLAY_CACHE_MAX {
cache.insert(replay_key, now + REPLAY_WINDOW_SECS);
}
// If the cache is full we accept (no DoS via cache exhaustion). The
// 5-min skew window already bounds replay risk.
}
// Look up the peer's pk and managed flag in one query.
let row = state
.db
.peer_get_auth(id_hdr)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let (pk_bytes, managed) = match row {
Some(v) => v,
None => {
// Early-boot race: the agent generates its keypair and starts
// signing API requests before its `--server` child has done
// the rendezvous RegisterPk handshake that creates the peer
// row. Returning Unauthorized here would leave brand-new
// agents stuck — the retry loop is designed around the
// ID_NOT_FOUND response from the handler, not a hard auth
// failure. Fall through to legacy so the handler can answer
// ID_NOT_FOUND; the next retry after RegisterPk completes
// will validate normally and TOFU-promote.
hbb_common::log::debug!(
"signed API request for unregistered peer {} — pre-rendezvous race, \
deferring to legacy path",
id_hdr,
);
return Ok(AuthOutcome::LegacyUnsigned);
}
};
if pk_bytes.is_empty() {
// Peer row exists (rendezvous touched it) but no PK yet — same
// race as above, mid-handshake. Defer to legacy; the handler's
// `enforce_managed_for_id` still protects this peer if it was
// somehow flagged managed=1 with no pk.
hbb_common::log::debug!(
"signed API request for peer {} with empty pk — deferring to legacy path",
id_hdr,
);
return Ok(AuthOutcome::LegacyUnsigned);
}
// Build the canonical signed message:
// "rd-api-v1\n" || METHOD || "\n" || PATH || "\n" || TS || "\n" || sha256(body)
let body_sha = sodiumoxide::crypto::hash::sha256::hash(body);
let mut msg = Vec::with_capacity(64 + method.len() + path.len());
msg.extend_from_slice(b"rd-api-v1\n");
msg.extend_from_slice(method.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(path.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(ts_s.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(body_sha.as_ref());
let pk = match sodiumoxide::crypto::sign::PublicKey::from_slice(&pk_bytes) {
Some(p) => p,
None => {
hbb_common::log::warn!(
"signed API {} from {}: stored pk ({}B) is not a valid Ed25519 public key",
path, id_hdr, pk_bytes.len(),
);
return Err(ApiError::Unauthorized);
}
};
let sig = match sodiumoxide::crypto::sign::Signature::from_bytes(&sig_bytes) {
Ok(s) => s,
Err(_) => {
hbb_common::log::warn!(
"signed API {} from {}: signature length {} is not the Ed25519 size",
path, id_hdr, sig_bytes.len(),
);
return Err(ApiError::Unauthorized);
}
};
if !sodiumoxide::crypto::sign::verify_detached(&sig, &msg, &pk) {
// The agent's keypair doesn't match the pk stored in `peer`.
// Usually this means a config was wiped/regenerated on the agent
// side without the server's row being cleared — the next
// successful RegisterPk handshake will fix it.
hbb_common::log::warn!(
"signed API {} from {}: signature does NOT verify against stored pk \
(agent's keypair differs from the one rendezvous registered) \
managed={}",
path, id_hdr, managed,
);
return Err(ApiError::Unauthorized);
}
// TOFU promote: first valid sig flips managed=0 → 1. After this, the
// same device can no longer fall back to the legacy unsigned path.
if !managed {
if let Err(e) = state.db.peer_set_managed(id_hdr, true).await {
hbb_common::log::warn!("peer_set_managed({}) failed: {}", id_hdr, e);
// Don't fail the request — the sig was valid, the promote is
// best-effort. Next request will retry the promote.
} else {
hbb_common::log::info!("peer {} TOFU-promoted to managed=1", id_hdr);
}
}
Ok(AuthOutcome::Verified {
id: id_hdr.to_string(),
})
}
/// Called by handlers AFTER they've parsed the body and extracted the
/// device id. Only meaningful when `verify` returned `LegacyUnsigned`.
/// Enforces: if the peer is currently managed=1, an unsigned request for
/// that id must be rejected.
pub async fn enforce_managed_for_id(
state: &Arc<AppState>,
id: &str,
) -> Result<(), ApiError> {
if id.is_empty() {
return Ok(());
}
let row = state
.db
.peer_get_auth(id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
match row {
Some((_, true)) => {
hbb_common::log::warn!(
"rejecting unsigned API request for managed peer {}",
id,
);
Err(ApiError::Unauthorized)
}
_ => Ok(()),
}
}
+198
View File
@@ -0,0 +1,198 @@
//! `POST /api/devices/cli` — used by `rustdesk --assign --token <T> ...`
//! to enroll a freshly installed device into a tenant slot.
//!
//! Per CONSOLE_API.md §11: bearer-authenticated; the response body is plain
//! text (empty = success, non-empty = informational message). The client
//! prints "Done!" when the body is empty.
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use crate::database::AbPeerInsert;
use axum::extract::Extension;
use axum::http::header;
use axum::response::IntoResponse;
use axum::Json;
use serde::Deserialize;
use serde_json::Value;
use std::sync::Arc;
#[derive(Debug, Deserialize)]
pub struct AssignBody {
pub id: String,
pub uuid: String,
#[serde(default)]
pub user_name: Option<String>,
#[serde(default)]
pub strategy_name: Option<String>,
#[serde(default)]
pub address_book_name: Option<String>,
#[serde(default)]
pub address_book_tag: Option<String>,
#[serde(default)]
pub address_book_alias: Option<String>,
#[serde(default)]
pub address_book_password: Option<String>,
#[serde(default)]
pub address_book_note: Option<String>,
#[serde(default)]
pub device_group_name: Option<String>,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub device_username: Option<String>,
#[serde(default)]
pub device_name: Option<String>,
}
pub async fn assign(
Extension(state): Extension<Arc<AppState>>,
caller: AuthedUser,
Json(body): Json<AssignBody>,
) -> Result<impl IntoResponse, ApiError> {
if body.id.is_empty() || body.uuid.is_empty() {
return Err(ApiError::BadRequest("id and uuid required".into()));
}
let mut warnings: Vec<String> = vec![];
// Resolve owner. If --user_name was supplied, that's the owner; otherwise
// the caller becomes the owner (matches `rustdesk --assign` flows where
// the operator's account is the destination).
let owner = if let Some(name) = body.user_name.as_deref().filter(|s| !s.is_empty()) {
if !caller.is_admin {
return Err(ApiError::Forbidden(
"admin required to assign to another user".into(),
));
}
match state
.db
.user_find_by_username(name)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
{
Some(u) => u,
None => {
return Err(ApiError::BadRequest(format!(
"no such user: {}",
name
)));
}
}
} else {
state
.db
.user_find_by_id(caller.user_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::Unauthorized)?
};
// Bind the device to the owner (mirrors what /api/login's device_claim
// does, but here it's an admin operation rather than user-initiated).
state.db.device_claim(owner.id, &body.id, &body.uuid).await;
// Address-book entry. We always target the *owner's* personal AB.
if let Some(ab_name) = body.address_book_name.as_deref().filter(|s| !s.is_empty()) {
let _ = ab_name; // M2's get_or_create_personal ignores the name; OSS has one personal AB per user.
let ab_guid = state
.db
.ab_get_or_create_personal(owner.id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let tags: Option<Vec<String>> = body
.address_book_tag
.as_deref()
.filter(|s| !s.is_empty())
.map(|t| t.split(',').map(|s| s.trim().to_string()).collect());
if let Err(e) = state
.db
.ab_peer_insert(
&ab_guid,
AbPeerInsert {
id: &body.id,
alias: body.address_book_alias.as_deref(),
note: body.address_book_note.as_deref(),
password: body.address_book_password.as_deref(),
hash: None,
username: body.device_username.as_deref(),
hostname: body.device_name.as_deref(),
platform: None,
},
tags.as_deref(),
)
.await
{
// Likely a UNIQUE conflict if the peer is already in the AB;
// surface as a warning rather than failing the whole call.
warnings.push(format!("address-book entry not added: {}", e));
}
}
// Strategy assignment by name. We attach to the device directly (peer-scoped),
// which is the most-specific tier in our resolver.
if let Some(name) = body.strategy_name.as_deref().filter(|s| !s.is_empty()) {
match resolve_strategy_id(&state, name).await? {
Some(strategy_id) => {
if let Err(e) = state
.db
.strategy_assign_peer(strategy_id, &body.id)
.await
{
warnings.push(format!("strategy assignment failed: {}", e));
}
}
None => {
warnings.push(format!("strategy {:?} does not exist", name));
}
}
}
// Device-group membership: ensure the group exists, ensure the owner is a
// member. We treat the group name as the natural key per the M2 schema.
if let Some(group_name) = body.device_group_name.as_deref().filter(|s| !s.is_empty()) {
if let Err(e) = state
.db
.device_group_ensure_member(group_name, owner.id)
.await
{
warnings.push(format!("device-group assignment failed: {}", e));
}
}
// Fields we accept but don't currently persist as discrete columns. These
// travel with the next sysinfo upload anyway (note, device_username,
// device_name end up in `device_sysinfo.payload` JSON).
if body.note.as_deref().map(|s| !s.is_empty()).unwrap_or(false) {
warnings.push(
"--note is currently surfaced via sysinfo only, not persisted as a discrete field"
.into(),
);
}
let body_text = if warnings.is_empty() {
String::new()
} else {
warnings.join("\n")
};
Ok((
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
body_text,
))
}
async fn resolve_strategy_id(
state: &AppState,
name: &str,
) -> Result<Option<i64>, ApiError> {
state
.db
.strategy_find_by_name(name)
.await
.map_err(|e| ApiError::Internal(e.to_string()))
}
/// Wrap the `Value` JSON the request _could_ have under `Json<Value>` if a
/// future variation needs it. Currently unused; kept for symmetry with other
/// modules that work with raw JSON in/out.
#[allow(dead_code)]
fn ignore_value(_v: Value) {}
+80
View File
@@ -0,0 +1,80 @@
//! SMTP transport for email-code login. Two modes:
//!
//! - **Production:** `--smtp-host` (and friends) configured → real SMTP via
//! `lettre` with optional STARTTLS + auth.
//! - **Dev:** `--smtp-host` empty → the code is logged to stdout instead.
//! This makes the round-trip testable without standing up a mail server.
use crate::api::state::EmailConfig;
use hbb_common::log;
use lettre::message::header::ContentType;
use lettre::transport::smtp::authentication::Credentials;
use lettre::transport::smtp::AsyncSmtpTransport;
use lettre::{AsyncTransport, Message, Tokio1Executor};
pub async fn send_login_code(
cfg: Option<&EmailConfig>,
to: &str,
code: &str,
) -> Result<(), String> {
if to.is_empty() {
return Err("recipient address is empty".into());
}
let Some(cfg) = cfg else {
// Dev mode: surface the code so the operator can complete the flow.
log::info!("[email-code] login code for <{}>: {}", to, code);
return Ok(());
};
let body = format!(
"Your login code is: {}\n\nIt expires in 10 minutes.\nIf you didn't request this, ignore this email.\n",
code
);
let message = Message::builder()
.from(
cfg.from
.parse()
.map_err(|e| format!("invalid From address {:?}: {}", cfg.from, e))?,
)
.to(to.parse().map_err(|e| format!("invalid To address {:?}: {}", to, e))?)
.subject("Your RustDesk login code")
.header(ContentType::TEXT_PLAIN)
.body(body)
.map_err(|e| format!("compose: {}", e))?;
let mut builder = if cfg.starttls {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.host)
.map_err(|e| format!("STARTTLS init for {}: {}", cfg.host, e))?
} else {
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.host)
}
.port(cfg.port);
if let (Some(user), Some(pass)) = (cfg.username.as_deref(), cfg.password.as_deref()) {
builder = builder.credentials(Credentials::new(user.to_string(), pass.to_string()));
}
let transport = builder.build();
transport
.send(message)
.await
.map_err(|e| format!("smtp send to {}: {}", cfg.host, e))?;
log::info!("[email-code] code mailed to <{}>", to);
Ok(())
}
/// Generate a 6-digit numeric code with cryptographic entropy. Returns the
/// code as a string and its sha256 for storage.
pub fn mint_code() -> (String, Vec<u8>) {
// Sample 4 random bytes, fold into 0..1_000_000, format as 6-digit
// zero-padded decimal. 24 bits of entropy is plenty for a 10-minute
// 5-attempt-limit code.
let bytes = sodiumoxide::randombytes::randombytes(4);
let mut n: u32 = 0;
for b in &bytes {
n = (n << 8) | (*b as u32);
}
let n = n % 1_000_000;
let code = format!("{:06}", n);
let hash = sodiumoxide::crypto::hash::sha256::hash(code.as_bytes())
.as_ref()
.to_vec();
(code, hash)
}
+55
View File
@@ -0,0 +1,55 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
/// Single error type for the management API. Always serializes to
/// `{"error":"..."}` per the protocol spec; the HTTP status is chosen so the
/// client behaves correctly:
///
/// - 401 Unauthorized clears the local access_token (intentional fallback in
/// the Flutter client — see CONSOLE_API.md §3.6).
/// - 200 OK + JSON `error` for business failures (bad creds, validation).
/// Most non-auth handlers should return BadRequest or Conflict instead so
/// the operator can distinguish them in logs.
#[derive(Debug)]
pub enum ApiError {
Unauthorized,
BadCredentials,
AccountDisabled,
Unverified,
Forbidden(String),
NotFound,
BadRequest(String),
Internal(String),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, msg) = match self {
ApiError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".to_string()),
ApiError::BadCredentials => (StatusCode::UNAUTHORIZED, "bad credentials".to_string()),
ApiError::AccountDisabled => (StatusCode::FORBIDDEN, "account disabled".to_string()),
ApiError::Unverified => (StatusCode::FORBIDDEN, "unverified".to_string()),
// Returning HTTP 200 + {"error": ...} for share-rule rejections.
// Flutter's _jsonDecodeActionResp at ab_model.dart:2002 surfaces
// the JSON `error` field as a toast and stays signed-in; using
// 403 here would trigger the global 401/403 logout path and yank
// the user's session.
ApiError::Forbidden(m) => (StatusCode::OK, m),
ApiError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
ApiError::Internal(m) => {
hbb_common::log::error!("api internal error: {}", m);
(StatusCode::OK, "internal error".to_string())
}
};
(status, Json(json!({ "error": msg }))).into_response()
}
}
impl From<hbb_common::anyhow::Error> for ApiError {
fn from(e: hbb_common::anyhow::Error) -> Self {
ApiError::Internal(e.to_string())
}
}
+37
View File
@@ -0,0 +1,37 @@
//! `GET /api/device-group/accessible` — paginated list of device groups the
//! caller is a member of (admin sees all). The Flutter client at
//! flutter/lib/models/group_model.dart:103 silently tolerates errors here, so
//! we keep the behavior tight: empty list when no groups exist, never panic.
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::pagination::{Page, PageQuery};
use crate::api::state::AppState;
use axum::extract::{Extension, Query};
use axum::Json;
use serde::Serialize;
use std::sync::Arc;
#[derive(Debug, Serialize)]
pub struct DeviceGroupOut {
pub name: String,
}
pub async fn accessible(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Query(q): Query<PageQuery>,
) -> Result<Json<Page<DeviceGroupOut>>, ApiError> {
let (total, rows) = state
.db
.groups_list_for_user(user.user_id, user.is_admin, q.offset(), q.limit())
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(Page {
total,
data: rows
.into_iter()
.map(|g| DeviceGroupOut { name: g.name })
.collect(),
}))
}
+175
View File
@@ -0,0 +1,175 @@
//! `POST /api/heartbeat` — the agent management loop. The client sends every
//! ~15 s (3 s when active connections exist). The reply may carry, in any
//! combination:
//! - `sysinfo: true` — force the client to re-upload sysinfo immediately,
//! - `disconnect: [conn_id, ...]` — tell the client to drop those sessions,
//! - `modified_at` + `strategy` — push a config-options merge,
//! - `exec: [{cmd_id, script, max_secs, max_bytes}, ...]` — PowerShell
//! commands queued from the admin UI. The agent runs each and POSTs
//! results to `/api/agent/exec-result`. See docs/AGENT-API-AUTH.md.
//!
//! Auth: signed agents (peer.managed=1) must carry `X-RD-Device-Id` +
//! `X-RD-Signature` headers — see `device_auth::verify`. Stock clients
//! (peer.managed=0) keep posting unsigned bodies; the first valid sig we
//! see flips the peer to managed=1 (TOFU).
use crate::api::device_auth::{self, AuthOutcome};
use crate::api::error::ApiError;
use crate::api::state::AppState;
use crate::api::strategy;
use axum::body::Bytes;
use axum::extract::Extension;
use axum::http::HeaderMap;
use axum::Json;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc;
#[derive(Debug, Deserialize)]
pub struct HeartbeatBody {
#[serde(default)]
pub id: String,
#[serde(default)]
pub uuid: String,
#[serde(default)]
pub ver: i64,
#[serde(default)]
pub conns: Option<Vec<i64>>,
#[serde(default)]
pub modified_at: i64,
}
#[derive(Debug, Serialize)]
pub struct HeartbeatResp {
/// Present-and-truthy → client re-uploads sysinfo immediately.
#[serde(skip_serializing_if = "Option::is_none")]
pub sysinfo: Option<bool>,
/// Conn IDs the client should drop. Always present (possibly empty).
pub disconnect: Vec<i64>,
/// Strategy version. Echoed back by the client; when it changes, the
/// client re-merges `strategy.config_options` into local config.
pub modified_at: i64,
pub strategy: Value,
/// PowerShell commands queued for this peer. Omitted from the JSON
/// reply when empty so vanilla rustdesk clients (which don't parse
/// this field) see a payload that's byte-for-byte identical to what
/// they received before this feature shipped.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub exec: Vec<ExecRequest>,
}
/// What the agent receives per queued PowerShell command. Caps live on the
/// server so the operator can tune fleet-wide without redeploying agents.
#[derive(Debug, Serialize)]
pub struct ExecRequest {
pub cmd_id: String,
pub script: String,
pub max_secs: u64,
pub max_bytes: u64,
}
/// Wall-clock ceiling on a single PowerShell exec. Server-side cap; the
/// agent kills the process when the deadline elapses and reports
/// `timed_out=true` to `/api/agent/exec-result`.
const EXEC_MAX_SECS: u64 = 300;
/// Combined stdout+stderr byte ceiling. Past this the agent stops
/// appending and sets `truncated=true`. 1 MiB matches the cap surfaced
/// in the admin UI confirm dialog.
const EXEC_MAX_BYTES: u64 = 1024 * 1024;
pub async fn heartbeat(
Extension(state): Extension<Arc<AppState>>,
headers: HeaderMap,
raw: Bytes,
) -> Result<Json<HeartbeatResp>, ApiError> {
let outcome = device_auth::verify(&state, "POST", "/api/heartbeat", &headers, &raw).await?;
let body: HeartbeatBody = serde_json::from_slice(&raw)
.map_err(|_| ApiError::BadRequest("invalid json".into()))?;
if body.id.is_empty() || body.uuid.is_empty() {
return Err(ApiError::BadRequest("id and uuid required".into()));
}
match outcome {
AuthOutcome::Verified { id: signed_id } => {
if body.id != signed_id {
return Err(ApiError::Unauthorized);
}
}
AuthOutcome::LegacyUnsigned => {
device_auth::enforce_managed_for_id(&state, &body.id).await?;
}
}
let conns_json = serde_json::to_string(&body.conns.unwrap_or_default())
.unwrap_or_else(|_| "[]".into());
let needs_sysinfo = state
.db
.sysinfo_heartbeat(
&body.id,
&body.uuid,
body.ver,
&conns_json,
&state.cfg.sysinfo_ver,
)
.await?;
// One-shot operator commands queued for this peer (force-disconnect,
// force-sysinfo). Read-and-delete in one transaction.
let mut disconnect: Vec<i64> = vec![];
let mut force_sysinfo = needs_sysinfo;
for cmd in state
.db
.heartbeat_pop_commands(&body.id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
{
match cmd.kind.as_str() {
"disconnect" => {
if let Some(payload) = cmd.payload {
if let Ok(arr) = serde_json::from_str::<Vec<i64>>(&payload) {
disconnect.extend(arr);
}
}
}
"sysinfo" => force_sysinfo = true,
other => hbb_common::log::warn!("unknown heartbeat_command kind {:?}", other),
}
}
// Strategy resolution (peer > device-group > user, highest priority wins).
let (modified_at, strategy) = strategy::resolve_for(&state, &body.id).await;
// Pop any queued PowerShell exec commands for this peer and flip them
// to 'running' in one transaction. The handler doesn't re-check the
// strategy gate here — that was done at dispatch time. By the time a
// row reaches 'queued' status, the operator has already passed all
// checks; if the strategy changed since dispatch, the rows in flight
// still ride out their lifecycle (an admin can flip the per-row state
// via the dashboard if they need to abort, but that's a separate UI
// surface — see future work in AGENT-API-AUTH.md).
let exec = state
.db
.exec_pop_queued_for_peer(&body.id)
.await
.map(|rows| {
rows.into_iter()
.map(|r| ExecRequest {
cmd_id: r.cmd_id,
script: r.script,
max_secs: EXEC_MAX_SECS,
max_bytes: EXEC_MAX_BYTES,
})
.collect::<Vec<_>>()
})
.unwrap_or_else(|e| {
hbb_common::log::warn!("exec_pop_queued_for_peer({}) failed: {}", body.id, e);
Vec::new()
});
Ok(Json(HeartbeatResp {
sysinfo: if force_sysinfo { Some(true) } else { None },
disconnect,
modified_at,
strategy,
exec,
}))
}
+162
View File
@@ -0,0 +1,162 @@
//! TCP-over-rendezvous HTTP fallback. The client wraps any `/api/*` request
//! in an `HttpProxyRequest` protobuf and ships it over the rendezvous TCP
//! connection (already encrypted via secure_tcp) when
//! `OPTION_USE_RAW_TCP_FOR_API=Y`. We dispatch the wrapped request through
//! the **same** axum `Router` the HTTPS listener uses, so every existing
//! handler — auth, AB, audit, OIDC, … — is reachable through this path
//! with zero per-route plumbing.
use axum::body::Body;
use axum::Router;
use hbb_common::log;
use hbb_common::rendezvous_proto::{HeaderEntry, HttpProxyRequest, HttpProxyResponse};
use http::header::{HeaderMap, HeaderName, HeaderValue};
use http::{Method, Request};
use once_cell::sync::Lazy;
use std::convert::TryFrom;
use std::sync::Mutex;
use tower::ServiceExt;
/// Shared router. Populated by [`api::serve`] before the HTTPS listener
/// starts, so that the rendezvous TCP path can reach the same handlers.
/// `Mutex` because `Router` isn't `Sync` even though it is `Send + Clone`;
/// we never hold the lock across an await — we clone out, drop the guard,
/// and call `oneshot` on the clone.
static ROUTER: Lazy<Mutex<Option<Router>>> = Lazy::new(|| Mutex::new(None));
pub fn install_router(r: Router) {
*ROUTER.lock().unwrap() = Some(r);
}
pub async fn dispatch(req: HttpProxyRequest) -> HttpProxyResponse {
let router = match ROUTER.lock().unwrap().as_ref() {
Some(r) => r.clone(),
None => return error_response(503, "router not initialized"),
};
let http_req = match build_request(&req) {
Ok(r) => r,
Err(msg) => return error_response(400, &msg),
};
let response = match router.oneshot(http_req).await {
Ok(r) => r,
Err(e) => {
log::warn!("http_proxy: router error: {}", e);
return error_response(500, &format!("router: {}", e));
}
};
let status = response.status().as_u16() as i32;
let headers = serialize_headers(response.headers());
let body = match collect_body(response.into_body()).await {
Ok(b) => b,
Err(msg) => return error_response(500, &msg),
};
HttpProxyResponse {
status,
headers,
body: body.into(),
error: String::new(),
..Default::default()
}
}
fn build_request(req: &HttpProxyRequest) -> Result<Request<Body>, String> {
let method = if req.method.is_empty() {
Method::GET
} else {
Method::try_from(req.method.as_bytes())
.map_err(|e| format!("invalid method {:?}: {}", req.method, e))?
};
let uri = if req.path.is_empty() {
"/".to_string()
} else if req.path.starts_with('/') {
req.path.clone()
} else {
format!("/{}", req.path)
};
let body_bytes: Vec<u8> = req.body.to_vec();
let mut builder = Request::builder().method(method).uri(uri);
let headers_map = builder
.headers_mut()
.ok_or_else(|| "request builder produced no headers map".to_string())?;
let mut saw_content_type = false;
for h in &req.headers {
if h.name.is_empty() {
continue;
}
let lower = h.name.to_ascii_lowercase();
// Drop hop-by-hop / framing headers we'll set ourselves to match
// the actual body length axum sees.
if matches!(
lower.as_str(),
"host" | "content-length" | "connection" | "transfer-encoding"
) {
continue;
}
if lower == "content-type" {
saw_content_type = true;
}
let name = HeaderName::try_from(h.name.as_bytes())
.map_err(|e| format!("bad header name {:?}: {}", h.name, e))?;
let value = HeaderValue::try_from(h.value.as_bytes())
.map_err(|e| format!("bad header value for {:?}: {}", h.name, e))?;
headers_map.append(name, value);
}
// Default to JSON if the client forgot — every /api/* handler expects
// JSON unless the route reads `body` as raw bytes (only /api/record),
// which doesn't care about content-type.
if !saw_content_type {
headers_map.insert(
HeaderName::from_static("content-type"),
HeaderValue::from_static("application/json"),
);
}
builder
.body(Body::from(body_bytes))
.map_err(|e| format!("build request: {}", e))
}
fn serialize_headers(map: &HeaderMap) -> Vec<HeaderEntry> {
map.iter()
.map(|(k, v)| HeaderEntry {
name: k.as_str().to_string(),
value: v.to_str().unwrap_or("").to_string(),
..Default::default()
})
.collect()
}
/// Collect any `http_body::Body` whose chunks are buffer-like into a `Vec<u8>`.
/// Works against both the request `Body` we build and axum's
/// `UnsyncBoxBody<Bytes, axum::Error>` response body.
async fn collect_body<B>(mut body: B) -> Result<Vec<u8>, String>
where
B: http_body::Body + Unpin,
B::Data: hbb_common::bytes::Buf,
B::Error: std::fmt::Display,
{
use hbb_common::bytes::Buf;
let mut buf = Vec::new();
while let Some(chunk) = body.data().await {
let mut chunk = chunk.map_err(|e| format!("body read: {}", e))?;
while chunk.has_remaining() {
let s = chunk.chunk();
buf.extend_from_slice(s);
let n = s.len();
chunk.advance(n);
}
}
Ok(buf)
}
fn error_response(status: i32, msg: &str) -> HttpProxyResponse {
HttpProxyResponse {
status,
body: msg.as_bytes().to_vec().into(),
error: msg.to_string(),
..Default::default()
}
}
+163
View File
@@ -0,0 +1,163 @@
//! `POST /api/agent/login-event` — agent-side reporting of user logon /
//! logoff events observed on the controlled machine. Surfaces a per-device
//! login history on the admin Devices detail page.
//!
//! Auth: same per-peer signed-API gate as `/api/sysinfo` /
//! `/api/heartbeat` / `/api/unattended-password` — see
//! [`crate::api::device_auth`]. Stock RustDesk doesn't post here at all,
//! so in practice every caller is a managed agent; we still keep the
//! `LegacyUnsigned → enforce_managed_for_id` path for symmetry with the
//! other agent endpoints.
//!
//! Body shape (events batched so an agent that was offline can catch up
//! on reconnect):
//!
//! ```json
//! {
//! "id": "<peer id>",
//! "uuid": "<peer uuid>",
//! "events": [
//! {
//! "at": 1717920000,
//! "kind": "logon", // or "logoff"
//! "username": "alice",
//! "domain": "CORP",
//! "session_id": 2,
//! "session_kind": "rdp" // or "console"
//! }
//! ]
//! }
//! ```
//!
//! Response: `"OK"` on success, `"ID_NOT_FOUND"` for an unregistered peer
//! (same shape as `/api/unattended-password` so the agent can use a single
//! retry helper for both).
use crate::api::device_auth::{self, AuthOutcome};
use crate::api::error::ApiError;
use crate::api::state::AppState;
use axum::body::Bytes;
use axum::extract::Extension;
use axum::http::HeaderMap;
use serde::Deserialize;
use std::sync::Arc;
#[derive(Debug, Deserialize)]
pub struct LoginEventIn {
pub at: i64,
pub kind: String,
#[serde(default)]
pub username: String,
#[serde(default)]
pub domain: String,
#[serde(default)]
pub session_id: i64,
#[serde(default)]
pub session_kind: String,
}
#[derive(Debug, Deserialize)]
pub struct LoginEventBody {
pub id: String,
pub uuid: String,
pub events: Vec<LoginEventIn>,
}
/// Cap per-request to bound DB cost from a misbehaving / catching-up agent.
const MAX_EVENTS_PER_POST: usize = 256;
pub async fn login_event(
Extension(state): Extension<Arc<AppState>>,
headers: HeaderMap,
body: Bytes,
) -> Result<String, ApiError> {
let outcome =
device_auth::verify(&state, "POST", "/api/agent/login-event", &headers, &body).await?;
let payload: LoginEventBody = serde_json::from_slice(&body)
.map_err(|_| ApiError::BadRequest("invalid json".into()))?;
if payload.id.is_empty() || payload.uuid.is_empty() {
return Err(ApiError::BadRequest("id and uuid are required".into()));
}
if payload.events.is_empty() {
return Ok("OK".to_string());
}
if payload.events.len() > MAX_EVENTS_PER_POST {
return Err(ApiError::BadRequest(format!(
"too many events in one POST (max {MAX_EVENTS_PER_POST})"
)));
}
// Bind the trusted identity to the body. Same rule as the other agent
// endpoints: signed → header id must equal body id; unsigned → peer
// must not be `managed=1`.
let id = match outcome {
AuthOutcome::Verified { id: signed_id } => {
if payload.id != signed_id {
return Err(ApiError::Unauthorized);
}
signed_id
}
AuthOutcome::LegacyUnsigned => {
device_auth::enforce_managed_for_id(&state, &payload.id).await?;
payload.id.clone()
}
};
let peer = state
.db
.get_peer(&id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if peer.is_none() {
// Same shape as /api/unattended-password — agent treats this as
// "retry later, rendezvous hasn't registered me yet".
return Ok("ID_NOT_FOUND".to_string());
}
let mut accepted = 0usize;
for ev in &payload.events {
let kind = ev.kind.trim();
if kind != "logon" && kind != "logoff" {
// Unknown kinds are ignored rather than 400ing the whole batch;
// a future agent build that adds e.g. "lock" should be able to
// post a mixed batch against an older server without losing
// the known-kind rows.
continue;
}
if let Err(e) = state
.db
.login_event_insert(
&id,
&payload.uuid,
ev.at,
kind,
ev.username.trim(),
ev.domain.trim(),
ev.session_id,
ev.session_kind.trim(),
)
.await
{
// Don't fail the whole batch on a single insert error — the
// agent's retry loop will resend the events that didn't land,
// and we'd rather record what we can than reject everything.
hbb_common::log::warn!(
"login_event_insert for peer {} failed: {}",
id,
e
);
continue;
}
accepted += 1;
}
hbb_common::log::debug!(
"login-event: peer={} accepted={}/{}",
id,
accepted,
payload.events.len()
);
Ok("OK".to_string())
}
+183
View File
@@ -0,0 +1,183 @@
//! `POST /api/agent/metrics` — continuous performance time-series the
//! agent samples at ~1/min. The admin Devices detail page renders this
//! as a CPU / memory sparkline plus a "current snapshot" card.
//!
//! Auth: same per-peer signed-API gate as the other agent endpoints —
//! see [`crate::api::device_auth`]. Body shape (batched so an agent
//! that's catching up after a transport outage can land everything in
//! one POST):
//!
//! ```json
//! {
//! "id": "<peer id>",
//! "uuid": "<peer uuid>",
//! "samples": [
//! {
//! "at": 1717920000,
//! "cpu_pct": 42.5,
//! "mem_used_mb": 7820,
//! "mem_total_mb": 16384,
//! "proc_count": 341,
//! "uptime_secs": 173000,
//! "top_cpu_name": "chrome.exe",
//! "top_cpu_pct": 18.3,
//! "top_mem_name": "chrome.exe",
//! "top_mem_mb": 1240
//! }
//! ]
//! }
//! ```
use crate::api::device_auth::{self, AuthOutcome};
use crate::api::error::ApiError;
use crate::api::state::AppState;
use crate::database::MetricsSampleRow;
use axum::body::Bytes;
use axum::extract::Extension;
use axum::http::HeaderMap;
use serde::Deserialize;
use std::sync::Arc;
#[derive(Debug, Deserialize)]
pub struct MetricsSampleIn {
pub at: i64,
#[serde(default)]
pub cpu_pct: f64,
#[serde(default)]
pub mem_used_mb: i64,
#[serde(default)]
pub mem_total_mb: i64,
#[serde(default)]
pub proc_count: i64,
#[serde(default)]
pub uptime_secs: i64,
#[serde(default)]
pub top_cpu_name: String,
#[serde(default)]
pub top_cpu_pct: f64,
#[serde(default)]
pub top_mem_name: String,
#[serde(default)]
pub top_mem_mb: i64,
}
#[derive(Debug, Deserialize)]
pub struct MetricsBody {
pub id: String,
pub uuid: String,
pub samples: Vec<MetricsSampleIn>,
}
/// Cap per request. At 60s sampling cadence + the agent's 30-minute
/// retry-and-drain budget, even a long outage should fit well under this.
const MAX_SAMPLES_PER_POST: usize = 512;
/// Defensive bound on string fields the agent puts in `top_*_name` — a
/// runaway process name doesn't get to balloon the DB row.
const MAX_PROC_NAME_LEN: usize = 128;
pub async fn metrics(
Extension(state): Extension<Arc<AppState>>,
headers: HeaderMap,
body: Bytes,
) -> Result<String, ApiError> {
let outcome =
device_auth::verify(&state, "POST", "/api/agent/metrics", &headers, &body).await?;
let payload: MetricsBody = serde_json::from_slice(&body)
.map_err(|_| ApiError::BadRequest("invalid json".into()))?;
if payload.id.is_empty() || payload.uuid.is_empty() {
return Err(ApiError::BadRequest("id and uuid are required".into()));
}
if payload.samples.is_empty() {
return Ok("OK".to_string());
}
if payload.samples.len() > MAX_SAMPLES_PER_POST {
return Err(ApiError::BadRequest(format!(
"too many samples in one POST (max {MAX_SAMPLES_PER_POST})"
)));
}
let id = match outcome {
AuthOutcome::Verified { id: signed_id } => {
if payload.id != signed_id {
return Err(ApiError::Unauthorized);
}
signed_id
}
AuthOutcome::LegacyUnsigned => {
device_auth::enforce_managed_for_id(&state, &payload.id).await?;
payload.id.clone()
}
};
let peer = state
.db
.get_peer(&id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if peer.is_none() {
return Ok("ID_NOT_FOUND".to_string());
}
let mut accepted = 0usize;
for s in &payload.samples {
// Sanity-clamp the floats and string lengths. The agent should
// produce well-formed values, but the public-API shape means
// garbage-in shouldn't propagate to garbage-on-screen.
let cpu_pct = clamp_pct(s.cpu_pct);
let top_cpu_pct = clamp_pct(s.top_cpu_pct);
let row = MetricsSampleRow {
at: s.at,
cpu_pct,
mem_used_mb: s.mem_used_mb.max(0),
mem_total_mb: s.mem_total_mb.max(0),
proc_count: s.proc_count.max(0),
uptime_secs: s.uptime_secs.max(0),
top_cpu_name: truncate(&s.top_cpu_name, MAX_PROC_NAME_LEN),
top_cpu_pct,
top_mem_name: truncate(&s.top_mem_name, MAX_PROC_NAME_LEN),
top_mem_mb: s.top_mem_mb.max(0),
};
if let Err(e) = state
.db
.metrics_sample_insert(&id, &payload.uuid, &row)
.await
{
hbb_common::log::warn!(
"metrics_sample_insert for peer {} failed: {}",
id,
e
);
continue;
}
accepted += 1;
}
hbb_common::log::debug!(
"metrics: peer={} accepted={}/{}",
id,
accepted,
payload.samples.len()
);
Ok("OK".to_string())
}
fn clamp_pct(v: f64) -> f64 {
if v.is_nan() {
0.0
} else {
v.clamp(0.0, 100.0)
}
}
/// Char-aware truncate (so we don't slice mid-multibyte). The cap is
/// generous so process names that include arguments or Unicode survive.
fn truncate(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
s.to_string()
} else {
s.chars().take(max_chars).collect()
}
}
+95
View File
@@ -0,0 +1,95 @@
use crate::api::error::ApiError;
use crate::api::state::AppState;
use async_trait::async_trait;
use axum::extract::{FromRequest, RequestParts};
use axum::http::header;
use std::sync::Arc;
/// Cookie name used by the admin dashboard. Browser-set, HttpOnly, SameSite=Strict.
pub const SESSION_COOKIE: &str = "rd_admin_session";
pub struct AuthedUser {
pub user_id: i64,
pub name: String,
pub is_admin: bool,
}
pub fn sha256_token(token: &str) -> Vec<u8> {
sodiumoxide::crypto::hash::sha256::hash(token.as_bytes())
.as_ref()
.to_vec()
}
#[async_trait]
impl<B: Send> FromRequest<B> for AuthedUser {
type Rejection = ApiError;
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
let state: axum::extract::Extension<Arc<AppState>> =
axum::extract::Extension::from_request(req)
.await
.map_err(|_| ApiError::Internal("missing state".into()))?;
let token = extract_token(req).ok_or(ApiError::Unauthorized)?;
let sha = sha256_token(&token);
let (user_id, _exp) = state
.db
.token_lookup(&sha)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::Unauthorized)?;
// Slide the expiry forward on every authenticated request.
if let Err(e) = state.db.token_touch(&sha, state.cfg.session_ttl_secs).await {
hbb_common::log::warn!("token_touch failed: {}", e);
}
let user = state
.db
.user_find_by_id(user_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::Unauthorized)?;
Ok(Self {
user_id: user.id,
name: user.username,
is_admin: user.is_admin,
})
}
}
/// Extract a token from either the `Authorization: Bearer …` header (preferred,
/// for the desktop client and curl) or the `rd_admin_session` cookie (for the
/// browser-driven admin dashboard). Returns `None` if neither is present.
fn extract_token<B>(req: &RequestParts<B>) -> Option<String> {
// Bearer header wins so a curl smoke test always behaves predictably,
// even when run from the same browser session.
if let Some(auth) = req.headers().get(header::AUTHORIZATION) {
if let Ok(s) = auth.to_str() {
if let Some(tok) = s.strip_prefix("Bearer ").map(str::trim) {
if !tok.is_empty() {
return Some(tok.to_string());
}
}
}
}
// Cookie header is a single line: `name=value; name2=value2; …`. Walk
// the comma-or-semicolon-separated pairs without pulling in a cookie crate.
if let Some(cookie_hdr) = req.headers().get(header::COOKIE) {
if let Ok(s) = cookie_hdr.to_str() {
for pair in s.split(';') {
let pair = pair.trim();
if let Some((name, value)) = pair.split_once('=') {
if name.trim() == SESSION_COOKIE {
let v = value.trim();
if !v.is_empty() {
return Some(v.to_string());
}
}
}
}
}
}
None
}
+130
View File
@@ -0,0 +1,130 @@
//! HTTP management API mounted in-process alongside hbbs's rendezvous
//! listeners. The router is wired in via `src/rendezvous_server.rs`'s outer
//! `tokio::select!`. M1 covers auth + heartbeat + sysinfo; later milestones
//! add address book, audit, OIDC, etc.
pub mod ab;
pub mod admin;
pub mod agent_exec;
pub mod audit;
pub mod auth;
pub mod device_auth;
pub mod devices_cli;
pub mod email;
pub mod error;
pub mod groups;
pub mod heartbeat;
pub mod http_proxy;
pub mod login_event;
pub mod metrics;
pub mod perf_events;
pub mod middleware;
pub mod oidc;
pub mod pagination;
pub mod peers;
pub mod plugin_sign;
pub mod record;
pub mod state;
pub mod strategy;
pub mod sysinfo;
pub mod twofa;
pub mod unattended;
pub mod users;
pub use state::AppState;
use axum::extract::Extension;
use axum::routing::{delete, get, post, put};
use axum::Router;
use hbb_common::{log, ResultType};
use std::net::SocketAddr;
use std::sync::Arc;
pub fn router(state: Arc<AppState>) -> Router {
let app = Router::new()
// M1: auth + heartbeat + sysinfo
.route(
"/api/login-options",
get(auth::login_options).head(auth::login_options_head),
)
.route("/api/login", post(auth::login))
.route("/api/currentUser", post(auth::current_user))
.route("/api/logout", post(auth::logout))
.route("/api/heartbeat", post(heartbeat::heartbeat))
.route("/api/sysinfo_ver", post(sysinfo::sysinfo_ver))
.route("/api/sysinfo", post(sysinfo::sysinfo))
.route("/api/agent/exec-result", post(agent_exec::exec_result))
.route("/api/agent/login-event", post(login_event::login_event))
.route("/api/agent/metrics", post(metrics::metrics))
.route("/api/agent/perf-events", post(perf_events::perf_events))
.route(
"/api/unattended-password",
post(unattended::unattended_password),
)
// M2: address book — modern (shared + personal)
.route("/api/ab/settings", post(ab::settings::settings))
.route("/api/ab/personal", post(ab::profiles::personal))
.route(
"/api/ab/shared/profiles",
post(ab::profiles::shared_profiles),
)
.route("/api/ab/peers", post(ab::peers::list))
.route("/api/ab/tags/:guid", post(ab::tags::list))
.route("/api/ab/peer/add/:guid", post(ab::peers::add))
.route("/api/ab/peer/update/:guid", put(ab::peers::update))
.route("/api/ab/peer/:guid", delete(ab::peers::delete))
.route("/api/ab/tag/add/:guid", post(ab::tags::add))
.route("/api/ab/tag/rename/:guid", put(ab::tags::rename))
.route("/api/ab/tag/update/:guid", put(ab::tags::update))
.route("/api/ab/tag/:guid", delete(ab::tags::delete))
// M2: address book — legacy single-blob fallback
.route(
"/api/ab",
get(ab::legacy::get).post(ab::legacy::put),
)
// M2: group / users / peers panel
.route(
"/api/device-group/accessible",
get(groups::accessible),
)
.route("/api/users", get(users::list))
.route("/api/peers", get(peers::list))
.route("/api/peers/:id/managed", put(peers::set_managed))
// M3: audit
.route("/api/audit/conn", post(audit::conn::conn))
.route("/api/audit/file", post(audit::file::file))
.route("/api/audit/alarm", post(audit::alarm::alarm))
.route("/api/audit", put(audit::note::note))
// M3: session recording upload
.route("/api/record", post(record::record))
// M4: TOTP enrollment (admin-only)
.route("/api/2fa/enroll", post(twofa::enroll))
.route("/api/2fa/unenroll", post(twofa::unenroll))
// M4: rustdesk --assign target
.route("/api/devices/cli", post(devices_cli::assign))
// M4: plugin signing (no auth — protocol-level)
.route("/lic/web/api/plugin-sign", post(plugin_sign::plugin_sign))
// M4: OIDC device-flow login
.route("/api/oidc/auth", post(oidc::auth::auth))
.route("/api/oidc/auth-query", get(oidc::poll::auth_query))
.route("/oidc/callback", get(oidc::callback::callback));
// M5: admin dashboard (HTMX + embedded HTML). Merged BEFORE the
// Extension layer so the merged router carries the shared state.
let app = match admin::build(state.clone()) {
Some(admin_router) => app.merge(admin_router),
None => app,
};
app.layer(Extension(state))
}
pub async fn serve(addr: SocketAddr, state: Arc<AppState>) -> ResultType<()> {
log::info!("HTTP API listening on {}", addr);
let app = router(state);
// Share the same router with the rendezvous-TCP HttpProxyRequest path so
// both transports route through the exact same handlers.
http_proxy::install_router(app.clone());
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await?;
Ok(())
}
+99
View File
@@ -0,0 +1,99 @@
//! `POST /api/oidc/auth` — start the device-flow login.
use crate::api::error::ApiError;
use crate::api::oidc::{discovery, random_token, require_provider, OIDC_SESSION_TTL_SECS};
use crate::api::state::AppState;
use crate::database::OidcSessionInsert;
use axum::extract::Extension;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use std::sync::Arc;
#[derive(Debug, Deserialize)]
pub struct AuthBody {
/// Provider short-name from `oidc_providers.name`. The Flutter client
/// sends this from the `op` field of the OIDC dialog.
#[serde(default)]
pub op: String,
#[serde(default)]
pub id: String,
#[serde(default)]
pub uuid: String,
#[serde(default, rename = "deviceInfo")]
pub device_info: Option<Value>,
}
pub async fn auth(
Extension(state): Extension<Arc<AppState>>,
Json(body): Json<AuthBody>,
) -> Result<Json<Value>, ApiError> {
if state.cfg.public_base_url.is_empty() {
return Err(ApiError::Internal(
"OIDC requires --public-base-url to be set".into(),
));
}
if body.op.is_empty() {
return Err(ApiError::BadRequest("op (provider name) required".into()));
}
let provider = require_provider(&state, &body.op).await?;
let disc = discovery::discover(&provider.issuer_url)
.await
.map_err(ApiError::Internal)?;
let code = random_token();
let csrf_state = random_token();
let device_info_json = body
.device_info
.as_ref()
.map(|v| v.to_string())
.unwrap_or_else(|| "{}".to_string());
let expires_at = chrono::Utc::now().timestamp() + OIDC_SESSION_TTL_SECS;
state
.db
.oidc_session_create(&OidcSessionInsert {
code: &code,
provider: &provider.name,
state: &csrf_state,
client_id_str: &body.id,
client_uuid: &body.uuid,
device_info_json: &device_info_json,
expires_at,
})
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
// Build the IdP authorization URL.
let url = format!(
"{auth}?response_type=code&client_id={cid}&redirect_uri={ru}&scope={scope}&state={state}",
auth = disc.authorization_endpoint,
cid = url_encode(&provider.client_id),
ru = url_encode(&provider.redirect_url),
scope = url_encode(&provider.scopes),
state = url_encode(&csrf_state),
);
Ok(Json(json!({
"code": code,
"url": url,
})))
}
/// Inline percent-encoder for the auth URL query string. See
/// `api::twofa::url_encode` for the same routine.
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*b as char);
}
_ => {
use std::fmt::Write;
let _ = write!(out, "%{:02X}", b);
}
}
}
out
}
+312
View File
@@ -0,0 +1,312 @@
//! `GET /oidc/callback?code=&state=` — browser-facing redirect target.
//!
//! After the user signs in at the IdP, the IdP redirects their browser
//! here. We exchange the IdP code for tokens, fetch userinfo, find/create
//! a local user, mint our access token, and mark the session `success`.
//! The browser sees a small "you can close this window" page; the desktop
//! client picks up the token via `/api/oidc/auth-query`.
use crate::api::admin::oidc_login::ADMIN_SENTINEL;
use crate::api::auth::mint_token;
use crate::api::middleware::{sha256_token, SESSION_COOKIE};
use crate::api::oidc::{discovery, require_provider};
use crate::api::state::AppState;
use axum::extract::{Extension, Query};
use axum::http::header::{LOCATION, SET_COOKIE};
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use serde::Deserialize;
use serde_json::Value;
use std::sync::Arc;
#[derive(Debug, Deserialize)]
pub struct CallbackQuery {
#[serde(default)]
pub code: String,
#[serde(default)]
pub state: String,
/// Some IdPs forward an error here on failed auth (e.g. user clicked
/// "deny"). We surface it as the session error and as a friendly page.
#[serde(default)]
pub error: Option<String>,
#[serde(default)]
pub error_description: Option<String>,
}
pub async fn callback(
Extension(state): Extension<Arc<AppState>>,
Query(q): Query<CallbackQuery>,
) -> Response {
match handle(state.clone(), q).await {
Ok(ok) if ok.is_admin_flow => {
if !ok.user_is_admin {
return Html(html_page(
"Sign-in failed",
"This account does not have admin access. Ask an existing admin to grant it on the Users page, then try again.",
))
.into_response();
}
// Set the dashboard session cookie and redirect to /admin/.
// Same cookie shape /admin/login uses on success.
let cookie = format!(
"{name}={token}; HttpOnly; Path=/; SameSite=Strict; Max-Age={ttl}",
name = SESSION_COOKIE,
token = ok.token,
ttl = state.cfg.session_ttl_secs,
);
let mut headers = HeaderMap::new();
if let Ok(v) = HeaderValue::from_str(&cookie) {
headers.insert(SET_COOKIE, v);
}
headers.insert(LOCATION, HeaderValue::from_static("/admin/"));
(StatusCode::SEE_OTHER, headers).into_response()
}
Ok(_) => Html(html_page(
"Sign-in complete",
"You can close this window and return to RustDesk.",
))
.into_response(),
Err(msg) => Html(html_page("Sign-in failed", &html_escape(&msg))).into_response(),
}
}
struct HandleOk {
/// Bearer token freshly minted for the local user. For the admin flow
/// we set it as `rd_admin_session`; for the desktop flow it's already
/// stashed on the OidcSession row for `/api/oidc/auth-query` polling.
token: String,
user_is_admin: bool,
is_admin_flow: bool,
}
async fn handle(state: Arc<AppState>, q: CallbackQuery) -> Result<HandleOk, String> {
if q.state.is_empty() {
return Err("missing state parameter".into());
}
let session = state
.db
.oidc_session_get_by_state(&q.state)
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| "unknown or expired oidc session (state)".to_string())?;
if let Some(err) = q.error.as_deref().filter(|s| !s.is_empty()) {
let detail = q
.error_description
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or(err);
let _ = state
.db
.oidc_session_fail(&session.code, &format!("idp: {}", detail))
.await;
return Err(format!("identity provider returned an error: {}", detail));
}
if q.code.is_empty() {
return Err("missing authorization code".into());
}
let provider = require_provider(&state, &session.provider)
.await
.map_err(|e| format!("{:?}", e))?;
let disc = discovery::discover(&provider.issuer_url).await?;
// Token exchange.
let token_body = match discovery::http_post_form(
&disc.token_endpoint,
&[
("grant_type", "authorization_code"),
("code", &q.code),
("redirect_uri", &provider.redirect_url),
("client_id", &provider.client_id),
("client_secret", &provider.client_secret),
],
)
.await
{
Ok(b) => b,
Err(e) => {
let _ = state
.db
.oidc_session_fail(&session.code, &format!("token exchange: {}", e))
.await;
return Err(e);
}
};
let token_resp: Value =
serde_json::from_str(&token_body).map_err(|e| format!("parse token resp: {}", e))?;
let access_token = token_resp
.get("access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| "token response missing access_token".to_string())?;
// Fetch userinfo. We trust the userinfo endpoint as the authority on
// the user's identity (sub + optional email + name).
let userinfo_url = disc
.userinfo_endpoint
.as_deref()
.ok_or_else(|| "provider has no userinfo_endpoint".to_string())?;
let userinfo_body = discovery::http_get_with_bearer(userinfo_url, access_token).await?;
let userinfo: Value = serde_json::from_str(&userinfo_body)
.map_err(|e| format!("parse userinfo: {}", e))?;
let sub = userinfo
.get("sub")
.and_then(|v| v.as_str())
.ok_or_else(|| "userinfo missing sub".to_string())?;
let email = userinfo.get("email").and_then(|v| v.as_str());
let display_name = userinfo
.get("name")
.and_then(|v| v.as_str())
.or_else(|| userinfo.get("preferred_username").and_then(|v| v.as_str()));
// Optional role-based admin sync. When the provider is configured with
// `admin_role`, we look up the userinfo claim at `roles_claim` (default
// "roles") and set is_admin accordingly. Two shapes are supported:
// - object: presence of the role name as a key
// (Zitadel default: `"urn:zitadel:iam:org:project:roles":
// {"admin": {"<orgid>": "<orgname>"}}`)
// - array of strings: presence of the role name as an element
// (e.g. a custom claim mapping `"roles": ["admin", "user"]`)
let desired_admin = provider.admin_role.as_deref().map(|role| {
let claim_name = provider.roles_claim.as_deref().unwrap_or("roles");
eval_admin_role(&userinfo, claim_name, role)
});
let user = state
.db
.user_upsert_oidc(sub, email, display_name, desired_admin)
.await
.map_err(|e| e.to_string())?;
if user.status == 0 {
return Err("user is disabled".into());
}
// Mint our own access token, store hashed, mark session complete.
let token = mint_token();
let sha = sha256_token(&token);
let is_admin_flow = session.client_uuid == ADMIN_SENTINEL;
// For admin-UI OIDC the "device id/uuid" fields carry the sentinel —
// don't pollute the tokens.peer_* columns with it.
let (token_peer_id, token_peer_uuid): (&str, &str) = if is_admin_flow {
("", "")
} else {
(
session.client_id_str.as_str(),
session.client_uuid.as_str(),
)
};
state
.db
.token_insert(
user.id,
&sha,
token_peer_id,
token_peer_uuid,
&session.device_info_json,
state.cfg.session_ttl_secs,
)
.await
.map_err(|e| e.to_string())?;
// Best-effort device claim — same path as `/api/login`. Skipped for
// admin-UI flow because the "device" is the operator's browser, not a
// real RustDesk peer; calling device_claim with the sentinel would
// insert a phantom row in device_sysinfo.
if !is_admin_flow {
state
.db
.device_claim(user.id, &session.client_id_str, &session.client_uuid)
.await;
}
state
.db
.oidc_session_complete(&session.code, &token, user.id)
.await
.map_err(|e| e.to_string())?;
Ok(HandleOk {
token,
user_is_admin: user.is_admin,
is_admin_flow,
})
}
fn html_page(title: &str, body: &str) -> String {
format!(
r#"<!doctype html>
<html><head><meta charset="utf-8"><title>{title}</title>
<style>
body {{ font-family: -apple-system, system-ui, sans-serif;
display: flex; flex-direction: column; align-items: center;
justify-content: center; height: 100vh; margin: 0;
background: #0e0f12; color: #e6e6e6; }}
.card {{ background: #1c1e22; padding: 48px 56px;
border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,.3); max-width: 480px; }}
h1 {{ margin: 0 0 16px; font-size: 22px; }}
p {{ margin: 0; line-height: 1.5; color: #b8b8b8; }}
</style>
</head><body><div class="card">
<h1>{title}</h1>
<p>{body}</p>
</div></body></html>"#,
title = title,
body = body
)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
/// Returns true iff the userinfo's `claim_name` field carries `role` —
/// either as an object key (Zitadel) or as an element of a string array
/// (generic). Anything else (missing claim, wrong type, role not present)
/// is treated as "not admin" so a misconfigured claim demotes rather than
/// silently grants.
fn eval_admin_role(userinfo: &Value, claim_name: &str, role: &str) -> bool {
let Some(node) = userinfo.get(claim_name) else {
return false;
};
if let Some(obj) = node.as_object() {
return obj.contains_key(role);
}
if let Some(arr) = node.as_array() {
return arr
.iter()
.any(|v| v.as_str().map(|s| s == role).unwrap_or(false));
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn admin_role_zitadel_object_shape() {
let u = json!({
"sub": "1",
"urn:zitadel:iam:org:project:roles": {
"admin": {"123": "myorg"},
"user": {"123": "myorg"},
},
});
assert!(eval_admin_role(&u, "urn:zitadel:iam:org:project:roles", "admin"));
assert!(!eval_admin_role(&u, "urn:zitadel:iam:org:project:roles", "owner"));
}
#[test]
fn admin_role_generic_array_shape() {
let u = json!({"sub": "1", "roles": ["admin", "user"]});
assert!(eval_admin_role(&u, "roles", "admin"));
assert!(!eval_admin_role(&u, "roles", "owner"));
}
#[test]
fn admin_role_missing_claim_is_not_admin() {
let u = json!({"sub": "1"});
assert!(!eval_admin_role(&u, "roles", "admin"));
}
}
+128
View File
@@ -0,0 +1,128 @@
//! `<issuer>/.well-known/openid-configuration` discovery + in-memory cache.
//!
//! Most OIDC providers serve a JSON document at this URL describing the
//! authorization, token, and userinfo endpoints. Doing discovery once per
//! provider and caching the result keeps the per-login overhead to two
//! HTTP calls (token exchange + userinfo).
use hbb_common::log;
use once_cell::sync::Lazy;
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::Mutex;
#[derive(Debug, Clone, Deserialize)]
pub struct OidcDiscovery {
pub authorization_endpoint: String,
pub token_endpoint: String,
#[serde(default)]
pub userinfo_endpoint: Option<String>,
#[serde(default)]
pub issuer: Option<String>,
}
static CACHE: Lazy<Mutex<HashMap<String, OidcDiscovery>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
/// Fetch (or return cached) discovery document for `issuer_url`. Strips a
/// trailing `/` so the cache key is stable across operator typos.
pub async fn discover(issuer_url: &str) -> Result<OidcDiscovery, String> {
let issuer = issuer_url.trim_end_matches('/').to_string();
if let Some(d) = CACHE.lock().unwrap().get(&issuer).cloned() {
return Ok(d);
}
let url = format!("{}/.well-known/openid-configuration", issuer);
log::info!("oidc: discovering {}", url);
let body = http_get(&url).await?;
let parsed: OidcDiscovery = serde_json::from_str(&body)
.map_err(|e| format!("discovery parse {}: {}", url, e))?;
CACHE.lock().unwrap().insert(issuer, parsed.clone());
Ok(parsed)
}
/// Blocking HTTP GET wrapped in `spawn_blocking`. We use the existing
/// `reqwest::blocking::Client` rather than adding an async client, because
/// (a) discovery happens at most once per provider and (b) the rustdesk
/// reqwest fork is configured for blocking-only use throughout the server.
pub async fn http_get(url: &str) -> Result<String, String> {
let url = url.to_owned();
hbb_common::tokio::task::spawn_blocking(move || {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("http client build: {}", e))?;
let resp = client
.get(&url)
.send()
.map_err(|e| format!("http get {}: {}", url, e))?;
let status = resp.status();
let body = resp.text().map_err(|e| format!("read body: {}", e))?;
if !status.is_success() {
return Err(format!("http {} -> {}: {}", url, status, body));
}
Ok(body)
})
.await
.map_err(|e| format!("spawn_blocking: {}", e))?
}
pub async fn http_post_form(
url: &str,
form: &[(&str, &str)],
) -> Result<String, String> {
let url = url.to_owned();
let owned: Vec<(String, String)> = form
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
hbb_common::tokio::task::spawn_blocking(move || {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("http client build: {}", e))?;
let pairs: Vec<(&str, &str)> = owned
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let resp = client
.post(&url)
.form(&pairs)
.send()
.map_err(|e| format!("http post {}: {}", url, e))?;
let status = resp.status();
let body = resp.text().map_err(|e| format!("read body: {}", e))?;
if !status.is_success() {
return Err(format!("http {} -> {}: {}", url, status, body));
}
Ok(body)
})
.await
.map_err(|e| format!("spawn_blocking: {}", e))?
}
pub async fn http_get_with_bearer(
url: &str,
bearer: &str,
) -> Result<String, String> {
let url = url.to_owned();
let bearer = bearer.to_owned();
hbb_common::tokio::task::spawn_blocking(move || {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("http client build: {}", e))?;
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {}", bearer))
.send()
.map_err(|e| format!("http get {}: {}", url, e))?;
let status = resp.status();
let body = resp.text().map_err(|e| format!("read body: {}", e))?;
if !status.is_success() {
return Err(format!("http {} -> {}: {}", url, status, body));
}
Ok(body)
})
.await
.map_err(|e| format!("spawn_blocking: {}", e))?
}
+53
View File
@@ -0,0 +1,53 @@
//! OIDC device-flow login.
//!
//! Wire flow (matching CONSOLE_API.md §3.5):
//!
//! 1. `POST /api/oidc/auth { op: <provider>, id, uuid, deviceInfo }` →
//! `{ code: <opaque-poll-handle>, url: <browser auth URL> }`. The client
//! opens `url` in the user's browser.
//! 2. The IdP redirects the browser back to our `/oidc/callback?code=...&state=...`.
//! That handler exchanges the IdP code for a token, fetches userinfo,
//! upserts a local user, mints our own access token, and marks the
//! session `success`.
//! 3. The client polls `GET /api/oidc/auth-query?code=&id=&uuid=` until it
//! sees a wrapped `AuthBody` envelope.
//!
//! Auth on the IdP side is handled by the provider's standard OAuth2
//! authorization-code flow. We keep the hbbs side minimal: discovery via
//! `<issuer>/.well-known/openid-configuration`, no JWT verification (we
//! trust the userinfo endpoint, authenticated via the access token).
pub mod auth;
pub mod callback;
pub mod discovery;
pub mod poll;
pub mod providers;
use crate::api::error::ApiError;
use crate::api::state::AppState;
use crate::database::OidcProviderRow;
pub(crate) const OIDC_SESSION_TTL_SECS: i64 = 600; // 10 minutes — the user has to sign in fast
/// Convenience: resolve a provider name to its row, or an ApiError if it
/// doesn't exist or is disabled.
pub(crate) async fn require_provider(
state: &AppState,
name: &str,
) -> Result<OidcProviderRow, ApiError> {
state
.db
.oidc_provider_get(name)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or_else(|| ApiError::BadRequest(format!("unknown OIDC provider: {}", name)))
}
/// 24 random bytes, base64url-encoded → ~32 characters. Used for both the
/// poll-handle (`code`) and the CSRF state.
pub(crate) fn random_token() -> String {
base64::encode_config(
sodiumoxide::randombytes::randombytes(24),
base64::URL_SAFE_NO_PAD,
)
}
+92
View File
@@ -0,0 +1,92 @@
//! `GET /api/oidc/auth-query?code=&id=&uuid=` — desktop-client poll loop.
//!
//! Wire shape: return the inner payload as the HTTP body directly. Do NOT
//! wrap in another `{ "body": ... }` envelope — the desktop client's
//! transport (`http_request_sync` in src/common.rs) already wraps every
//! response in `{ status_code, headers, body }` and feeds the inner `body`
//! string to `HbbHttpResponse::parse`. An extra envelope makes the parser
//! see `{"body": "..."}`, fail to deserialize as `AuthBody`, and silently
//! retry until the 180 s client timeout. Spent half a day on this — keep
//! the bare shape.
//!
//! Inner payloads:
//! - while pending: `{"error":"No authed oidc is found"}` — client keeps polling.
//! - on success: `{access_token, type:"access_token", user}` — client stops.
//! - on error: `{"error":"<message>"}` — client surfaces and stops polling.
use crate::api::error::ApiError;
use crate::api::state::AppState;
use crate::api::users::UserPayload;
use axum::extract::{Extension, Query};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use std::sync::Arc;
#[derive(Debug, Deserialize)]
pub struct PollQuery {
pub code: String,
#[serde(default)]
pub id: String,
#[serde(default)]
pub uuid: String,
}
pub async fn auth_query(
Extension(state): Extension<Arc<AppState>>,
Query(q): Query<PollQuery>,
) -> Result<Json<Value>, ApiError> {
let now = chrono::Utc::now().timestamp();
let session = state
.db
.oidc_session_get_by_code(&q.code)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or_else(|| ApiError::BadRequest("unknown oidc session".into()))?;
hbb_common::log::info!(
"oidc poll: code={} status={} user_id={:?} elapsed_to_expiry={}",
q.code,
session.status,
session.user_id,
session.expires_at - now,
);
if session.expires_at <= now && session.status == "pending" {
// The client treats this as an ordinary "still pending" tick and
// gives up on its own timeout (180 s).
return Ok(Json(json!({"error": "No authed oidc is found"})));
}
match session.status.as_str() {
"pending" => Ok(Json(json!({"error": "No authed oidc is found"}))),
"error" => {
let msg = session
.error
.clone()
.unwrap_or_else(|| "OIDC sign-in failed".to_string());
Ok(Json(json!({ "error": msg })))
}
"success" => {
let access_token = session
.access_token
.clone()
.ok_or_else(|| ApiError::Internal("success session missing token".into()))?;
let user_id = session
.user_id
.ok_or_else(|| ApiError::Internal("success session missing user_id".into()))?;
let user = state
.db
.user_find_by_id(user_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or_else(|| ApiError::Internal("user vanished mid-flow".into()))?;
Ok(Json(json!({
"access_token": access_token,
"type": "access_token",
"user": UserPayload::from(&user),
})))
}
other => Err(ApiError::Internal(format!(
"unknown oidc status {:?}",
other
))),
}
}
+106
View File
@@ -0,0 +1,106 @@
//! Operator-supplied provider config. Reads a TOML file shaped like:
//!
//! ```toml
//! [[providers]]
//! name = "google"
//! display_name = "Google"
//! issuer_url = "https://accounts.google.com"
//! client_id = "<google client id>"
//! client_secret = "<google client secret>"
//! scopes = "openid email profile"
//! ```
//!
//! Each entry is upserted into the `oidc_providers` table at startup.
//! `redirect_url` is computed from `--public-base-url` + `/oidc/callback`.
//!
//! TOML parsing uses the existing `rust-ini` crate? — no, we'd need a TOML
//! parser. We already have `toml` transitively via several deps; pull it in
//! directly for clarity.
use crate::database::{Database, OidcProviderRow};
use hbb_common::log;
use serde::Deserialize;
use std::path::Path;
#[derive(Debug, Deserialize)]
struct ProvidersFile {
#[serde(default)]
providers: Vec<ProviderEntry>,
}
#[derive(Debug, Deserialize)]
struct ProviderEntry {
name: String,
#[serde(default)]
display_name: Option<String>,
#[serde(default)]
icon_url: Option<String>,
issuer_url: String,
client_id: String,
client_secret: String,
#[serde(default = "default_scopes")]
scopes: String,
/// Optional override; defaults to `<public-base-url>/oidc/callback`.
#[serde(default)]
redirect_url: Option<String>,
#[serde(default = "default_true")]
enabled: bool,
/// Role-based admin sync. Set both to drive `is_admin` from the IdP:
/// admin_role = "admin"
/// roles_claim = "urn:zitadel:iam:org:project:roles" # Zitadel
/// Or for a generic IdP that emits `roles: ["admin","user"]`:
/// admin_role = "admin"
/// # roles_claim defaults to "roles"
#[serde(default)]
admin_role: Option<String>,
#[serde(default)]
roles_claim: Option<String>,
}
fn default_scopes() -> String {
"openid email profile".to_string()
}
fn default_true() -> bool {
true
}
pub async fn load_from_file(
db: &Database,
path: &Path,
public_base_url: &str,
) -> Result<usize, String> {
let bytes = std::fs::read_to_string(path)
.map_err(|e| format!("read {}: {}", path.display(), e))?;
let parsed: ProvidersFile =
toml::from_str(&bytes).map_err(|e| format!("parse {}: {}", path.display(), e))?;
let mut count = 0;
for p in parsed.providers {
let redirect_url = p
.redirect_url
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| {
let base = public_base_url.trim_end_matches('/');
format!("{}/oidc/callback", base)
});
let row = OidcProviderRow {
name: p.name.clone(),
display_name: p.display_name,
icon_url: p.icon_url,
issuer_url: p.issuer_url,
client_id: p.client_id,
client_secret: p.client_secret,
scopes: p.scopes,
redirect_url,
enabled: p.enabled,
admin_role: p.admin_role.filter(|s| !s.is_empty()),
roles_claim: p.roles_claim.filter(|s| !s.is_empty()),
};
db.oidc_provider_upsert(&row)
.await
.map_err(|e| format!("upsert {}: {}", p.name, e))?;
count += 1;
log::info!("oidc: provider {:?} configured", p.name);
}
Ok(count)
}
+38
View File
@@ -0,0 +1,38 @@
use serde::{Deserialize, Serialize};
/// Query-string pagination for list endpoints. The Flutter client at
/// flutter/lib/models/ab_model.dart and group_model.dart sends
/// `?current=1&pageSize=100` against every paginated list. Field names are
/// spelled explicitly here — `serde(rename_all = "camelCase")` would also
/// rename `current`, which we don't want.
#[derive(Debug, Deserialize)]
pub struct PageQuery {
#[serde(default = "default_current")]
pub current: i64,
#[serde(default = "default_page_size", rename = "pageSize")]
pub page_size: i64,
}
fn default_current() -> i64 {
1
}
fn default_page_size() -> i64 {
100
}
impl PageQuery {
pub fn offset(&self) -> i64 {
let cur = self.current.max(1);
(cur - 1) * self.limit()
}
pub fn limit(&self) -> i64 {
self.page_size.clamp(1, 1000)
}
}
/// Standard envelope: `{ total, data }`.
#[derive(Debug, Serialize)]
pub struct Page<T: Serialize> {
pub total: i64,
pub data: Vec<T>,
}
+110
View File
@@ -0,0 +1,110 @@
//! `GET /api/peers` — paginated peer list for the Group tab in the desktop
//! client. Flutter decoder at flutter/lib/common/hbbs/hbbs.dart:77 expects
//! `{ id, user, user_name, device_group_name, note, status, info: {...} }`
//! per row.
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::pagination::{Page, PageQuery};
use crate::api::state::AppState;
use axum::extract::{Extension, Path, Query};
use axum::Json;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::sync::Arc;
#[derive(Debug, Serialize)]
pub struct PeerOut {
pub id: String,
pub user: String,
pub user_name: String,
pub device_group_name: String,
pub note: String,
pub status: i64,
pub info: Value,
}
pub async fn list(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Query(q): Query<PageQuery>,
) -> Result<Json<Page<PeerOut>>, ApiError> {
let (total, rows) = state
.db
.peers_list_accessible(user.user_id, user.is_admin, q.offset(), q.limit())
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let data: Vec<PeerOut> = rows
.into_iter()
.map(|r| {
// Trim the full sysinfo blob to what the client actually reads.
let parsed: Value = serde_json::from_str(&r.sysinfo_payload).unwrap_or(Value::Null);
let pick = |k: &str| -> String {
parsed
.get(k)
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string()
};
let info = json!({
"username": pick("username"),
"device_name": pick("hostname"),
"os": pick("os"),
});
PeerOut {
id: r.id,
user: r.owner_username,
user_name: r.owner_display_name,
device_group_name: r.device_group_name,
note: r.note,
status: r.status,
info,
}
})
.collect();
Ok(Json(Page { total, data }))
}
#[derive(Debug, Deserialize)]
pub struct SetManagedBody {
pub managed: bool,
}
/// `PUT /api/peers/:id/managed` — admin-only toggle for the signed-API gate.
/// Setting `managed=true` is also done TOFU-style by the sig-verify helper
/// on the first valid signature, so this endpoint is mainly useful for:
/// - Pre-enrolling a peer before its agent boots.
/// - Downgrading a peer back to the unsigned path after a managed agent
/// is uninstalled or replaced with stock RustDesk.
pub async fn set_managed(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Path(id): Path<String>,
Json(body): Json<SetManagedBody>,
) -> Result<Json<Value>, ApiError> {
if !user.is_admin {
return Err(ApiError::Forbidden("admin only".into()));
}
// Confirm the peer exists before flipping, so an admin typo doesn't
// silently create a row-not-found situation.
let row = state
.db
.peer_get_auth(&id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if row.is_none() {
return Err(ApiError::NotFound);
}
state
.db
.peer_set_managed(&id, body.managed)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
hbb_common::log::info!(
"admin {} set peer {} managed={}",
user.name,
id,
body.managed
);
Ok(Json(json!({ "ok": true, "managed": body.managed })))
}
+141
View File
@@ -0,0 +1,141 @@
//! `POST /api/agent/perf-events` — performance-related Windows event log
//! entries the agent surfaced from `Microsoft-Windows-Diagnostics-
//! Performance/Operational`, `Microsoft-Windows-Resource-Exhaustion-
//! Detector/Operational`, and a few hand-picked IDs from `System`
//! (unexpected reboots, BSODs, dirty shutdowns). The admin UI shows
//! the recent ones in the device's Performance section.
//!
//! Auth: same per-peer signed-API gate as the other agent endpoints.
//! Server-side dedup is via the UNIQUE (peer_id, provider, record_id)
//! index — the agent persists a per-channel cursor to disk, but a
//! restart that loses the cursor can safely re-emit overlapping ranges.
use crate::api::device_auth::{self, AuthOutcome};
use crate::api::error::ApiError;
use crate::api::state::AppState;
use crate::database::PerfEventRow;
use axum::body::Bytes;
use axum::extract::Extension;
use axum::http::HeaderMap;
use serde::Deserialize;
use std::sync::Arc;
#[derive(Debug, Deserialize)]
pub struct PerfEventIn {
pub at: i64,
pub provider: String,
pub event_id: i64,
#[serde(default = "default_level")]
pub level: i64,
#[serde(default)]
pub record_id: i64,
#[serde(default)]
pub summary: String,
#[serde(default)]
pub detail_json: String,
}
fn default_level() -> i64 {
4 // WEL "Information"
}
#[derive(Debug, Deserialize)]
pub struct PerfEventsBody {
pub id: String,
pub uuid: String,
pub events: Vec<PerfEventIn>,
}
const MAX_EVENTS_PER_POST: usize = 128;
const MAX_PROVIDER_LEN: usize = 64;
const MAX_SUMMARY_LEN: usize = 512;
const MAX_DETAIL_LEN: usize = 8 * 1024;
pub async fn perf_events(
Extension(state): Extension<Arc<AppState>>,
headers: HeaderMap,
body: Bytes,
) -> Result<String, ApiError> {
let outcome =
device_auth::verify(&state, "POST", "/api/agent/perf-events", &headers, &body).await?;
let payload: PerfEventsBody = serde_json::from_slice(&body)
.map_err(|_| ApiError::BadRequest("invalid json".into()))?;
if payload.id.is_empty() || payload.uuid.is_empty() {
return Err(ApiError::BadRequest("id and uuid are required".into()));
}
if payload.events.is_empty() {
return Ok("OK".to_string());
}
if payload.events.len() > MAX_EVENTS_PER_POST {
return Err(ApiError::BadRequest(format!(
"too many events in one POST (max {MAX_EVENTS_PER_POST})"
)));
}
let id = match outcome {
AuthOutcome::Verified { id: signed_id } => {
if payload.id != signed_id {
return Err(ApiError::Unauthorized);
}
signed_id
}
AuthOutcome::LegacyUnsigned => {
device_auth::enforce_managed_for_id(&state, &payload.id).await?;
payload.id.clone()
}
};
let peer = state
.db
.get_peer(&id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if peer.is_none() {
return Ok("ID_NOT_FOUND".to_string());
}
let mut accepted = 0usize;
for e in &payload.events {
let provider = e.provider.trim();
if provider.is_empty() {
continue;
}
let row = PerfEventRow {
at: e.at,
provider: truncate(provider, MAX_PROVIDER_LEN),
event_id: e.event_id,
level: e.level,
record_id: e.record_id,
summary: truncate(&e.summary, MAX_SUMMARY_LEN),
detail_json: truncate(&e.detail_json, MAX_DETAIL_LEN),
received_at: 0, // server fills via DEFAULT on INSERT
};
if let Err(err) = state.db.perf_event_insert(&id, &payload.uuid, &row).await {
hbb_common::log::warn!(
"perf_event_insert for peer {} failed: {}",
id,
err
);
continue;
}
accepted += 1;
}
hbb_common::log::debug!(
"perf-events: peer={} accepted={}/{}",
id,
accepted,
payload.events.len()
);
Ok("OK".to_string())
}
fn truncate(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
s.to_string()
} else {
s.chars().take(max_chars).collect()
}
}
+59
View File
@@ -0,0 +1,59 @@
//! `POST /lic/web/api/plugin-sign` — signs a plugin's status/heartbeat
//! payload with the server's Ed25519 secret. The client (plugin runtime,
//! src/plugin/callback_msg.rs:282-296) sends:
//!
//! `{ "plugin_id": "...", "version": "...", "msg": [u8, u8, ...] }`
//!
//! and expects:
//!
//! `{ "signed_msg": [u8, u8, ...] }`
//!
//! No Authorization header — the client opens this without a token. Auth
//! is implicit via the licence-key shared secret on the rest of the
//! deployment; we just sign whatever is asked. (Pro can additionally
//! validate the plugin against an allowlist; OSS just signs.)
use crate::api::error::ApiError;
use axum::Json;
use serde::{Deserialize, Serialize};
use sodiumoxide::crypto::sign;
use std::sync::Arc;
#[derive(Debug, Deserialize)]
pub struct PluginSignReq {
#[serde(default)]
pub plugin_id: String,
#[serde(default)]
pub version: String,
pub msg: Vec<u8>,
}
#[derive(Debug, Serialize)]
pub struct PluginSignResp {
pub signed_msg: Vec<u8>,
}
/// The signing key is the same Ed25519 secret hbbs already uses for
/// rendezvous KeyExchange (`id_ed25519`). We pull it from the shared
/// `RendezvousServer.inner.sk` via the AppState — but `AppState` doesn't
/// hold it today, so this handler reads it directly from a process-wide
/// `OnceCell` populated at startup. (See `set_signing_key` below.)
pub async fn plugin_sign(
Json(req): Json<PluginSignReq>,
) -> Result<Json<PluginSignResp>, ApiError> {
let sk = SIGNING_KEY
.get()
.ok_or_else(|| ApiError::Internal("plugin signing not configured".into()))?;
let signed = sign::sign(&req.msg, sk);
Ok(Json(PluginSignResp { signed_msg: signed }))
}
use once_cell::sync::OnceCell;
static SIGNING_KEY: OnceCell<Arc<sign::SecretKey>> = OnceCell::new();
/// Called once from `RendezvousServer::start` after the keypair is loaded.
/// A no-op if already set; the server will only ever have one Ed25519 key.
pub fn set_signing_key(sk: sign::SecretKey) {
let _ = SIGNING_KEY.set(Arc::new(sk));
}
+54
View File
@@ -0,0 +1,54 @@
//! `POST /api/record?type={new|part|tail|remove}&file=&offset=&length=`
//!
//! No Authorization header — clients fire-and-forget. The wire flow is
//! defined in CONSOLE_API.md §8 and src/hbbs_http/record_upload.rs in the
//! client. We dispatch on `?type=` into the storage state machine.
pub mod storage;
use crate::api::error::ApiError;
use crate::api::state::AppState;
use axum::body::Bytes;
use axum::extract::{Extension, Query};
use axum::http::StatusCode;
use serde::Deserialize;
use std::sync::Arc;
#[derive(Debug, Deserialize)]
pub struct RecordQuery {
#[serde(rename = "type")]
pub kind: String,
pub file: String,
#[serde(default)]
pub offset: Option<u64>,
#[serde(default)]
pub length: Option<usize>,
}
pub async fn record(
Extension(state): Extension<Arc<AppState>>,
Query(q): Query<RecordQuery>,
body: Bytes,
) -> Result<StatusCode, ApiError> {
match q.kind.as_str() {
"new" => storage::handle_new(&state, &q.file, "").await?,
"part" => {
let offset = q.offset.unwrap_or(0);
let length = q.length.unwrap_or(body.len());
storage::handle_part(&state, &q.file, offset, length, &body).await?;
}
"tail" => {
let offset = q.offset.unwrap_or(0);
let length = q.length.unwrap_or(body.len());
storage::handle_tail(&state, &q.file, offset, length, &body).await?;
}
"remove" => storage::handle_remove(&state, &q.file).await?,
other => {
return Err(ApiError::BadRequest(format!(
"unknown record type {:?}",
other
)));
}
}
Ok(StatusCode::OK)
}
+147
View File
@@ -0,0 +1,147 @@
//! On-disk file IO for `/api/record`. The wire flow lives in
//! [src/hbbs_http/record_upload.rs](file:///Users/sn0/Desktop/rustdesk/src/hbbs_http/record_upload.rs)
//! on the client side: the controller emits `?type=new` once, then a series
//! of `?type=part&offset=N&length=L` chunks, and finally a `?type=tail`
//! header rewrite at offset 0. We mirror that as a tiny state machine.
use crate::api::error::ApiError;
use crate::api::state::AppState;
use std::path::{Component, Path, PathBuf};
use tokio::fs::{File, OpenOptions};
use tokio::io::{AsyncSeekExt, AsyncWriteExt, SeekFrom};
const TAIL_MAX: usize = 1024;
/// Reject any filename that contains a path separator or `..` component.
/// The client only ever sends a basename per
/// `record_upload.rs:118-122`, so anything else is suspicious.
pub fn sanitized_path(root: &Path, file: &str) -> Result<PathBuf, ApiError> {
if file.is_empty() {
return Err(ApiError::BadRequest("file required".into()));
}
let p = Path::new(file);
let mut comps = p.components();
let only = comps.next();
let extra = comps.next();
match (only, extra) {
(Some(Component::Normal(name)), None) if !name.is_empty() => Ok(root.join(name)),
_ => Err(ApiError::BadRequest("invalid file name".into())),
}
}
pub async fn handle_new(
state: &AppState,
file: &str,
peer_id: &str,
) -> Result<(), ApiError> {
let path = sanitized_path(&state.cfg.recording_dir, file)?;
if let Some(dir) = path.parent() {
tokio::fs::create_dir_all(dir)
.await
.map_err(|e| ApiError::Internal(format!("mkdir {}: {}", dir.display(), e)))?;
}
// Truncate (or create) the file.
OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&path)
.await
.map_err(|e| ApiError::Internal(format!("create {}: {}", path.display(), e)))?;
state
.db
.recording_new(peer_id, file)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(())
}
pub async fn handle_part(
state: &AppState,
file: &str,
offset: u64,
length: usize,
body: &[u8],
) -> Result<(), ApiError> {
if body.len() != length {
hbb_common::log::warn!(
"record part length mismatch: declared={} actual={}",
length,
body.len()
);
}
let path = sanitized_path(&state.cfg.recording_dir, file)?;
let max = state.cfg.recording_max_size_bytes;
if max > 0 && offset.saturating_add(body.len() as u64) > max {
return Err(ApiError::Forbidden("recording size cap exceeded".into()));
}
let mut f: File = OpenOptions::new()
.write(true)
.create(true)
.open(&path)
.await
.map_err(|e| ApiError::Internal(format!("open {}: {}", path.display(), e)))?;
f.seek(SeekFrom::Start(offset))
.await
.map_err(|e| ApiError::Internal(format!("seek: {}", e)))?;
f.write_all(body)
.await
.map_err(|e| ApiError::Internal(format!("write: {}", e)))?;
f.flush().await.ok();
let new_size = offset + body.len() as u64;
state
.db
.recording_set_state(file, "recording", Some(new_size as i64), false)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(())
}
pub async fn handle_tail(
state: &AppState,
file: &str,
offset: u64,
length: usize,
body: &[u8],
) -> Result<(), ApiError> {
if offset != 0 {
return Err(ApiError::BadRequest("tail must be at offset 0".into()));
}
if length > TAIL_MAX || body.len() > TAIL_MAX {
return Err(ApiError::BadRequest("tail exceeds 1024 bytes".into()));
}
let path = sanitized_path(&state.cfg.recording_dir, file)?;
let mut f = OpenOptions::new()
.write(true)
.open(&path)
.await
.map_err(|e| ApiError::Internal(format!("open {}: {}", path.display(), e)))?;
f.seek(SeekFrom::Start(0))
.await
.map_err(|e| ApiError::Internal(format!("seek: {}", e)))?;
f.write_all(body)
.await
.map_err(|e| ApiError::Internal(format!("write tail: {}", e)))?;
f.flush().await.ok();
state
.db
.recording_set_state(file, "finished", None, true)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(())
}
pub async fn handle_remove(state: &AppState, file: &str) -> Result<(), ApiError> {
let path = sanitized_path(&state.cfg.recording_dir, file)?;
if let Err(e) = tokio::fs::remove_file(&path).await {
if e.kind() != std::io::ErrorKind::NotFound {
hbb_common::log::warn!("remove {}: {}", path.display(), e);
}
}
state
.db
.recording_delete(file)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(())
}
+140
View File
@@ -0,0 +1,140 @@
use crate::common::{get_arg, get_arg_or};
use crate::database::Database;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Clone)]
pub struct ApiConfig {
pub login_options: Vec<String>,
pub sysinfo_ver: String,
pub session_ttl_secs: i64,
/// When true, `/api/ab/personal` returns 404, forcing the client into the
/// legacy single-blob AB path (`GET/POST /api/ab`). The default is the
/// modern shared-AB path.
pub ab_legacy_mode: bool,
/// Surfaced verbatim via `/api/ab/settings.max_peer_one_ab`.
pub ab_max_peers_per_book: i64,
/// On-disk root for `/api/record` uploads. Created on first use; one
/// subdirectory per peer-id under here.
pub recording_dir: PathBuf,
/// 0 means unlimited.
pub recording_max_size_bytes: u64,
/// 0 means no retention sweep.
pub audit_retention_days: i64,
/// SMTP transport for email-code login. `None` = dev mode: codes are
/// logged to stdout instead of mailed.
pub email: Option<EmailConfig>,
/// Externally reachable base URL of this server, e.g. for the OIDC
/// redirect_uri. Empty disables OIDC.
pub public_base_url: String,
/// On-disk root for the admin dashboard's static files. Empty disables
/// the dashboard entirely.
pub admin_ui_dir: String,
}
/// SMTP wiring for email-code login.
#[derive(Clone, Debug)]
pub struct EmailConfig {
pub host: String,
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
pub from: String,
pub starttls: bool,
}
#[derive(Clone)]
pub struct AppState {
pub db: Database,
pub cfg: ApiConfig,
}
impl AppState {
pub fn new(db: Database) -> Arc<Self> {
let ab_legacy_mode = matches!(
get_arg_or("ab-legacy-mode", "off".to_string())
.to_ascii_lowercase()
.as_str(),
"on" | "y" | "yes" | "true" | "1"
);
let ab_max_peers_per_book: i64 = get_arg_or("ab-max-peers-per-book", "100".to_string())
.parse()
.unwrap_or(100);
let recording_dir =
PathBuf::from(get_arg_or("recording-dir", "./recordings".to_string()));
let recording_max_size_bytes: u64 = get_arg_or("recording-max-size-mb", "0".to_string())
.parse::<u64>()
.unwrap_or(0)
.saturating_mul(1024 * 1024);
let audit_retention_days: i64 = get_arg_or("audit-retention-days", "0".to_string())
.parse()
.unwrap_or(0);
let email = build_email_config();
let public_base_url = get_arg("public-base-url");
let admin_ui_dir = get_arg_or("admin-ui-dir", "./admin_ui".to_string());
// login_options advertises every login method this server accepts.
// The Flutter client uses this to render the matching button on the
// sign-in dialog. `email_code` and `oidc/<name>` are opt-in so a
// deployment without SMTP / OIDC doesn't dangle a broken button.
let mut login_options = vec!["account".to_string()];
if email.is_some() || std::env::var("ALLOW_DEV_EMAIL_CODE").is_ok() {
login_options.push("email_code".to_string());
}
// OIDC providers are mounted dynamically — actual provider names are
// appended later by the oidc::providers loader once the DB rows exist.
Arc::new(Self {
db,
cfg: ApiConfig {
login_options,
sysinfo_ver: "m1-1".to_string(),
session_ttl_secs: 30 * 86400,
ab_legacy_mode,
ab_max_peers_per_book,
recording_dir,
recording_max_size_bytes,
audit_retention_days,
email,
public_base_url,
admin_ui_dir,
},
})
}
}
fn build_email_config() -> Option<EmailConfig> {
let host = get_arg("smtp-host");
if host.is_empty() {
return None;
}
let port: u16 = get_arg_or("smtp-port", "587".to_string())
.parse()
.unwrap_or(587);
let username = {
let u = get_arg("smtp-user");
if u.is_empty() { None } else { Some(u) }
};
let password = {
let p = get_arg("smtp-pass");
if p.is_empty() { None } else { Some(p) }
};
let from = {
let f = get_arg("smtp-from");
if f.is_empty() {
format!("noreply@{}", host)
} else {
f
}
};
let starttls = matches!(
get_arg_or("smtp-tls", "on".to_string()).to_ascii_lowercase().as_str(),
"on" | "y" | "yes" | "true" | "1"
);
Some(EmailConfig {
host,
port,
username,
password,
from,
starttls,
})
}
+53
View File
@@ -0,0 +1,53 @@
//! Strategy resolver for the heartbeat path. The actual SQL lives in
//! `Database::strategy_resolve_for` — this module exists to give the
//! heartbeat handler a stable import surface and to centralize how a
//! resolved strategy is converted into the wire-shape JSON the client
//! expects (`strategy.config_options` + `strategy.extra` per
//! CONSOLE_API.md §6.1).
use crate::api::state::AppState;
use crate::database::ResolvedStrategy;
use serde_json::{json, Value};
/// Resolve and serialize a strategy for `peer_id`. Returns
/// `(modified_at, strategy_value)` where `strategy_value` is the JSON object
/// the heartbeat reply embeds under `strategy`. When no strategy applies, we
/// return an empty `{config_options: {}, extra: {}}` and `modified_at = 0`.
pub async fn resolve_for(state: &AppState, peer_id: &str) -> (i64, Value) {
let resolved = state
.db
.strategy_resolve_for(peer_id)
.await
.unwrap_or_default();
serialize(&resolved)
}
fn serialize(r: &ResolvedStrategy) -> (i64, Value) {
let cfg: Value = serde_json::from_str(&r.config_options_json).unwrap_or_else(|_| json!({}));
let extra: Value = serde_json::from_str(&r.extra_json).unwrap_or_else(|_| json!({}));
(
r.modified_at,
json!({
"config_options": cfg,
"extra": extra,
}),
)
}
/// `true` iff the resolved strategy for `peer_id` carries
/// `config_options."enable-remote-exec" = "Y"`. Default is `false` (no
/// strategy assigned, or strategy didn't set the key) — exec is opt-in
/// per the design in docs/AGENT-API-AUTH.md.
pub async fn allows_remote_exec(state: &AppState, peer_id: &str) -> bool {
let resolved = state
.db
.strategy_resolve_for(peer_id)
.await
.unwrap_or_default();
let cfg: Value = serde_json::from_str(&resolved.config_options_json)
.unwrap_or_else(|_| json!({}));
cfg.get("enable-remote-exec")
.and_then(|v| v.as_str())
.map(|s| s.eq_ignore_ascii_case("Y"))
.unwrap_or(false)
}
+88
View File
@@ -0,0 +1,88 @@
use crate::api::device_auth::{self, AuthOutcome};
use crate::api::error::ApiError;
use crate::api::state::AppState;
use axum::body::Bytes;
use axum::extract::Extension;
use axum::http::HeaderMap;
use serde_json::Value;
use std::sync::Arc;
/// Plain-text version string that the client compares against its cached
/// `sysinfo_ver`. Same value the heartbeat handler echoes via the
/// `sysinfo: true` flag.
pub async fn sysinfo_ver(Extension(state): Extension<Arc<AppState>>) -> String {
state.cfg.sysinfo_ver.clone()
}
/// Bare-string body: `"SYSINFO_UPDATED"` or `"ID_NOT_FOUND"`. The client at
/// /Users/sn0/Desktop/rustdesk/src/hbbs_http/sync.rs:212 does a literal
/// `==` comparison on these — do not wrap in JSON.
pub async fn sysinfo(
Extension(state): Extension<Arc<AppState>>,
headers: HeaderMap,
body: Bytes,
) -> Result<String, ApiError> {
// Step 1: signature gate. Verified → trust the id from the signed
// header. LegacyUnsigned → fall through but enforce that the body id
// isn't a managed peer (would be downgrade attempt).
let outcome = device_auth::verify(&state, "POST", "/api/sysinfo", &headers, &body).await?;
// Step 2: parse body.
let payload: Value = serde_json::from_slice(&body)
.map_err(|_| ApiError::BadRequest("invalid json".into()))?;
let body_id = payload
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default();
let uuid = payload
.get("uuid")
.and_then(|v| v.as_str())
.unwrap_or_default();
if body_id.is_empty() || uuid.is_empty() {
return Err(ApiError::BadRequest("id and uuid required".into()));
}
// Step 3: bind the trusted identity to the body. For signed requests,
// the body id must match the header id — otherwise the agent is trying
// to write inventory for someone else.
let id = match outcome {
AuthOutcome::Verified { id: signed_id } => {
if body_id != signed_id {
return Err(ApiError::Unauthorized);
}
signed_id
}
AuthOutcome::LegacyUnsigned => {
device_auth::enforce_managed_for_id(&state, body_id).await?;
body_id.to_string()
}
};
// Tie sysinfo storage to a real rendezvous-registered peer. Without this
// gate, any caller could populate device_sysinfo for arbitrary IDs.
let peer = state
.db
.get_peer(&id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if peer.is_none() {
return Ok("ID_NOT_FOUND".to_string());
}
let version = parse_version_number(payload.get("version").and_then(|v| v.as_str()));
state
.db
.sysinfo_upsert(&id, uuid, &payload.to_string(), &state.cfg.sysinfo_ver, version)
.await?;
Ok("SYSINFO_UPDATED".to_string())
}
fn parse_version_number(s: Option<&str>) -> i64 {
let Some(s) = s else { return 0 };
// hbb_common encodes "1.4.2" as 1*1_000_000 + 4*1_000 + 2 = 1_004_002.
let mut parts = s.split('.').map(|p| p.parse::<i64>().unwrap_or(0));
let major = parts.next().unwrap_or(0);
let minor = parts.next().unwrap_or(0);
let patch = parts.next().unwrap_or(0);
major * 1_000_000 + minor * 1_000 + patch
}
+147
View File
@@ -0,0 +1,147 @@
//! `POST /api/2fa/enroll` — admin-only TOTP enrollment.
//!
//! Generates a fresh 20-byte (160-bit) base32 secret, stores it for the
//! target user, and returns:
//! - `secret_b32` — the literal secret to enter into an authenticator app.
//! - `otpauth_url` — the standard `otpauth://totp/...` URL the same apps
//! accept as a QR-code or pasted-string.
//!
//! There is no client-facing UI for this in the desktop app; operators run it
//! by curl after creating the user. M4's `--bootstrap-admin-username` admin
//! is the natural caller.
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use axum::extract::Extension;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use std::sync::Arc;
use totp_rs::Secret;
#[derive(Debug, Deserialize)]
pub struct EnrollBody {
/// Either `user_id` or `username` is required. `user_id` wins if both
/// are present.
#[serde(default)]
pub user_id: Option<i64>,
#[serde(default)]
pub username: Option<String>,
/// Issuer name shown in the authenticator app. Defaults to "RustDesk".
#[serde(default)]
pub issuer: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UnenrollBody {
#[serde(default)]
pub user_id: Option<i64>,
#[serde(default)]
pub username: Option<String>,
}
pub async fn enroll(
Extension(state): Extension<Arc<AppState>>,
caller: AuthedUser,
Json(body): Json<EnrollBody>,
) -> Result<Json<Value>, ApiError> {
if !caller.is_admin {
return Err(ApiError::Forbidden("admin required".into()));
}
let user = resolve_target(&state, body.user_id, body.username.as_deref()).await?;
// 20 random bytes -> base32 (the standard size for SHA1 TOTP).
let raw = sodiumoxide::randombytes::randombytes(20);
let secret_b32 = Secret::Raw(raw.clone()).to_encoded().to_string();
state
.db
.totp_enroll(user.id, &secret_b32)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let issuer = body
.issuer
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or("RustDesk");
// Build the otpauth:// URL manually rather than depend on totp-rs's
// URL helpers (their API has shifted between minor versions). Format
// per https://github.com/google/google-authenticator/wiki/Key-Uri-Format.
let otpauth_url = format!(
"otpauth://totp/{issuer}:{account}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits=6&period=30",
issuer = url_encode(issuer),
account = url_encode(&user.username),
secret = url_encode(&secret_b32),
);
Ok(Json(json!({
"user_id": user.id,
"username": user.username,
"secret_b32": secret_b32,
"otpauth_url": otpauth_url,
})))
}
pub async fn unenroll(
Extension(state): Extension<Arc<AppState>>,
caller: AuthedUser,
Json(body): Json<UnenrollBody>,
) -> Result<Json<Value>, ApiError> {
if !caller.is_admin {
return Err(ApiError::Forbidden("admin required".into()));
}
let user = resolve_target(&state, body.user_id, body.username.as_deref()).await?;
let removed = state
.db
.totp_unenroll(user.id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(json!({ "removed": removed })))
}
/// Minimal percent-encoder for the otpauth URL fields. Encodes anything
/// outside the unreserved URL set (`A-Za-z0-9-_.~`) — keeps the URL short
/// and avoids pulling in `urlencoding` just for this single call site.
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*b as char);
}
_ => {
use std::fmt::Write;
let _ = write!(out, "%{:02X}", b);
}
}
}
out
}
async fn resolve_target(
state: &AppState,
user_id: Option<i64>,
username: Option<&str>,
) -> Result<crate::database::UserRow, ApiError> {
if let Some(id) = user_id {
return state
.db
.user_find_by_id(id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::NotFound);
}
if let Some(name) = username.filter(|s| !s.is_empty()) {
return state
.db
.user_find_by_username(name)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::NotFound);
}
Err(ApiError::BadRequest(
"user_id or username required".into(),
))
}
+90
View File
@@ -0,0 +1,90 @@
//! `POST /api/unattended-password` — agent-side reporting of the per-boot
//! "permanent password" used for unattended access (no logged-in user to
//! click the approval popup). hello-agent generates a random password
//! every time the service starts and posts it here so the admin UI can
//! surface it for support staff.
//!
//! Auth: same per-peer signed-API gate as `/api/sysinfo` and
//! `/api/heartbeat` — see [`crate::api::device_auth`]. Managed peers
//! (`peer.managed = 1`) must carry a valid Ed25519 signature; stock
//! clients keep posting unsigned and the first valid signature TOFU-
//! promotes the peer.
use crate::api::device_auth::{self, AuthOutcome};
use crate::api::error::ApiError;
use crate::api::state::AppState;
use axum::body::Bytes;
use axum::extract::Extension;
use axum::http::HeaderMap;
use serde_json::Value;
use std::sync::Arc;
/// Body: `{"id": "...", "uuid": "...", "password": "..."}`
/// Response (bare string, like sysinfo): `"OK"` or `"ID_NOT_FOUND"`.
pub async fn unattended_password(
Extension(state): Extension<Arc<AppState>>,
headers: HeaderMap,
body: Bytes,
) -> Result<String, ApiError> {
let outcome = device_auth::verify(
&state,
"POST",
"/api/unattended-password",
&headers,
&body,
)
.await?;
let payload: Value = serde_json::from_slice(&body)
.map_err(|_| ApiError::BadRequest("invalid json".into()))?;
let body_id = payload
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default();
let uuid = payload
.get("uuid")
.and_then(|v| v.as_str())
.unwrap_or_default();
let password = payload
.get("password")
.and_then(|v| v.as_str())
.unwrap_or_default();
if body_id.is_empty() || uuid.is_empty() || password.is_empty() {
return Err(ApiError::BadRequest(
"id, uuid, and password are required".into(),
));
}
// Bind the trusted identity to the body. For signed requests the body
// id must match the header id, or the agent is trying to overwrite
// someone else's displayed password. For unsigned requests we just
// need to ensure the peer isn't already locked down as managed.
let id = match outcome {
AuthOutcome::Verified { id: signed_id } => {
if body_id != signed_id {
return Err(ApiError::Unauthorized);
}
signed_id
}
AuthOutcome::LegacyUnsigned => {
device_auth::enforce_managed_for_id(&state, body_id).await?;
body_id.to_string()
}
};
let peer = state
.db
.get_peer(&id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if peer.is_none() {
return Ok("ID_NOT_FOUND".to_string());
}
state
.db
.set_unattended_password(&id, uuid, password)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok("OK".to_string())
}
+77
View File
@@ -0,0 +1,77 @@
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::pagination::{Page, PageQuery};
use crate::api::state::AppState;
use crate::database::UserRow;
use axum::extract::{Extension, Query};
use axum::Json;
use hbb_common::ResultType;
use serde::Serialize;
use serde_json::{json, Value};
use std::sync::Arc;
#[derive(Debug, Serialize)]
pub struct UserPayload {
pub name: String,
pub display_name: String,
pub avatar: String,
pub email: String,
pub note: String,
pub status: i64,
pub is_admin: bool,
/// The desktop client's OIDC poll loop deserializes the AuthBody using
/// the Rust struct in src/hbbs_http/account.rs, where `info` is a
/// REQUIRED field (no #[serde(default)]). Missing it makes serde fail,
/// the poll loop's `Ok(_)` arm fires, and the client polls forever
/// even though the OIDC session was successful. Emit an empty object
/// — the client's own UserInfo defaults handle the rest.
pub info: Value,
}
impl From<&UserRow> for UserPayload {
fn from(u: &UserRow) -> Self {
Self {
name: u.username.clone(),
display_name: u.display_name.clone(),
avatar: u.avatar.clone(),
email: u.email.clone(),
note: u.note.clone(),
status: u.status,
is_admin: u.is_admin,
info: json!({}),
}
}
}
pub async fn hash_password(plain: String) -> ResultType<String> {
Ok(
hbb_common::tokio::task::spawn_blocking(move || bcrypt::hash(plain, 10))
.await??,
)
}
pub async fn verify_password(hash: String, plain: String) -> ResultType<bool> {
Ok(
hbb_common::tokio::task::spawn_blocking(move || bcrypt::verify(plain, &hash))
.await??,
)
}
/// `GET /api/users` — paginated list of users visible to the caller. Admin
/// sees all enabled users; non-admin sees themselves plus members of any
/// device-group they share. Flutter decoder at common/hbbs/hbbs.dart:26.
pub async fn list(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Query(q): Query<PageQuery>,
) -> Result<Json<Page<UserPayload>>, ApiError> {
let (total, rows) = state
.db
.users_list_accessible(user.user_id, user.is_admin, q.offset(), q.limit())
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(Page {
total,
data: rows.iter().map(UserPayload::from).collect(),
}))
}
+23 -1
View File
@@ -1,6 +1,7 @@
use clap::App;
use hbb_common::{
allow_err, anyhow::{Context, Result}, get_version_number, log, tokio, ResultType
allow_err, anyhow::{Context, Result}, get_version_number, log, tcp::listen_any, tokio,
tokio::net::TcpListener, ResultType,
};
use ini::Ini;
use sodiumoxide::crypto::sign;
@@ -11,6 +12,27 @@ use std::{
time::{Instant, SystemTime},
};
/// Bind a TCP listener for `port`. When `host` is empty (the default for
/// every flag that accepts it), falls through to `listen_any` which binds
/// the dual-stack `[::]` wildcard. When `host` is set, binds only to that
/// address — used by deployments that put nginx/Caddy out front for TLS
/// termination on the WS / HTTP ports and want hbbs/hbbr's plain sockets
/// reachable only from localhost.
pub async fn bind_tcp_listener(host: &str, port: i32) -> ResultType<TcpListener> {
if host.is_empty() {
return listen_any(port as u16).await;
}
let host_with_brackets = if host.contains(':') && !host.starts_with('[') {
format!("[{}]", host)
} else {
host.to_string()
};
let addr: SocketAddr = format!("{}:{}", host_with_brackets, port).parse()?;
let l = TcpListener::bind(addr).await?;
log::info!("listen on tcp {}", addr);
Ok(l)
}
#[allow(dead_code)]
pub(crate) fn get_expired_time() -> Instant {
let now = Instant::now();
+4114 -1
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -15,6 +15,7 @@ fn main() -> ResultType<()> {
let args = format!(
"-p, --port=[NUMBER(default={RELAY_PORT})] 'Sets the listening port'
-k, --key=[KEY] 'Only allow the client with the same key'
--ws-listen=[HOST] 'Bind address for the browser-facing WebSocket relay port (port+2). Default = wildcard. Set to 127.0.0.1 (or ::1) when a reverse proxy claims the public port for TLS termination.'
",
);
let matches = App::new("hbbr")
@@ -40,6 +41,7 @@ fn main() -> ResultType<()> {
matches
.value_of("key")
.unwrap_or(&std::env::var("KEY").unwrap_or_default()),
matches.value_of("ws-listen").unwrap_or(""),
)?;
Ok(())
}
+2 -1
View File
@@ -1,6 +1,7 @@
mod rendezvous_server;
pub use rendezvous_server::*;
pub mod api;
pub mod common;
mod database;
pub mod database;
mod peer;
mod version;
+32 -1
View File
@@ -21,6 +21,26 @@ fn main() -> ResultType<()> {
-u, --software-url=[URL] 'Sets download url of RustDesk software of newest version'
-r, --relay-servers=[HOST] 'Sets the default relay servers, separated by comma'
-M, --rmem=[NUMBER(default={RMEM})] 'Sets UDP recv buffer size, set system rmem_max first, e.g., sudo sysctl -w net.core.rmem_max=52428800. vi /etc/sysctl.conf, net.core.rmem_max=52428800, sudo sysctl p'
--http-port=[NUMBER(default=21114)] 'HTTP management API port (0 disables)'
--http-listen=[HOST] 'Bind address for --http-port. Default = wildcard. Set to 127.0.0.1 (or ::1) when nginx/Caddy fronts this port for TLS.'
--ws-listen=[HOST] 'Bind address for the browser-facing WebSocket rendezvous port (port+2). Default = wildcard. Set to 127.0.0.1 (or ::1) when a reverse proxy claims the public port for TLS termination.'
--bootstrap-admin-username=[USERNAME] 'Username to seed on first startup if users table is empty'
--bootstrap-admin-password=[PASSWORD] 'Password to seed on first startup if users table is empty'
--ab-legacy-mode=[on|off] 'When on, /api/ab/personal returns 404 to force legacy single-blob AB'
--ab-max-peers-per-book=[NUMBER(default=100)] 'Surfaced via /api/ab/settings.max_peer_one_ab'
--recording-dir=[PATH(default=./recordings)] 'Root directory for /api/record uploads'
--recording-max-size-mb=[NUMBER] 'Optional ceiling per recording file; 0 or unset = unlimited'
--audit-retention-days=[NUMBER] 'Hourly task deletes audit rows older than N days; 0 disables'
--smtp-host=[HOST] 'SMTP host for email-code login; if empty, codes are logged to stdout (dev mode)'
--smtp-port=[NUMBER(default=587)] 'SMTP port'
--smtp-user=[USER] 'SMTP username (omit for unauthenticated relays)'
--smtp-pass=[PASS] 'SMTP password'
--smtp-from=[ADDR] 'From: address for outbound login emails (default: noreply@<smtp-host>)'
--smtp-tls=[on|off] 'STARTTLS on the SMTP connection (default: on)'
--public-base-url=[URL] 'Externally reachable HTTP base URL (e.g. https://rustdesk.example.com:21114) — required for OIDC redirect callbacks'
--oidc-config=[PATH] 'TOML file describing OIDC providers (upserted into oidc_providers at startup)'
--admin-ui-dir=[PATH] 'Directory of static admin-dashboard files served at /admin/ (default: ./admin_ui; empty disables)'
--unattended-pwd-visibility=[always|logged-out] 'When the admin UI shows a device unattended password. logged-out (default) = only when nobody is logged in; always = also while a user is logged in'
, --mask=[MASK] 'Determine if the connection comes from LAN, e.g. 192.168.0.0/16'
-k, --key=[KEY] 'Only allow the client with the same key'",
);
@@ -31,7 +51,18 @@ fn main() -> ResultType<()> {
}
let rmem = get_arg("rmem").parse::<usize>().unwrap_or(RMEM);
let serial: i32 = get_arg("serial").parse().unwrap_or(0);
let http_port: i32 = get_arg_or("http-port", "21114".to_string())
.parse()
.unwrap_or(21114);
crate::common::check_software_update();
RendezvousServer::start(port, serial, &get_arg_or("key", "-".to_owned()), rmem)?;
RendezvousServer::start(
port,
serial,
&get_arg_or("key", "-".to_owned()),
rmem,
http_port,
&get_arg("ws-listen"),
&get_arg("http-listen"),
)?;
Ok(())
}
+13 -2
View File
@@ -46,7 +46,7 @@ const BLACKLIST_FILE: &str = "blacklist.txt";
const BLOCKLIST_FILE: &str = "blocklist.txt";
#[tokio::main(flavor = "multi_thread")]
pub async fn start(port: &str, key: &str) -> ResultType<()> {
pub async fn start(port: &str, key: &str, ws_listen: &str) -> ResultType<()> {
let key = get_server_sk(key);
if let Ok(mut file) = std::fs::File::open(BLACKLIST_FILE) {
let mut contents = String::new();
@@ -82,10 +82,21 @@ pub async fn start(port: &str, key: &str) -> ResultType<()> {
log::info!("Listening on tcp :{}", port);
let port2 = port + 2;
log::info!("Listening on websocket :{}", port2);
// The WS port (21119 default) is the only browser-facing endpoint at
// hbbr — operators put nginx/Caddy in front of it for TLS. Allow
// pinning it to localhost so the reverse proxy can claim the public
// port without colliding. The plain TCP relay port (21117) is for
// desktop clients and stays on the wildcard.
let ws_listen = ws_listen.to_owned();
let main_task = async move {
loop {
log::info!("Start");
io_loop(listen_any(port).await?, listen_any(port2).await?, &key).await;
io_loop(
listen_any(port).await?,
crate::common::bind_tcp_listener(&ws_listen, port2 as i32).await?,
&key,
)
.await;
}
};
let listen_signal = crate::common::listen_signal();
+251 -11
View File
@@ -8,7 +8,7 @@ use hbb_common::{
futures::future::join_all,
futures_util::{
sink::SinkExt,
stream::{SplitSink, StreamExt},
stream::{SplitSink, SplitStream, StreamExt},
},
log,
protobuf::{Message as _, MessageField},
@@ -16,7 +16,7 @@ use hbb_common::{
register_pk_response::Result::{TOO_FREQUENT, UUID_MISMATCH},
*,
},
tcp::{listen_any, FramedStream},
tcp::{listen_any, Encrypt, FramedStream},
timeout,
tokio::{
self,
@@ -31,7 +31,7 @@ use hbb_common::{
AddrMangle, ResultType,
};
use ipnetwork::Ipv4Network;
use sodiumoxide::crypto::sign;
use sodiumoxide::crypto::{box_, sign};
use std::{
collections::HashMap,
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
@@ -49,9 +49,14 @@ enum Data {
const REG_TIMEOUT: i32 = 30_000;
type TcpStreamSink = SplitSink<Framed<TcpStream, BytesCodec>, Bytes>;
type TcpStreamSrc = SplitStream<Framed<TcpStream, BytesCodec>>;
type WsSink = SplitSink<tokio_tungstenite::WebSocketStream<TcpStream>, tungstenite::Message>;
enum Sink {
TcpStream(TcpStreamSink),
/// Plain or encrypted TCP. The optional `Encrypt` is only present after a
/// successful server-initiated `secure_tcp` handshake — see
/// `try_secure_tcp_handshake`. When `Some`, every outgoing message is
/// sealed with secretbox before being framed.
TcpStream(TcpStreamSink, Option<Encrypt>),
Ws(WsSink),
}
type Sender = mpsc::UnboundedSender<Data>;
@@ -99,11 +104,62 @@ enum LoopFailure {
impl RendezvousServer {
#[tokio::main(flavor = "multi_thread")]
pub async fn start(port: i32, serial: i32, key: &str, rmem: usize) -> ResultType<()> {
pub async fn start(
port: i32,
serial: i32,
key: &str,
rmem: usize,
http_port: i32,
ws_listen: &str,
http_listen: &str,
) -> ResultType<()> {
let (key, sk) = Self::get_server_sk(key);
let nat_port = port - 1;
let ws_port = port + 2;
// Capture the bind addresses as owned Strings so the async move
// closures below can hold onto them across reconnect retries.
let ws_listen = ws_listen.to_owned();
let http_listen = http_listen.to_owned();
let pm = PeerMap::new().await?;
// M1: build the HTTP API state and seed the admin user if requested.
// Done here (right after PeerMap::new) so the API server, the seeding,
// and the rendezvous loop all share the same Database connection pool.
let api_state = crate::api::AppState::new(pm.db.clone());
// M4: hand the same Ed25519 secret used for the rendezvous key
// exchange to the plugin-signing handler. Without this set,
// POST /lic/web/api/plugin-sign returns "plugin signing not configured".
if let Some(sk_ref) = sk.clone() {
crate::api::plugin_sign::set_signing_key(sk_ref);
}
// M4: load operator-supplied OIDC providers from --oidc-config (TOML).
// Errors are logged but don't kill the server — the operator can
// hand-insert into oidc_providers as a fallback.
let oidc_path = get_arg("oidc-config");
if !oidc_path.is_empty() {
let public_base = api_state.cfg.public_base_url.clone();
let db = pm.db.clone();
match crate::api::oidc::providers::load_from_file(
&db,
std::path::Path::new(&oidc_path),
&public_base,
)
.await
{
Ok(n) => log::info!("oidc: loaded {} providers from {}", n, oidc_path),
Err(e) => log::warn!("oidc: failed to load {}: {}", oidc_path, e),
}
}
{
let bn = get_arg("bootstrap-admin-username");
let bp = get_arg("bootstrap-admin-password");
if !bn.is_empty() && !bp.is_empty() {
if let Err(e) = pm.db.bootstrap_admin(&bn, &bp).await {
log::warn!("bootstrap admin failed: {}", e);
}
} else {
pm.db.warn_if_no_users().await;
}
}
log::info!("serial={}", serial);
let rendezvous_servers = get_servers(&get_arg("rendezvous-servers"), "rendezvous-servers");
log::info!("Listening on tcp/udp :{}", port);
@@ -149,7 +205,11 @@ impl RendezvousServer {
rs.parse_relay_servers(&get_arg("relay-servers"));
let mut listener = create_tcp_listener(port).await?;
let mut listener2 = create_tcp_listener(nat_port).await?;
let mut listener3 = create_tcp_listener(ws_port).await?;
// The WS port is the only browser-facing endpoint at hbbs — it's
// the one operators put nginx/Caddy in front of for TLS. Allow
// pinning it to localhost so the reverse proxy can claim
// `[::]:21118` without colliding.
let mut listener3 = crate::common::bind_tcp_listener(&ws_listen, ws_port).await?;
let test_addr = std::env::var("TEST_HBBS").unwrap_or_default();
if std::env::var("ALWAYS_USE_RELAY")
.unwrap_or_default()
@@ -216,15 +276,37 @@ impl RendezvousServer {
}
LoopFailure::Listener3 => {
drop(listener3);
listener3 = create_tcp_listener(ws_port).await?;
listener3 = crate::common::bind_tcp_listener(&ws_listen, ws_port).await?;
}
}
}
};
let listen_signal = listen_signal();
// The HTTP API task. `pending()` keeps the select! arm well-typed
// when the operator disabled it via `--http-port=0` — that branch
// never fires.
let api_task: std::pin::Pin<
Box<dyn std::future::Future<Output = ResultType<()>> + Send>,
> = if http_port > 0 {
let bind_host = if http_listen.is_empty() { "0.0.0.0" } else { http_listen.as_str() };
// Allow IPv6 / [::1] / hostnames — wrap bare IPv6 in brackets for the URL form.
let host_with_brackets = if bind_host.contains(':') && !bind_host.starts_with('[') {
format!("[{}]", bind_host)
} else {
bind_host.to_string()
};
let addr: SocketAddr = format!("{}:{}", host_with_brackets, http_port).parse()?;
log::info!("HTTP API listening on {}", addr);
let st = api_state.clone();
Box::pin(crate::api::serve(addr, st))
} else {
log::info!("HTTP API disabled (http-port = 0)");
Box::pin(std::future::pending::<ResultType<()>>())
};
tokio::select!(
res = main_task => res,
res = listen_signal => res,
res = api_task => res,
)
}
@@ -562,6 +644,16 @@ impl RendezvousServer {
});
Self::send_to_sink(sink, msg_out).await;
}
// M4: HTTP-over-rendezvous fallback. The client uses this when
// OPTION_USE_RAW_TCP_FOR_API=Y (locked-down networks where
// direct HTTPS is blocked). We dispatch the wrapped request
// through the SAME axum router as the HTTP listener.
Some(rendezvous_message::Union::HttpProxyRequest(req)) => {
let resp = crate::api::http_proxy::dispatch(req).await;
let mut msg_out = RendezvousMessage::new();
msg_out.set_http_proxy_response(resp);
Self::send_to_sink(sink, msg_out).await;
}
_ => {}
}
}
@@ -831,7 +923,12 @@ impl RendezvousServer {
if let Some(sink) = sink.as_mut() {
if let Ok(bytes) = msg.write_to_bytes() {
match sink {
Sink::TcpStream(s) => {
Sink::TcpStream(s, enc) => {
let bytes = if let Some(enc) = enc.as_mut() {
enc.enc(&bytes)
} else {
bytes
};
allow_err!(s.send(Bytes::from(bytes)).await);
}
Sink::Ws(ws) => {
@@ -1185,9 +1282,70 @@ impl RendezvousServer {
}
}
} else {
let (a, mut b) = Framed::new(stream, BytesCodec::new()).split();
sink = Some(Sink::TcpStream(a));
while let Ok(Some(Ok(bytes))) = timeout(30_000, b.next()).await {
let (mut a, mut b) = Framed::new(stream, BytesCodec::new()).split();
// Server-initiated secure_tcp handshake. Only attempted when the
// server has a signing key (the default — `--key=-` auto-generates
// one). Signs an ephemeral box public key and sends it to the
// client; the client may either reply with a sealed symmetric key
// (the secure path used by logged-in clients, see
// src/client.rs:427-431 and src/common.rs:1939) or send a regular
// protobuf message (plain mode). Plain-mode clients filter out
// unsolicited KeyExchange via get_next_nonkeyexchange_msg, so the
// KeyExchange we just emitted is harmless to them.
let mut decrypter: Option<Encrypt> = None;
let mut buffered_first: Option<BytesMut> = None;
if let Some(sk) = self.inner.sk.clone() {
log::info!("secure_tcp: handshake starting for {}", addr);
match try_secure_tcp_handshake(&mut a, &mut b, &sk).await {
Ok(HandshakeOutcome::Secure(enc)) => {
let send_state = enc.clone();
decrypter = Some(enc);
log::info!("secure_tcp: handshake completed (encrypted) for {}", addr);
sink = Some(Sink::TcpStream(a, Some(send_state)));
}
Ok(HandshakeOutcome::Plain(bytes)) => {
log::info!(
"secure_tcp: client sent plain first message ({} bytes) from {}",
bytes.len(),
addr
);
buffered_first = Some(bytes);
sink = Some(Sink::TcpStream(a, None));
}
Ok(HandshakeOutcome::Skip) => {
log::info!(
"secure_tcp: handshake window timed out (client never replied) for {}",
addr
);
sink = Some(Sink::TcpStream(a, None));
}
Err(e) => {
log::warn!("secure_tcp: handshake error for {}: {}", addr, e);
sink = Some(Sink::TcpStream(a, None));
}
}
} else {
log::debug!("secure_tcp: no signing key configured; skipping handshake");
sink = Some(Sink::TcpStream(a, None));
}
// Replay the message we already consumed during the handshake
// window before entering the normal read loop.
if let Some(bytes) = buffered_first {
if !self.handle_tcp(&bytes, &mut sink, addr, key, ws).await {
if sink.is_none() {
self.tcp_punch.lock().await.remove(&try_into_v4(addr));
}
log::debug!("Tcp connection from {:?} closed", addr);
return Ok(());
}
}
while let Ok(Some(Ok(mut bytes))) = timeout(30_000, b.next()).await {
if let Some(dec) = decrypter.as_mut() {
if let Err(e) = dec.dec(&mut bytes) {
log::warn!("decryption error from {}: {}", addr, e);
break;
}
}
if !self.handle_tcp(&bytes, &mut sink, addr, key, ws).await {
break;
}
@@ -1369,3 +1527,85 @@ async fn create_tcp_listener(port: i32) -> ResultType<TcpListener> {
log::debug!("listen on tcp {:?}", s.local_addr());
Ok(s)
}
/// Outcome of the server-initiated `secure_tcp` handshake on a fresh TCP
/// rendezvous connection. The matching client code lives in
/// /Users/sn0/Desktop/rustdesk/src/common.rs:1939 (`secure_tcp_impl`).
enum HandshakeOutcome {
/// Client cooperated; the resulting `Encrypt` is shared between the
/// inbound decrypter and the outbound `Sink`.
Secure(Encrypt),
/// Client did not opt into encryption — first message we read is a
/// regular `RendezvousMessage`. We hand the bytes back to the caller so
/// they can be dispatched via `handle_tcp` before the read loop begins.
Plain(BytesMut),
/// No first message arrived within the handshake window. Fall through
/// to plain mode; the next `b.next()` in the main read loop will pick
/// up whatever the client eventually sends.
Skip,
}
/// Server-side counterpart to the client's `secure_tcp_impl`. Sends a signed
/// ephemeral box public key, then reads the first message:
///
/// 1. If it's a `KeyExchange` carrying `[client_box_pk, sealed_sym_key]`,
/// decrypt the sealed sym key with our box secret and return an `Encrypt`
/// initialised from that key — ready to use on both directions.
/// 2. If it's any other `RendezvousMessage`, return the bytes verbatim so
/// the caller can dispatch them as if no handshake had happened.
///
/// Plain-mode clients (no API token configured) skip unsolicited
/// `KeyExchange` via `get_next_nonkeyexchange_msg` on their side, so the
/// `KeyExchange` we emit unconditionally is ignored when the client hasn't
/// opted into encryption.
async fn try_secure_tcp_handshake(
sink: &mut TcpStreamSink,
src: &mut TcpStreamSrc,
sk: &sign::SecretKey,
) -> ResultType<HandshakeOutcome> {
// Ephemeral Curve25519 keypair for this connection only.
let (our_pk_b, our_sk_b) = box_::gen_keypair();
// Sign the public key with our long-lived Ed25519 sign key. The client
// verifies this signature using the public key the user pasted into
// their RustDesk settings.
let signed = sign::sign(&our_pk_b.0, sk);
let mut msg_out = RendezvousMessage::new();
msg_out.set_key_exchange(KeyExchange {
keys: vec![Bytes::from(signed)],
..Default::default()
});
let bytes = msg_out.write_to_bytes()?;
log::info!("secure_tcp: sending KeyExchange ({} bytes payload)", bytes.len());
sink.send(Bytes::from(bytes)).await?;
// Wait briefly for the client's reply. 5 s is comfortably below the
// client's READ_TIMEOUT and the server-loop 30 s timeout, so a slow
// plain-mode client just falls through to `Skip`.
match timeout(5_000, src.next()).await {
Ok(Some(Ok(bytes))) => {
log::info!("secure_tcp: received reply ({} bytes)", bytes.len());
if let Ok(msg_in) = RendezvousMessage::parse_from_bytes(&bytes) {
if let Some(rendezvous_message::Union::KeyExchange(ex)) = msg_in.union {
if ex.keys.len() != 2 {
bail!(
"invalid key exchange response: keys.len() = {}",
ex.keys.len()
);
}
let key = Encrypt::decode(&ex.keys[1], &ex.keys[0], &our_sk_b)?;
return Ok(HandshakeOutcome::Secure(Encrypt::new(key)));
} else {
log::info!(
"secure_tcp: reply was a non-KeyExchange RendezvousMessage; treating as plain"
);
}
} else {
log::info!("secure_tcp: reply did not parse as RendezvousMessage; treating as plain");
}
Ok(HandshakeOutcome::Plain(bytes))
}
Ok(Some(Err(e))) => bail!("read error during handshake: {}", e),
Ok(None) => bail!("connection closed during handshake"),
Err(_) => Ok(HandshakeOutcome::Skip),
}
}
+7
View File
@@ -0,0 +1,7 @@
node_modules/
*.log
.DS_Store
# dist/ is INTENTIONALLY committed — see web_client/README.md.
# The Rust hbbs binary serves dist/bundle.js via include_bytes!, so
# committing it lets `cargo build` work without a Node toolchain.
+55
View File
@@ -0,0 +1,55 @@
# RustDesk web client
Browser-based RustDesk client embedded in `rustdesk-server`. Surfaced from the
admin dashboard as a "Connect" button on the Devices page.
## Architecture (one-liner)
Plain TypeScript SPA → talks WebSocket directly to `hbbs:21118` (rendezvous)
and `hbbr:21119` (relay) → `protobufjs` for wire format → `libsodium-wrappers`
for crypto → WebCodecs for video/audio decode → `<canvas>` for display.
No frameworks. ~1 MB minified.
## Building
```sh
./build.sh # bundles to dist/bundle.{js,css}
git add dist/
```
`dist/` is committed so `cargo build -p hbbs` doesn't need Node. Anyone
touching code under `src/` should re-run `./build.sh` and commit the new
`dist/` files in the same commit.
## Regenerating proto bindings
Rare — only when `libs/hbb_common` bumps and proto fields change:
```sh
npm run protogen
./build.sh
git add src/proto/generated.* dist/
```
## Layout
```
src/
main.ts boot: read #custom-config, init transport
crypto.ts libsodium wrapper
proto/ generated protobufjs static modules (committed)
transport/ rendezvous WS, relay WS, secure handshake state machine
decode/ video (WebCodecs VideoDecoder), audio (AudioDecoder)
input/ mouse/keyboard capture → protobuf MouseEvent/KeyEvent
ui/ canvas + toolbar + style.css
audit.ts POST /api/audit/conn with admin cookie
dist/
bundle.js + .css committed esbuild output
```
## Wire-protocol references
- `/Users/sn0/Desktop/rustdesk-server/libs/hbb_common/protos/{rendezvous,message}.proto`
- `/Users/sn0/Desktop/rustdesk/src/client.rs` — desktop-client connect/secure/login state machine
- `/Users/sn0/Desktop/rustdesk-server/libs/hbb_common/src/tcp.rs:296-344` — secretbox nonce derivation (8-byte LE counter, separate per direction)
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Build the web client bundle.
#
# Outputs: dist/bundle.js, dist/bundle.js.map, dist/bundle.css
#
# Re-run after editing anything under src/. Commit dist/* alongside source so
# `cargo build` doesn't need a Node toolchain.
#
# To regenerate the protobuf bindings (rare — when libs/hbb_common bumps):
# npm run protogen && npm run build
set -euo pipefail
cd "$(dirname "$0")"
if [ ! -d node_modules ]; then
echo "Installing npm dependencies..."
npm install
fi
mkdir -p dist
echo "Bundling JS..."
npm run --silent build:js
echo "Copying CSS..."
npm run --silent build:css
echo "Done. Bundle:"
ls -lh dist/bundle.js dist/bundle.css 2>/dev/null || true
+190
View File
@@ -0,0 +1,190 @@
/* RustDesk web client — minimal, dark theme to match the admin dashboard. */
html, body {
margin: 0;
padding: 0;
height: 100%;
background: #0f172a;
color: #e2e8f0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
#root {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.placeholder {
background: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
padding: 32px 40px;
max-width: 540px;
text-align: center;
}
.placeholder h1 {
margin: 0 0 16px;
font-size: 20px;
font-weight: 600;
}
.placeholder p {
margin: 8px 0;
font-size: 14px;
color: #cbd5e1;
}
.placeholder code {
background: #0f172a;
padding: 2px 6px;
border-radius: 3px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
color: #7dd3fc;
}
.muted { color: #64748b !important; font-size: 12px !important; }
.pw-form {
display: flex;
gap: 8px;
margin-top: 16px;
align-items: stretch;
}
.pw-form input[type="password"] {
flex: 1;
background: #0f172a;
border: 1px solid #334155;
color: #e2e8f0;
padding: 8px 10px;
border-radius: 4px;
font-size: 14px;
font-family: inherit;
}
.pw-form input[type="password"]:focus {
outline: none;
border-color: #38bdf8;
}
.pw-form button {
background: #0284c7;
border: 0;
color: #f0f9ff;
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.pw-form button:hover { background: #0369a1; }
/* Separator between "waiting for approval / cancel" and the unattended-
* password override on the awaiting-approval screen. */
.pw-divider {
border: none;
border-top: 1px solid rgba(148, 163, 184, 0.2);
margin: 20px 0 12px;
}
.error-inline {
background: rgba(220, 38, 38, 0.15);
border: 1px solid rgba(220, 38, 38, 0.4);
color: #fca5a5;
padding: 8px 10px;
border-radius: 4px;
font-size: 13px;
margin-top: 12px;
}
/* ------- Live session ------- */
.session {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
background: #000;
}
.rd-canvas {
flex: 1;
width: 100%;
height: 100%;
/* Letterbox: keep aspect ratio while fitting the browser viewport. */
object-fit: contain;
display: block;
background: #000;
}
.hud {
position: fixed;
top: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.55);
color: #cbd5e1;
font-size: 11px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
padding: 4px 8px;
border-radius: 4px;
z-index: 10;
display: flex;
align-items: center;
gap: 8px;
}
.hud-fps {
pointer-events: none;
}
.hud-btn {
background: #334155;
border: 0;
color: #e2e8f0;
padding: 3px 8px;
border-radius: 3px;
font-size: 11px;
font-family: inherit;
cursor: pointer;
}
.hud-btn:hover { background: #475569; }
.hud-select {
background: #334155;
border: 0;
color: #e2e8f0;
padding: 2px 4px;
border-radius: 3px;
font-size: 11px;
font-family: inherit;
cursor: pointer;
}
.hud-select:hover { background: #475569; }
.reconnect-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(15, 23, 42, 0.65);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
color: #e2e8f0;
font-family: inherit;
font-size: 15px;
z-index: 20;
pointer-events: none;
}
.error {
background: rgba(220, 38, 38, 0.15);
border: 1px solid rgba(220, 38, 38, 0.4);
border-radius: 8px;
padding: 24px 32px;
color: #fca5a5;
max-width: 640px;
}
.error h1 { margin: 0 0 12px; font-size: 18px; }
.error pre { white-space: pre-wrap; font-size: 13px; }
+30
View File
File diff suppressed because one or more lines are too long
+7
View File
File diff suppressed because one or more lines are too long
+1376
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "rustdesk-web-client",
"version": "0.1.0",
"private": true,
"description": "Browser-based RustDesk client embedded in rustdesk-server admin dashboard.",
"type": "module",
"scripts": {
"build": "./build.sh",
"build:js": "esbuild src/main.ts --bundle --minify --format=esm --outfile=dist/bundle.js --target=es2022 --sourcemap=external",
"build:css": "cp src/ui/style.css dist/bundle.css",
"protogen": "pbjs --target static-module --wrap es6 --es6 --keep-case -o src/proto/generated.js ../libs/hbb_common/protos/rendezvous.proto ../libs/hbb_common/protos/message.proto && pbts -o src/proto/generated.d.ts src/proto/generated.js"
},
"devDependencies": {
"esbuild": "^0.21.0",
"protobufjs-cli": "^1.1.3",
"typescript": "^5.4.0"
},
"dependencies": {
"@noble/hashes": "^2.2.0",
"protobufjs": "^7.2.6",
"tweetnacl": "^1.0.3"
}
}
+131
View File
@@ -0,0 +1,131 @@
// Crypto wrapper around tweetnacl.
//
// The byte-for-byte references for everything here are:
// /Users/sn0/Desktop/rustdesk/src/common.rs:2005-2031 (decode_id_pk, create_symmetric_key_msg)
// /Users/sn0/Desktop/rustdesk-server/libs/hbb_common/src/tcp.rs:296-344 (Encrypt + nonce derivation)
//
// tweetnacl exposes the same NaCl primitives the desktop client's
// `sodiumoxide` uses:
// - Ed25519: nacl.sign.open (verifies + unwraps a signed message)
// - Curve25519 box: nacl.box (asymmetric one-shot — we use the all-zero
// 24-byte nonce per the desktop client's create_symmetric_key_msg)
// - Secretbox: nacl.secretbox / nacl.secretbox.open (XSalsa20-Poly1305,
// symmetric per-message — nonce derivation is per-direction 8-byte LE
// sequence counter || 16 zero bytes)
//
// Pure JS, ~50 KB minified, no WASM, no module-resolution drama.
import nacl from "tweetnacl";
// Resolved immediately — kept as a Promise so the call site doesn't change
// when we swap implementations later.
export const sodiumReady: Promise<void> = Promise.resolve();
export const SIGN_PUBLICKEYBYTES = nacl.sign.publicKeyLength; // 32
export const BOX_PUBLICKEYBYTES = nacl.box.publicKeyLength; // 32
export const BOX_SECRETKEYBYTES = nacl.box.secretKeyLength; // 32
export const BOX_NONCEBYTES = nacl.box.nonceLength; // 24
export const SECRETBOX_KEYBYTES = nacl.secretbox.keyLength; // 32
export const SECRETBOX_NONCEBYTES = nacl.secretbox.nonceLength; // 24
/** Verify and unwrap an Ed25519-signed message. Throws on auth failure. */
export function signOpen(signed: Uint8Array, publicKey: Uint8Array): Uint8Array {
if (publicKey.length !== SIGN_PUBLICKEYBYTES) {
throw new Error(`signOpen: bad pk length ${publicKey.length}`);
}
const out = nacl.sign.open(signed, publicKey);
if (!out) throw new Error("signOpen: signature verification failed");
return out;
}
/** Generate an ephemeral Curve25519 keypair for the box handshake. */
export function genBoxKeypair(): { publicKey: Uint8Array; secretKey: Uint8Array } {
const kp = nacl.box.keyPair();
return { publicKey: kp.publicKey, secretKey: kp.secretKey };
}
/** Generate a fresh symmetric key for the per-session secretbox stream. */
export function genSecretboxKey(): Uint8Array {
return nacl.randomBytes(SECRETBOX_KEYBYTES);
}
/**
* Seal `msg` under the peer's Curve25519 public key with our secret key.
* All-zero 24-byte nonce matches `sodiumoxide::crypto::box_::seal` in the
* desktop client's create_symmetric_key_msg.
*/
export function boxSeal(
msg: Uint8Array,
peerPublicKey: Uint8Array,
ourSecretKey: Uint8Array,
): Uint8Array {
const nonce = new Uint8Array(BOX_NONCEBYTES); // all zeros
return nacl.box(msg, nonce, peerPublicKey, ourSecretKey);
}
/**
* Encrypt with secretbox using the per-direction nonce derivation:
* nonce[0..8] = sequence_counter as little-endian u64
* nonce[8..24] = zeros
* Returns ciphertext (which includes the 16-byte Poly1305 tag).
*/
export function secretboxSeal(
msg: Uint8Array,
sequence: bigint,
key: Uint8Array,
): Uint8Array {
return nacl.secretbox(msg, makeNonce(sequence), key);
}
/** Inverse of secretboxSeal — throws on auth failure. */
export function secretboxOpen(
cipher: Uint8Array,
sequence: bigint,
key: Uint8Array,
): Uint8Array {
const out = nacl.secretbox.open(cipher, makeNonce(sequence), key);
if (!out) throw new Error("secretboxOpen: authentication failed");
return out;
}
/** Build a 24-byte nonce: little-endian u64 in [0..8], zeros in [8..24]. */
function makeNonce(sequence: bigint): Uint8Array {
const n = new Uint8Array(SECRETBOX_NONCEBYTES);
const view = new DataView(n.buffer);
view.setBigUint64(0, sequence, /*littleEndian=*/ true);
return n;
}
// Pure-JS SHA-256 from @noble/hashes — works in non-secure contexts
// (browsers gate `crypto.subtle` to HTTPS / localhost only). Audited,
// minimal, no WASM. The dashboard often runs on plain http for self-hosted
// deployments, which would break SubtleCrypto.
import { sha256 as nobleSha256 } from "@noble/hashes/sha2.js";
/** SHA-256 of `data`. Used for the Hash/LoginRequest password challenge. */
export function sha256(data: Uint8Array): Uint8Array {
return nobleSha256(data);
}
/** Concatenate two byte arrays. */
export function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
const out = new Uint8Array(a.length + b.length);
out.set(a, 0);
out.set(b, a.length);
return out;
}
/** Standard base64 (with padding) → bytes. Used to decode the server pk. */
export function base64Decode(s: string): Uint8Array {
const bin = atob(s);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
/** Bytes → standard base64 (with padding). */
export function base64Encode(bytes: Uint8Array): string {
let s = "";
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s);
}
+143
View File
@@ -0,0 +1,143 @@
// Audio pipeline using WebCodecs AudioDecoder.
//
// Wire format (from libs/hbb_common/protos/message.proto):
// Misc{audio_format: {sample_rate, channels}} sent once at session start
// Message{audio_frame: {data: <opus frame>}} periodic
//
// We feed each opus frame into AudioDecoder, get back an AudioData with PCM
// samples, copy into an AudioBufferSourceNode, and schedule it on a single
// AudioContext "playhead" timeline so consecutive packets play seamlessly.
import { hbb } from "../proto/generated.js";
export class AudioPipeline {
private decoder: AudioDecoder | null = null;
private ctx: AudioContext | null = null;
private playhead = 0; // next absolute scheduling time, in ctx seconds
private timestamp = 0; // monotonic counter for EncodedAudioChunk
private muted = false;
private gain: GainNode | null = null;
/** Configure on the first AudioFormat message. Throws if WebCodecs unavailable. */
configure(format: hbb.IAudioFormat): void {
if (typeof AudioDecoder === "undefined") {
throw new Error("WebCodecs AudioDecoder unavailable. Open via http://localhost or https:// — secure-context only.");
}
const sampleRate = format.sample_rate || 48000;
const channels = format.channels || 2;
if (this.decoder) this.decoder.close();
this.ctx = new AudioContext({ sampleRate, latencyHint: "interactive" });
this.gain = this.ctx.createGain();
this.gain.connect(this.ctx.destination);
this.playhead = this.ctx.currentTime + 0.05; // 50ms initial buffer
this.timestamp = 0;
this.decoder = new AudioDecoder({
output: (data) => this.onAudioData(data),
error: (e) => console.error("[rustdesk-web] AudioDecoder error:", e),
});
this.decoder.configure({
codec: "opus",
sampleRate,
numberOfChannels: channels,
});
}
/** Feed one opus packet (raw bytes from AudioFrame.data). */
pushFrame(data: Uint8Array): void {
if (!this.decoder || data.length === 0 || this.muted) return;
try {
const chunk = new EncodedAudioChunk({
type: "key", // opus is all-key per packet
timestamp: this.timestamp,
data,
});
this.timestamp += 20_000; // ~20ms per opus frame in microseconds
this.decoder.decode(chunk);
} catch (e) {
console.error("[rustdesk-web] audio decode failed:", e);
}
}
/** Resume the AudioContext after a user gesture (some browsers gate
* audio playback to the first interaction). */
async resume(): Promise<void> {
if (this.ctx && this.ctx.state === "suspended") {
await this.ctx.resume();
}
}
setMuted(muted: boolean): void {
this.muted = muted;
if (this.gain && this.ctx) {
this.gain.gain.setValueAtTime(muted ? 0 : 1, this.ctx.currentTime);
}
}
isMuted(): boolean {
return this.muted;
}
close(): void {
if (this.decoder) {
try { this.decoder.close(); } catch { /* ignore */ }
this.decoder = null;
}
if (this.ctx) {
try { this.ctx.close(); } catch { /* ignore */ }
this.ctx = null;
}
}
private onAudioData(data: AudioData): void {
if (!this.ctx || !this.gain) {
data.close();
return;
}
try {
const numChannels = data.numberOfChannels;
const numFrames = data.numberOfFrames;
const sampleRate = data.sampleRate;
const buffer = this.ctx.createBuffer(numChannels, numFrames, sampleRate);
// WebCodecs opus output is typically `f32` (interleaved), not
// `f32-planar`. Detect the format and deinterleave when needed.
const format = data.format || "f32";
if (format.endsWith("-planar")) {
// Each plane is one channel — copy directly.
for (let ch = 0; ch < numChannels; ch++) {
data.copyTo(buffer.getChannelData(ch), { planeIndex: ch });
}
} else {
// Interleaved: copy all samples, then deinterleave into the
// AudioBuffer's planar channels.
const interleaved = new Float32Array(numChannels * numFrames);
data.copyTo(interleaved, { planeIndex: 0 });
for (let ch = 0; ch < numChannels; ch++) {
const dest = buffer.getChannelData(ch);
for (let i = 0; i < numFrames; i++) {
dest[i] = interleaved[i * numChannels + ch]!;
}
}
}
const src = this.ctx.createBufferSource();
src.buffer = buffer;
src.connect(this.gain);
// Schedule on a sliding playhead so consecutive packets are gap-less.
// If we've fallen behind real time (network hiccup / decoder stall),
// re-anchor to "now + 50ms" to avoid scheduling a runaway pile-up.
const now = this.ctx.currentTime;
if (this.playhead < now) {
this.playhead = now + 0.05;
}
src.start(this.playhead);
this.playhead += numFrames / sampleRate;
} catch (e) {
console.error("[rustdesk-web] audio render failed:", e);
} finally {
data.close();
}
}
}

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