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>
This commit is contained in:
Generated
+131
-3
@@ -195,6 +195,12 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"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]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
@@ -414,6 +420,12 @@ dependencies = [
|
|||||||
"toml 0.5.9",
|
"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]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@@ -567,12 +579,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.3"
|
version = "0.10.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506"
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -668,6 +681,22 @@ version = "1.13.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
|
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]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.35"
|
version = "0.8.35"
|
||||||
@@ -1087,6 +1116,7 @@ dependencies = [
|
|||||||
"ipnetwork",
|
"ipnetwork",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
|
"lettre",
|
||||||
"local-ip-address",
|
"local-ip-address",
|
||||||
"mac_address",
|
"mac_address",
|
||||||
"machine-uid 0.2.0",
|
"machine-uid 0.2.0",
|
||||||
@@ -1101,7 +1131,10 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sodiumoxide",
|
"sodiumoxide",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite",
|
||||||
|
"toml 0.7.8",
|
||||||
|
"totp-rs",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tungstenite",
|
"tungstenite",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -1163,6 +1196,15 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "0.2.7"
|
version = "0.2.7"
|
||||||
@@ -1294,6 +1336,16 @@ dependencies = [
|
|||||||
"unicode-normalization",
|
"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]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "1.8.1"
|
version = "1.8.1"
|
||||||
@@ -1438,6 +1490,33 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
|
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]]
|
[[package]]
|
||||||
name = "lexical-core"
|
name = "lexical-core"
|
||||||
version = "0.7.6"
|
version = "0.7.6"
|
||||||
@@ -2083,6 +2162,12 @@ dependencies = [
|
|||||||
"proc-macro2 1.0.93",
|
"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]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.8.5"
|
version = "0.8.5"
|
||||||
@@ -2404,6 +2489,16 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
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]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.101.7"
|
version = "0.101.7"
|
||||||
@@ -2558,6 +2653,17 @@ dependencies = [
|
|||||||
"digest",
|
"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]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
@@ -3141,6 +3247,19 @@ dependencies = [
|
|||||||
"winnow",
|
"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]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -3338,7 +3457,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c"
|
checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"form_urlencoded",
|
"form_urlencoded",
|
||||||
"idna",
|
"idna 0.2.3",
|
||||||
"matches",
|
"matches",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
@@ -3503,6 +3622,15 @@ dependencies = [
|
|||||||
"webpki",
|
"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]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "0.25.4"
|
version = "0.25.4"
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ path = "src/utils.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
hbb_common = { path = "libs/hbb_common" }
|
hbb_common = { path = "libs/hbb_common" }
|
||||||
|
tokio = { version = "1", features = ["fs", "io-util"] }
|
||||||
|
totp-rs = { version = "5.4", default-features = false }
|
||||||
|
lettre = { version = "0.10", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "builder"] }
|
||||||
|
toml = "0.7"
|
||||||
serde_derive = "1.0"
|
serde_derive = "1.0"
|
||||||
serde = "1.0"
|
serde = "1.0"
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod legacy;
|
||||||
|
pub mod peers;
|
||||||
|
pub mod profiles;
|
||||||
|
pub mod rules;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod tags;
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 })))
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 })))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod alarm;
|
||||||
|
pub mod conn;
|
||||||
|
pub mod file;
|
||||||
|
pub mod note;
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+361
@@ -0,0 +1,361 @@
|
|||||||
|
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> {
|
||||||
|
// Branch on `type`. Empty / "account" is the password path; "tfa_code"
|
||||||
|
// is the second leg of a TOTP challenge issued earlier in this same
|
||||||
|
// dance. Reject anything else for now — M4 will add email_code etc.
|
||||||
|
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`.
|
||||||
|
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": "tfa_check",
|
||||||
|
"tfa_type": "totp",
|
||||||
|
"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),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
//! `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.
|
||||||
|
//!
|
||||||
|
//! Auth: none (the client identifies the device by `(id, uuid)` body fields).
|
||||||
|
|
||||||
|
use crate::api::error::ApiError;
|
||||||
|
use crate::api::state::AppState;
|
||||||
|
use crate::api::strategy;
|
||||||
|
use axum::extract::Extension;
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn heartbeat(
|
||||||
|
Extension(state): Extension<Arc<AppState>>,
|
||||||
|
Json(body): Json<HeartbeatBody>,
|
||||||
|
) -> Result<Json<HeartbeatResp>, ApiError> {
|
||||||
|
if body.id.is_empty() || body.uuid.is_empty() {
|
||||||
|
return Err(ApiError::BadRequest("id and uuid required".into()));
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
|
||||||
|
Ok(Json(HeartbeatResp {
|
||||||
|
sysinfo: if force_sysinfo { Some(true) } else { None },
|
||||||
|
disconnect,
|
||||||
|
modified_at,
|
||||||
|
strategy,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
use crate::api::error::ApiError;
|
||||||
|
use crate::api::state::AppState;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use axum::extract::{FromRequest, RequestParts, TypedHeader};
|
||||||
|
use axum::headers::{authorization::Bearer, Authorization};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
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 bearer: TypedHeader<Authorization<Bearer>> =
|
||||||
|
TypedHeader::from_request(req).await.map_err(|_| ApiError::Unauthorized)?;
|
||||||
|
let state: axum::extract::Extension<Arc<AppState>> =
|
||||||
|
axum::extract::Extension::from_request(req)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal("missing state".into()))?;
|
||||||
|
let token = bearer.0 .0.token().to_string();
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
//! 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 audit;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod devices_cli;
|
||||||
|
pub mod email;
|
||||||
|
pub mod error;
|
||||||
|
pub mod groups;
|
||||||
|
pub mod heartbeat;
|
||||||
|
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 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 {
|
||||||
|
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))
|
||||||
|
// 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))
|
||||||
|
// 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))
|
||||||
|
.layer(Extension(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn serve(addr: SocketAddr, state: Arc<AppState>) -> ResultType<()> {
|
||||||
|
log::info!("HTTP API listening on {}", addr);
|
||||||
|
axum::Server::bind(&addr)
|
||||||
|
.serve(router(state).into_make_service())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
//! `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::auth::mint_token;
|
||||||
|
use crate::api::middleware::sha256_token;
|
||||||
|
use crate::api::oidc::{discovery, require_provider};
|
||||||
|
use crate::api::state::AppState;
|
||||||
|
use axum::extract::{Extension, Query};
|
||||||
|
use axum::response::Html;
|
||||||
|
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>,
|
||||||
|
) -> Html<String> {
|
||||||
|
match handle(state, q).await {
|
||||||
|
Ok(()) => Html(html_page(
|
||||||
|
"Sign-in complete",
|
||||||
|
"You can close this window and return to RustDesk.",
|
||||||
|
)),
|
||||||
|
Err(msg) => Html(html_page("Sign-in failed", &html_escape(&msg))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle(state: Arc<AppState>, q: CallbackQuery) -> Result<(), 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()));
|
||||||
|
|
||||||
|
let user = state
|
||||||
|
.db
|
||||||
|
.user_upsert_oidc(sub, email, display_name)
|
||||||
|
.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);
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.token_insert(
|
||||||
|
user.id,
|
||||||
|
&sha,
|
||||||
|
&session.client_id_str,
|
||||||
|
&session.client_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`.
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
}
|
||||||
@@ -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))?
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
//! `GET /api/oidc/auth-query?code=&id=&uuid=` — client poll loop.
|
||||||
|
//!
|
||||||
|
//! The Flutter client (src/hbbs_http/account.rs) wraps the response in an
|
||||||
|
//! outer envelope where the `body` field is itself JSON. We mirror that:
|
||||||
|
//!
|
||||||
|
//! `{ "body": "<inner-json-string>" }`
|
||||||
|
//!
|
||||||
|
//! The inner JSON is one of:
|
||||||
|
//! - while pending: `{"error":"No authed oidc is found"}` — client keeps polling.
|
||||||
|
//! - on success: the standard AuthBody (`{access_token, type:"access_token", user}`).
|
||||||
|
//! - 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()))?;
|
||||||
|
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(wrap_inner(json!({"error": "No authed oidc is found"})));
|
||||||
|
}
|
||||||
|
match session.status.as_str() {
|
||||||
|
"pending" => Ok(wrap_inner(json!({"error": "No authed oidc is found"}))),
|
||||||
|
"error" => {
|
||||||
|
let msg = session
|
||||||
|
.error
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "OIDC sign-in failed".to_string());
|
||||||
|
Ok(wrap_inner(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()))?;
|
||||||
|
let body = json!({
|
||||||
|
"access_token": access_token,
|
||||||
|
"type": "access_token",
|
||||||
|
"user": UserPayload::from(&user),
|
||||||
|
});
|
||||||
|
Ok(wrap_inner(body))
|
||||||
|
}
|
||||||
|
other => Err(ApiError::Internal(format!(
|
||||||
|
"unknown oidc status {:?}",
|
||||||
|
other
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wrap_inner(inner: Value) -> Json<Value> {
|
||||||
|
Json(json!({ "body": inner.to_string() }))
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
//! 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
//! `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, Query};
|
||||||
|
use axum::Json;
|
||||||
|
use serde::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 }))
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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");
|
||||||
|
// 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
//! 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,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
use crate::api::error::ApiError;
|
||||||
|
use crate::api::state::AppState;
|
||||||
|
use axum::extract::Extension;
|
||||||
|
use axum::Json;
|
||||||
|
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>>,
|
||||||
|
Json(payload): Json<Value>,
|
||||||
|
) -> Result<String, ApiError> {
|
||||||
|
let 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 id.is_empty() || uuid.is_empty() {
|
||||||
|
return Err(ApiError::BadRequest("id and uuid required".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
+2208
-1
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
mod rendezvous_server;
|
mod rendezvous_server;
|
||||||
pub use rendezvous_server::*;
|
pub use rendezvous_server::*;
|
||||||
|
pub mod api;
|
||||||
pub mod common;
|
pub mod common;
|
||||||
mod database;
|
pub mod database;
|
||||||
mod peer;
|
mod peer;
|
||||||
mod version;
|
mod version;
|
||||||
|
|||||||
+26
-1
@@ -21,6 +21,22 @@ fn main() -> ResultType<()> {
|
|||||||
-u, --software-url=[URL] 'Sets download url of RustDesk software of newest version'
|
-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'
|
-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'
|
-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)'
|
||||||
|
--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)'
|
||||||
, --mask=[MASK] 'Determine if the connection comes from LAN, e.g. 192.168.0.0/16'
|
, --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'",
|
-k, --key=[KEY] 'Only allow the client with the same key'",
|
||||||
);
|
);
|
||||||
@@ -31,7 +47,16 @@ fn main() -> ResultType<()> {
|
|||||||
}
|
}
|
||||||
let rmem = get_arg("rmem").parse::<usize>().unwrap_or(RMEM);
|
let rmem = get_arg("rmem").parse::<usize>().unwrap_or(RMEM);
|
||||||
let serial: i32 = get_arg("serial").parse().unwrap_or(0);
|
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();
|
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,
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+221
-9
@@ -8,7 +8,7 @@ use hbb_common::{
|
|||||||
futures::future::join_all,
|
futures::future::join_all,
|
||||||
futures_util::{
|
futures_util::{
|
||||||
sink::SinkExt,
|
sink::SinkExt,
|
||||||
stream::{SplitSink, StreamExt},
|
stream::{SplitSink, SplitStream, StreamExt},
|
||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
protobuf::{Message as _, MessageField},
|
protobuf::{Message as _, MessageField},
|
||||||
@@ -16,7 +16,7 @@ use hbb_common::{
|
|||||||
register_pk_response::Result::{TOO_FREQUENT, UUID_MISMATCH},
|
register_pk_response::Result::{TOO_FREQUENT, UUID_MISMATCH},
|
||||||
*,
|
*,
|
||||||
},
|
},
|
||||||
tcp::{listen_any, FramedStream},
|
tcp::{listen_any, Encrypt, FramedStream},
|
||||||
timeout,
|
timeout,
|
||||||
tokio::{
|
tokio::{
|
||||||
self,
|
self,
|
||||||
@@ -31,7 +31,7 @@ use hbb_common::{
|
|||||||
AddrMangle, ResultType,
|
AddrMangle, ResultType,
|
||||||
};
|
};
|
||||||
use ipnetwork::Ipv4Network;
|
use ipnetwork::Ipv4Network;
|
||||||
use sodiumoxide::crypto::sign;
|
use sodiumoxide::crypto::{box_, sign};
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||||
@@ -49,9 +49,14 @@ enum Data {
|
|||||||
|
|
||||||
const REG_TIMEOUT: i32 = 30_000;
|
const REG_TIMEOUT: i32 = 30_000;
|
||||||
type TcpStreamSink = SplitSink<Framed<TcpStream, BytesCodec>, Bytes>;
|
type TcpStreamSink = SplitSink<Framed<TcpStream, BytesCodec>, Bytes>;
|
||||||
|
type TcpStreamSrc = SplitStream<Framed<TcpStream, BytesCodec>>;
|
||||||
type WsSink = SplitSink<tokio_tungstenite::WebSocketStream<TcpStream>, tungstenite::Message>;
|
type WsSink = SplitSink<tokio_tungstenite::WebSocketStream<TcpStream>, tungstenite::Message>;
|
||||||
enum Sink {
|
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),
|
Ws(WsSink),
|
||||||
}
|
}
|
||||||
type Sender = mpsc::UnboundedSender<Data>;
|
type Sender = mpsc::UnboundedSender<Data>;
|
||||||
@@ -99,11 +104,56 @@ enum LoopFailure {
|
|||||||
|
|
||||||
impl RendezvousServer {
|
impl RendezvousServer {
|
||||||
#[tokio::main(flavor = "multi_thread")]
|
#[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,
|
||||||
|
) -> ResultType<()> {
|
||||||
let (key, sk) = Self::get_server_sk(key);
|
let (key, sk) = Self::get_server_sk(key);
|
||||||
let nat_port = port - 1;
|
let nat_port = port - 1;
|
||||||
let ws_port = port + 2;
|
let ws_port = port + 2;
|
||||||
let pm = PeerMap::new().await?;
|
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);
|
log::info!("serial={}", serial);
|
||||||
let rendezvous_servers = get_servers(&get_arg("rendezvous-servers"), "rendezvous-servers");
|
let rendezvous_servers = get_servers(&get_arg("rendezvous-servers"), "rendezvous-servers");
|
||||||
log::info!("Listening on tcp/udp :{}", port);
|
log::info!("Listening on tcp/udp :{}", port);
|
||||||
@@ -222,9 +272,23 @@ impl RendezvousServer {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let listen_signal = listen_signal();
|
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 addr: SocketAddr = format!("0.0.0.0:{http_port}").parse()?;
|
||||||
|
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!(
|
tokio::select!(
|
||||||
res = main_task => res,
|
res = main_task => res,
|
||||||
res = listen_signal => res,
|
res = listen_signal => res,
|
||||||
|
res = api_task => res,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,7 +895,12 @@ impl RendezvousServer {
|
|||||||
if let Some(sink) = sink.as_mut() {
|
if let Some(sink) = sink.as_mut() {
|
||||||
if let Ok(bytes) = msg.write_to_bytes() {
|
if let Ok(bytes) = msg.write_to_bytes() {
|
||||||
match sink {
|
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);
|
allow_err!(s.send(Bytes::from(bytes)).await);
|
||||||
}
|
}
|
||||||
Sink::Ws(ws) => {
|
Sink::Ws(ws) => {
|
||||||
@@ -1185,9 +1254,70 @@ impl RendezvousServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let (a, mut b) = Framed::new(stream, BytesCodec::new()).split();
|
let (mut a, mut b) = Framed::new(stream, BytesCodec::new()).split();
|
||||||
sink = Some(Sink::TcpStream(a));
|
// Server-initiated secure_tcp handshake. Only attempted when the
|
||||||
while let Ok(Some(Ok(bytes))) = timeout(30_000, b.next()).await {
|
// 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 {
|
if !self.handle_tcp(&bytes, &mut sink, addr, key, ws).await {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1369,3 +1499,85 @@ async fn create_tcp_listener(port: i32) -> ResultType<TcpListener> {
|
|||||||
log::debug!("listen on tcp {:?}", s.local_addr());
|
log::debug!("listen on tcp {:?}", s.local_addr());
|
||||||
Ok(s)
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user