feat(admin): OIDC sign-in, role sync, deploy/delete UX, and docs
This commit lights up the missing pieces of the admin dashboard and the
OIDC flow that the desktop client already speaks. It bundles several
independent fixes that share enough touch points (oidc/callback,
admin/mod, database schema) that splitting was more churn than help.
OIDC — desktop client
- /api/login: when TOTP is enrolled, return type:"email_check"
+ tfa_type:"tfa_check" instead of type:"tfa_check". The Flutter
client's switch only branches on access_token / email_check; the
prior shape silently fell into "bad response from server".
- /api/login dispatcher: route the second leg to login_tfa_code when
tfaCode + secret are both present, regardless of the declared type.
The desktop client sends type:"email_code" for both email-code AND
TOTP second legs and distinguishes by which field is set.
- /api/oidc/auth-query: drop the bogus extra {"body": "..."} envelope.
The desktop client's http_request_sync already wraps every response
in {status_code, headers, body}, and HbbHttpResponse::parse expects
the auth payload at that level. Our extra envelope made the parser
fail silently as DataTypeFormat and the poll loop spun until the
180 s client timeout.
- UserPayload: add a required info: {} field; the Rust-side polling
deserializer at src/hbbs_http/account.rs expects it (no
#[serde(default)]). Without it the AuthBody parse failed on every
poll, producing the same forever-pending symptom as above.
- Add an always-on info-level log line at the poll handler so this
family of "client never advances" bugs is observable from hbbs.log.
OIDC — admin dashboard
- New unauthenticated entry points:
GET /admin/oidc/providers JSON list for login.html
GET /admin/login/oidc/:provider 302 → IdP authorization endpoint
The session is marked admin-flow via a sentinel ("__admin_ui__") in
client_id_str / client_uuid so the existing /oidc/callback can tell
it apart from a desktop device flow.
- /oidc/callback finishes admin sessions by setting the
rd_admin_session cookie + 303 to /admin/. Non-admin users get a
helpful error page instead of a session.
- Admin-flow callbacks SKIP device_claim() so the dashboard sign-in
no longer inserts a phantom "__admin_ui__" device row in
device_sysinfo, and the token's peer_id / peer_uuid columns stay
blank instead of carrying the sentinel.
- admin_ui/login.html fetches the providers list on load and renders
one button per enabled provider beneath the password form.
OIDC — role-based admin sync
- New per-provider config fields admin_role + roles_claim (in
oidc.toml AND oidc_providers, via soft ALTER TABLE). When set, the
callback evaluates the userinfo claim and forces users.is_admin
accordingly on every login. Promotion AND demotion at the IdP
propagate. Two claim shapes supported:
- object key match (Zitadel:
urn:zitadel:iam:org:project:roles -> { "admin": {...} })
- string-array contains (generic: roles -> ["admin","user"])
- user_upsert_oidc gains a desired_admin: Option<bool> arg so the same
upsert path handles "leave admin alone" (desired_admin = None) and
"force from IdP" (Some(bool)). Three unit tests cover both shapes
plus the missing-claim case.
Admin dashboard — Address books
- Full CRUD for shared books from the dashboard:
create, list shares, add/upgrade/remove a per-user share with
read / read+write / full rules, delete the book.
- Personal books also get a Delete action — confirms with a stronger
message that the user's desktop client will recreate an empty
personal book on next sync if it's still signed in (deletion is
effectively "reset to empty", not "permanently revoked"). Use in
combination with user-delete to fully revoke.
- New DB methods: ab_create_shared, ab_delete (cascades peers/tags/
peer_tags/shares), ab_get_owner_kind, ab_list_shares, ab_share_set
(idempotent upsert), ab_share_remove.
Admin dashboard — Devices
- Delete action in the per-row menu. device_delete cascades through
device_sysinfo, peer (rendezvous identity), heartbeat_commands and
peer-scoped strategy_assignments. Audit logs, recordings, and AB
entries that reference the peer are intentionally preserved
(historical/manual data).
Admin dashboard — Deploy page
- New page that generates the unsigned CustomServer blob the desktop
client accepts via `rustdesk --config <blob>` (see
rustdesk/src/custom_server.rs:get_custom_server_from_config_string;
the unsigned-JSON path is a real codepath, no Pro signing key
needed). Form prefills the public key from id_ed25519.pub in CWD.
- Also emits the equivalent renamed-installer filename
(rustdesk-host=...,key=... .exe). Strips api= from the filename
when it equals the default http://<host>:21114 (Windows can't store
: or / in filenames); warns when the API URL is non-default.
Login form fixes
- TOTP form field: rename serde wire field from tfa_code to tfaCode
so the dashboard's HTMX form (input name="tfaCode") actually
populates it. The previous mismatch silently dropped the code and
the server kept asking for it.
- TOTP redirect guard: only redirect on empty 2xx body (real login).
The TOTP-required path returns 2xx with an HTML prompt fragment
that must NOT be redirected away from.
Docs
- New docs/CONFIGURATION.md covering all CLI flags, OIDC setup
(generic + Zitadel walk-through), role-based admin sync, TOTP,
strategies, address books, dashboard URL map, DB notes, and a
pre-prod security checklist.
Schema
- soft ALTER TABLE oidc_providers ADD COLUMN admin_role / roles_claim
(guarded by the duplicate-column-name swallower for SQLite < 3.35).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,8 @@
|
||||
hx-get="/admin/pages/audit" hx-target="#main" hx-push-url="#audit">Audit log</a>
|
||||
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
||||
hx-get="/admin/pages/recordings" hx-target="#main" hx-push-url="#recordings">Recordings</a>
|
||||
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
||||
hx-get="/admin/pages/deploy" hx-target="#main" hx-push-url="#deploy">Deploy</a>
|
||||
</nav>
|
||||
<div class="px-2 py-3 border-t border-slate-800">
|
||||
<button
|
||||
|
||||
@@ -66,6 +66,36 @@
|
||||
|
||||
<div id="err" class="text-sm text-rose-400 min-h-[1.25em]"></div>
|
||||
</form>
|
||||
|
||||
<!-- OIDC providers (rendered only when /admin/oidc/providers is non-empty) -->
|
||||
<div id="oidc-block" class="mt-6 hidden">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="flex-1 h-px bg-slate-800"></div>
|
||||
<span class="text-xs text-slate-500">or</span>
|
||||
<div class="flex-1 h-px bg-slate-800"></div>
|
||||
</div>
|
||||
<div id="oidc-buttons" class="space-y-2"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Fetch enabled providers and render one button each. The button just
|
||||
// navigates to /admin/login/oidc/<name>, which 302s the browser to the
|
||||
// IdP. After the IdP redirects to /oidc/callback, the server sets our
|
||||
// session cookie and redirects to /admin/.
|
||||
fetch('/admin/oidc/providers').then(r => r.json()).then(list => {
|
||||
if (!Array.isArray(list) || list.length === 0) return;
|
||||
const block = document.getElementById('oidc-block');
|
||||
const root = document.getElementById('oidc-buttons');
|
||||
list.forEach(p => {
|
||||
const a = document.createElement('a');
|
||||
a.href = '/admin/login/oidc/' + encodeURIComponent(p.name);
|
||||
a.className = 'block w-full text-center bg-slate-800 hover:bg-slate-700 border border-slate-700 text-sm rounded px-4 py-2 transition';
|
||||
a.textContent = 'Sign in with ' + (p.display_name || p.name);
|
||||
root.appendChild(a);
|
||||
});
|
||||
block.classList.remove('hidden');
|
||||
}).catch(() => { /* silently hide block on any error */ });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
# RustDesk Server (hbbs) — Configuration Guide
|
||||
|
||||
This document covers the runtime flags exposed by `hbbs`, the file formats
|
||||
it reads (notably `oidc.toml`), and the operator workflows that string
|
||||
those together — bootstrap admin, OIDC sign-in, TOTP, address books,
|
||||
strategies, recordings, the admin dashboard.
|
||||
|
||||
The matching desktop-client API surface is documented separately in the
|
||||
`rustdesk` repo at `docs/CONSOLE_API.md`.
|
||||
|
||||
---
|
||||
|
||||
## CLI flags
|
||||
|
||||
Pass flags directly on the command line. There is no config file for
|
||||
hbbs itself (only the optional `oidc.toml` referenced from a flag).
|
||||
|
||||
### Networking & rendezvous
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--port=<NUM>` | `21116` | TCP/UDP rendezvous port. |
|
||||
| `--rendezvous-servers=<HOSTS>` | unset | Peer rendezvous servers (comma-separated). |
|
||||
| `--relay-servers=<HOSTS>` | unset | Default relay hosts handed to clients. |
|
||||
| `--rmem=<BYTES>` | platform default | UDP recv buffer size. Bump along with `net.core.rmem_max`. |
|
||||
| `--mask=<CIDR>` | unset | LAN mask (e.g. `192.168.0.0/16`) used to flag local connections. |
|
||||
| `--key=<B64>` | derived from `id_ed25519` | Force a specific public key; clients must match. Leave unset to auto-load `id_ed25519` next to the binary. |
|
||||
|
||||
### HTTP API & dashboard
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--http-port=<NUM>` | `21114` | HTTP API port (`/api/*`) and admin dashboard (`/admin/*`). `0` disables both. |
|
||||
| `--admin-ui-dir=<PATH>` | `./admin_ui` | Hint at where the dashboard's static HTML lives. The HTML is *embedded* in the binary; this flag is informational. Setting it to empty (`--admin-ui-dir=`) disables the dashboard entirely. |
|
||||
| `--public-base-url=<URL>` | unset | The externally-reachable HTTP base of this server, e.g. `https://rustdesk.example.com:21114`. **Required when OIDC is enabled** — used to build `/oidc/callback` redirect URIs. |
|
||||
|
||||
### Bootstrap admin
|
||||
|
||||
| Flag | Purpose |
|
||||
|---|---|
|
||||
| `--bootstrap-admin-username=<USER>` | On first startup, if the `users` table is empty *and* both flags are set, insert one admin user. Subsequent restarts ignore these flags (no overwrite). |
|
||||
| `--bootstrap-admin-password=<PASS>` | Same. Bcrypt-hashed at insert time. |
|
||||
|
||||
If you forget to bootstrap, hbbs logs a warning at startup ("no users in users table"); recover by either restarting with the flags or `INSERT INTO users` directly via `sqlite3`.
|
||||
|
||||
### Address books
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--ab-legacy-mode=<on\|off>` | `off` | When `on`, `/api/ab/personal` returns 404. Forces clients into the legacy single-blob AB mode. |
|
||||
| `--ab-max-peers-per-book=<NUM>` | `100` | Surfaced via `/api/ab/settings.max_peer_one_ab`. Soft cap; the client uses it for UI hints. |
|
||||
|
||||
### Recordings
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--recording-dir=<PATH>` | `./recordings` | Root for `/api/record` uploads. One subdirectory per peer. |
|
||||
| `--recording-max-size-mb=<NUM>` | unset (=unlimited) | Per-file ceiling. Aborts oversized parts. |
|
||||
|
||||
> **Note:** Stock OSS RustDesk clients **do not upload** recordings to `/api/record` — the uploader's `ENABLE` flag at `src/hbbs_http/record_upload.rs` has no setter in OSS source. Server-side recording requires a custom client build that flips that flag. The `Recordings` admin tab will stay empty for stock clients; the endpoint is provided for wire parity with Pro clients.
|
||||
|
||||
### Audit retention
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--audit-retention-days=<NUM>` | `0` (=keep forever) | Hourly task deletes `audit_conn` / `audit_file` / `audit_alarm` rows older than N days. |
|
||||
|
||||
### Email-code login (`/api/login` with `type:"email_code"`)
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--smtp-host=<HOST>` | unset | If unset, codes are *logged to stdout* (dev mode) instead of mailed. The `email_code` login option is also dropped from `/api/login-options` until SMTP is configured. |
|
||||
| `--smtp-port=<NUM>` | `587` | |
|
||||
| `--smtp-user=<USER>` | unset | Omit for unauthenticated relays. |
|
||||
| `--smtp-pass=<PASS>` | unset | |
|
||||
| `--smtp-from=<ADDR>` | `noreply@<smtp-host>` | From: header. |
|
||||
| `--smtp-tls=<on\|off>` | `on` | STARTTLS on the SMTP transport. |
|
||||
|
||||
### OIDC
|
||||
|
||||
| Flag | Purpose |
|
||||
|---|---|
|
||||
| `--oidc-config=<PATH>` | TOML file (see below). Providers are upserted into `oidc_providers` at startup. Re-run with a different file to change providers; rows missing from the new file remain in the DB but can be `enabled=0`'d via SQL. |
|
||||
| `--public-base-url=<URL>` | **Required** if any provider is configured. Determines the redirect URI registered with the IdP. |
|
||||
|
||||
---
|
||||
|
||||
## OIDC integration
|
||||
|
||||
The server speaks standard OIDC Authorization Code flow with discovery
|
||||
(`/.well-known/openid-configuration`). Tested against Zitadel; should work
|
||||
with any standards-compliant IdP (Keycloak, Auth0, Google, Okta, Authelia,
|
||||
Dex, etc.).
|
||||
|
||||
Two entry points are wired:
|
||||
|
||||
1. **Desktop client** — `/api/login-options` advertises `oidc/<name>` per enabled provider. The Flutter login dialog renders a button per advertised name. Clicking starts the device-flow polling cycle (`/api/oidc/auth` → browser → `/oidc/callback` → `/api/oidc/auth-query` poll).
|
||||
2. **Admin dashboard** — `/admin/login.html` fetches `/admin/oidc/providers` and renders a "Sign in with X" button per provider. Clicking jumps the browser to `/admin/login/oidc/<name>` which 302-redirects to the IdP. After the IdP returns to `/oidc/callback`, the server detects the admin-flow sentinel and finishes by setting the dashboard session cookie + redirecting to `/admin/`.
|
||||
|
||||
### `oidc.toml` schema
|
||||
|
||||
Pass via `--oidc-config /path/to/oidc.toml`.
|
||||
|
||||
```toml
|
||||
[[providers]]
|
||||
# Slug used in URLs (`/admin/login/oidc/<name>`, `/api/login-options`
|
||||
# advertises `oidc/<name>`). Lowercase, no spaces.
|
||||
name = "zitadel"
|
||||
|
||||
# Display label on the sign-in button.
|
||||
display_name = "Sign in with Zitadel"
|
||||
|
||||
# Optional. Square icon URL shown next to the label (not used yet by all
|
||||
# UIs; reserved for future button rendering).
|
||||
# icon_url = "https://example.com/zitadel.svg"
|
||||
|
||||
# OIDC issuer. The server fetches `<issuer_url>/.well-known/openid-configuration`
|
||||
# and caches the discovery doc in-process. Trailing slash is stripped.
|
||||
issuer_url = "https://idp.example.com"
|
||||
|
||||
# Application credentials from the IdP.
|
||||
client_id = "..."
|
||||
client_secret = "..."
|
||||
|
||||
# Scopes requested at the authorization endpoint. Most setups want
|
||||
# "openid email profile". Zitadel additionally needs the project audience
|
||||
# scope to receive role claims (see Role-based admin sync below).
|
||||
scopes = "openid email profile"
|
||||
|
||||
# Optional. If unset, computed as `<--public-base-url>/oidc/callback`.
|
||||
# Override only when you reverse-proxy under a different host.
|
||||
# redirect_url = "https://rustdesk.example.com/oidc/callback"
|
||||
|
||||
# Optional. Defaults to true.
|
||||
enabled = true
|
||||
|
||||
# --- Role-based admin sync (optional) ---
|
||||
# When `admin_role` is set, every successful sign-in via this provider
|
||||
# evaluates the userinfo claim at `roles_claim` and forces the local
|
||||
# user's `is_admin` to (role present in claim). Promotion AND demotion
|
||||
# at the IdP propagate. Leave both unset to manage admin status manually
|
||||
# from the dashboard.
|
||||
# admin_role = "admin"
|
||||
# roles_claim = "roles" # or e.g. "urn:zitadel:iam:org:project:roles"
|
||||
```
|
||||
|
||||
`oidc.toml` may contain multiple `[[providers]]` blocks for multi-IdP setups.
|
||||
|
||||
### Walk-through: Zitadel
|
||||
|
||||
#### In Zitadel
|
||||
|
||||
1. **Project → New project** (or pick an existing one).
|
||||
2. **New application** under the project:
|
||||
- Type: **Web**
|
||||
- Authentication flow: **Code** (Authorization Code with client secret)
|
||||
- Auth method: **Basic** *or* **Post** (server sends `client_id` + `client_secret` in the form body — both modes accept that)
|
||||
- **Redirect URIs**: `<public-base-url>/oidc/callback` — character-exact, including scheme. Zitadel rejects `http://` redirects on non-localhost unless dev mode is on, so use TLS in production.
|
||||
3. **Authorizations** — assign the project's roles to whichever users you want to be admins.
|
||||
4. **Project → General**: turn on **"Assert Roles On Authentication"** so roles flow into the userinfo response.
|
||||
5. Copy **Client ID** and **Client Secret** from the application's overview page.
|
||||
|
||||
#### `oidc.toml`
|
||||
|
||||
```toml
|
||||
[[providers]]
|
||||
name = "zitadel"
|
||||
display_name = "Sign in with Zitadel"
|
||||
issuer_url = "https://your-instance.zitadel.cloud"
|
||||
client_id = "PASTE_FROM_ZITADEL"
|
||||
client_secret = "PASTE_FROM_ZITADEL"
|
||||
# `urn:zitadel:iam:org:project:id:zitadel:aud` is required for the project's
|
||||
# roles to be included in the userinfo response.
|
||||
scopes = "openid email profile urn:zitadel:iam:org:project:id:zitadel:aud"
|
||||
admin_role = "admin"
|
||||
roles_claim = "urn:zitadel:iam:org:project:roles"
|
||||
```
|
||||
|
||||
#### `hbbs` flags
|
||||
|
||||
```sh
|
||||
./hbbs --http-port 21114 \
|
||||
--public-base-url 'https://rustdesk.example.com:21114' \
|
||||
--oidc-config /etc/rustdesk/oidc.toml
|
||||
```
|
||||
|
||||
#### Verify
|
||||
|
||||
After hbbs starts, look for:
|
||||
|
||||
```
|
||||
oidc: provider "zitadel" configured
|
||||
oidc: loaded 1 providers from /etc/rustdesk/oidc.toml
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```sh
|
||||
# 1. Provider visible to the desktop client
|
||||
curl -s http://127.0.0.1:21114/api/login-options
|
||||
# expect a list including "oidc/zitadel"
|
||||
|
||||
# 2. Provider visible to the admin dashboard
|
||||
curl -s http://127.0.0.1:21114/admin/oidc/providers
|
||||
# expect [{"name":"zitadel","display_name":"Sign in with Zitadel",...}]
|
||||
|
||||
# 3. Discovery is reachable (IdP-side)
|
||||
curl -s https://your-instance.zitadel.cloud/.well-known/openid-configuration | jq .issuer
|
||||
```
|
||||
|
||||
### Role-based admin sync
|
||||
|
||||
When `admin_role` is set on a provider, every successful sign-in evaluates
|
||||
the userinfo claim at `roles_claim` (defaults to `"roles"` if unset) and
|
||||
forces `users.is_admin` accordingly. **Promotion and demotion at the IdP
|
||||
propagate on the next login.**
|
||||
|
||||
Two claim shapes are supported:
|
||||
|
||||
- **Object** (Zitadel default at `urn:zitadel:iam:org:project:roles`): role names are keys.
|
||||
```json
|
||||
"urn:zitadel:iam:org:project:roles": {
|
||||
"admin": {"123": "myorg"},
|
||||
"user": {"123": "myorg"}
|
||||
}
|
||||
```
|
||||
- **Array of strings** (generic, common with Keycloak, Auth0 custom claims):
|
||||
```json
|
||||
"roles": ["admin", "user"]
|
||||
```
|
||||
|
||||
Set `admin_role = "admin"` and either set `roles_claim` to the exact claim
|
||||
name (Zitadel) or omit it to default to `"roles"` (generic).
|
||||
|
||||
> **Sharp edge:** when role-sync is configured, manually-granted admin
|
||||
> rights in the dashboard get **revoked** on the next OIDC login if the
|
||||
> role isn't present at the IdP. This is the correct contract for a
|
||||
> single source of truth, but surprising if you forget. Manage admin
|
||||
> status in *one* place at a time.
|
||||
|
||||
### Troubleshooting OIDC
|
||||
|
||||
- **"Sign-in complete" page in browser but desktop client stays at "Waiting account auth"**: usually a state mismatch between server and client. Check `hbbs.log` — the poll endpoint logs every tick at INFO. If you see `status=success` lines that don't stop, suspect a wire-shape mismatch. (This was a real bug we hit and fixed; see git log for `oidc envelope`.)
|
||||
- **Browser shows "identity provider returned an error"**: check `oidc_sessions.error` for the row that just failed. Most common: `redirect_uri` mismatch between Zitadel and `--public-base-url`.
|
||||
- **No "Sign in with X" button in the dashboard or desktop client**: check `oidc_provider_list_enabled()` returns rows. If `--public-base-url` is empty, `/admin/oidc/providers` and `/api/login-options` both suppress OIDC entries (the redirect URI would be unbuildable).
|
||||
- **Admin landing on the "no admin access" error after first OIDC sign-in**: expected if `admin_role` isn't configured. Either configure role-sync (preferred), or have the user sign in once to create their row, then promote them on the Users page. The next OIDC sign-in resolves to that row.
|
||||
|
||||
---
|
||||
|
||||
## TOTP / 2FA
|
||||
|
||||
Per-user TOTP is enrolled from the dashboard:
|
||||
|
||||
1. Sign in as an admin → **Users** page.
|
||||
2. Pick a user → action menu → **Enroll TOTP**.
|
||||
3. Scan the QR code into an authenticator (1Password, Authy, Google Authenticator, etc.). The secret is shown once and stored in `user_totp_secrets`.
|
||||
|
||||
After enrollment, the next desktop-client login flow is:
|
||||
|
||||
1. Username + password → server returns `{"type":"email_check","tfa_type":"tfa_check","secret":<nonce>}`.
|
||||
2. Client opens its verification-code dialog → user enters the 6-digit code → re-POSTs `/api/login` with `type:"email_code"` (yes, that's what the desktop client sends for both email and TOTP second legs), `tfaCode` set, `secret` echoed back.
|
||||
3. Server verifies the code against `user_totp_secrets`, mints an access token, returns `{"type":"access_token", ...}`.
|
||||
|
||||
For dashboard logins, the inline form at `/admin/login.html` shows the TOTP field after the first password submit returns the prompt fragment.
|
||||
|
||||
---
|
||||
|
||||
## Strategies (server-pushed config)
|
||||
|
||||
Strategies push `config_options` to peers via heartbeat replies. They are
|
||||
managed entirely from the dashboard's **Strategies** page. Resolution
|
||||
order per peer:
|
||||
|
||||
1. Direct peer-scoped assignment (`strategy_assignments.peer_id`)
|
||||
2. Device-group assignment via the peer's owner
|
||||
3. User assignment
|
||||
|
||||
The peer's `Config::get_option` calls reflect the resolved values within
|
||||
~15 s of any change to `modified_at` on the strategy row.
|
||||
|
||||
---
|
||||
|
||||
## Address books
|
||||
|
||||
- **Personal books** are owned per-user and managed from the user's desktop client. The dashboard surfaces them read-only.
|
||||
- **Shared books** are server-side artifacts. Create from the dashboard's **Address books** page → "Manage shares" → grant per-user `read` / `read+write` / `full` access. Clients pick up shared books on their next AB sync (~30 s).
|
||||
|
||||
If you set `--ab-legacy-mode=on`, `/api/ab/personal` 404s and clients fall back to the single-blob `/api/ab` path. Use only if a stock client misbehaves on the modern path.
|
||||
|
||||
---
|
||||
|
||||
## Admin dashboard URLs
|
||||
|
||||
| Path | Auth | What |
|
||||
|---|---|---|
|
||||
| `/admin/`, `/admin/index.html` | none (login page redirects in JS) | Single-page shell |
|
||||
| `/admin/login.html` | none | Sign-in form (password / TOTP / OIDC buttons) |
|
||||
| `/admin/login` | none (POST form) | Password+TOTP submit → sets `rd_admin_session` cookie |
|
||||
| `/admin/logout` | cookie | Clears cookie |
|
||||
| `/admin/me` | cookie | Sidebar's logged-in-as widget |
|
||||
| `/admin/oidc/providers` | none | JSON list of enabled providers, used by login.html |
|
||||
| `/admin/login/oidc/:name` | none | Starts admin OIDC flow (302s to IdP) |
|
||||
| `/admin/pages/users` | cookie + admin | Users page fragment |
|
||||
| `/admin/pages/devices` | cookie + admin | Devices (incl. delete) |
|
||||
| `/admin/pages/groups` | cookie + admin | Device groups |
|
||||
| `/admin/pages/strategies` | cookie + admin | Strategy management |
|
||||
| `/admin/pages/address-books` | cookie + admin | Personal + shared books |
|
||||
| `/admin/pages/oidc` | cookie + admin | Read-only OIDC provider listing |
|
||||
| `/admin/pages/audit` | cookie + admin | Audit log browser |
|
||||
| `/admin/pages/recordings` | cookie + admin | Recording file listing |
|
||||
| `/admin/pages/deploy` | cookie + admin | `--config` blob + renamed-installer generator |
|
||||
|
||||
The session cookie (`rd_admin_session`) is HttpOnly + SameSite=Strict.
|
||||
The middleware accepts the same cookie *or* `Authorization: Bearer …`,
|
||||
so the same auth covers `/api/*` for the desktop client and `/admin/*`
|
||||
for the dashboard with no separate session model.
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
SQLite, file `db_v2.sqlite3` in hbbs's working directory. Tables created
|
||||
at startup with `CREATE TABLE IF NOT EXISTS`; column additions use
|
||||
`ALTER TABLE ADD COLUMN` guarded by a duplicate-column-name swallower
|
||||
(SQLite < 3.35 lacks `ADD COLUMN IF NOT EXISTS`).
|
||||
|
||||
Backup is a plain file copy while hbbs is stopped, or `sqlite3
|
||||
db_v2.sqlite3 .dump > backup.sql` while running. There is no
|
||||
multi-instance HA; run a single hbbs against a single SQLite file.
|
||||
|
||||
---
|
||||
|
||||
## Security checklist before exposing to the internet
|
||||
|
||||
- TLS in front of `--http-port` (Caddy / nginx / Traefik). Required for OIDC redirect URIs in production.
|
||||
- `--public-base-url` set to the *externally* reachable URL, including the scheme.
|
||||
- `--bootstrap-admin-password` rotated immediately after first login (Users page → reset password).
|
||||
- `--key` / `id_ed25519` not committed to source control. Treat the private key as a deploy secret.
|
||||
- Audit retention (`--audit-retention-days`) set to a value that matches your data-retention policy.
|
||||
- If running behind a reverse proxy: forward the original `Host:` header so OIDC redirect-URI validation matches.
|
||||
@@ -20,8 +20,10 @@ pub struct LoginForm {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
/// 6-digit TOTP code, present on the second leg when the first leg
|
||||
/// returned `tfa_check`.
|
||||
#[serde(default)]
|
||||
/// returned `tfa_check`. The HTML input is `name="tfaCode"` (camelCase)
|
||||
/// to match the rest of the dashboard's form conventions, so we rename
|
||||
/// the wire field rather than renaming the input.
|
||||
#[serde(default, rename = "tfaCode")]
|
||||
pub tfa_code: String,
|
||||
/// Echo of the TOTP nonce the first-leg response set on the form.
|
||||
#[serde(default)]
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
pub mod auth;
|
||||
pub mod me;
|
||||
pub mod oidc_login;
|
||||
pub mod pages;
|
||||
|
||||
use axum::http::header;
|
||||
@@ -45,6 +46,11 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
|
||||
.route("/admin/login", post(auth::login))
|
||||
.route("/admin/logout", post(auth::logout))
|
||||
.route("/admin/me", get(me::me))
|
||||
// OIDC entry points consumed by login.html (unauthenticated — they
|
||||
// *initiate* a sign-in). The matching /oidc/callback is mounted by
|
||||
// the public api router and finishes both desktop and admin flows.
|
||||
.route("/admin/oidc/providers", get(oidc_login::list_providers))
|
||||
.route("/admin/login/oidc/:provider", get(oidc_login::start_login))
|
||||
// Page fragments — one per sidebar entry.
|
||||
.route("/admin/pages/users", get(pages::users::index))
|
||||
.route("/admin/pages/users/create", post(pages::users::create))
|
||||
@@ -78,6 +84,10 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
|
||||
"/admin/pages/devices/:peer_id/sysinfo-refresh",
|
||||
post(pages::devices::force_sysinfo),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/devices/:peer_id/delete",
|
||||
post(pages::devices::delete),
|
||||
)
|
||||
// Groups
|
||||
.route("/admin/pages/groups/create", post(pages::groups::create))
|
||||
.route("/admin/pages/groups/:id/delete", post(pages::groups::delete))
|
||||
@@ -102,6 +112,11 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
|
||||
"/admin/pages/strategies/:id/delete",
|
||||
post(pages::strategies::delete),
|
||||
)
|
||||
.route("/admin/pages/deploy", get(pages::deploy::index))
|
||||
.route(
|
||||
"/admin/pages/deploy/generate",
|
||||
post(pages::deploy::generate),
|
||||
)
|
||||
.route("/admin/pages/devices", get(pages::devices::index))
|
||||
.route("/admin/pages/groups", get(pages::groups::index))
|
||||
.route("/admin/pages/strategies", get(pages::strategies::index))
|
||||
@@ -109,6 +124,26 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
|
||||
"/admin/pages/address-books",
|
||||
get(pages::address_books::index),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/address-books/create",
|
||||
post(pages::address_books::create),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/address-books/:guid/delete",
|
||||
post(pages::address_books::delete),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/address-books/:guid/manage",
|
||||
get(pages::address_books::manage),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/address-books/:guid/shares/add",
|
||||
post(pages::address_books::share_add),
|
||||
)
|
||||
.route(
|
||||
"/admin/pages/address-books/:guid/shares/:user_id/remove",
|
||||
post(pages::address_books::share_remove),
|
||||
)
|
||||
.route("/admin/pages/oidc", get(pages::oidc::index))
|
||||
.route("/admin/pages/audit", get(pages::audit::index))
|
||||
.route("/admin/pages/recordings", get(pages::recordings::index));
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! OIDC login entry points for the admin dashboard.
|
||||
//!
|
||||
//! Two unauthenticated GET endpoints used by `admin_ui/login.html`:
|
||||
//!
|
||||
//! - `GET /admin/oidc/providers` returns the enabled providers as JSON so
|
||||
//! the login page can render a button per provider.
|
||||
//! - `GET /admin/login/oidc/:provider` creates an OIDC session marked as
|
||||
//! admin-flow (via the sentinel below) and 302-redirects the browser to
|
||||
//! the IdP authorization URL. After the IdP redirects to
|
||||
//! `/oidc/callback`, the existing callback handler detects the sentinel
|
||||
//! and finishes by setting `rd_admin_session` + redirecting to `/admin/`
|
||||
//! (see api/oidc/callback.rs).
|
||||
//!
|
||||
//! We keep this module separate from the desktop-client OIDC flow so the
|
||||
//! "device polls /api/oidc/auth-query" semantics stay untouched.
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::oidc::{discovery, random_token, require_provider, OIDC_SESSION_TTL_SECS};
|
||||
use crate::api::state::AppState;
|
||||
use crate::database::OidcSessionInsert;
|
||||
use axum::extract::{Extension, Path};
|
||||
use axum::response::Redirect;
|
||||
use axum::Json;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Sentinel stuffed into `client_id_str` / `client_uuid` of an OidcSession
|
||||
/// so the callback can tell admin-UI flows apart from desktop-client flows.
|
||||
/// Real device UUIDs from the desktop client are hex-formatted GUIDs and
|
||||
/// won't collide.
|
||||
pub const ADMIN_SENTINEL: &str = "__admin_ui__";
|
||||
|
||||
pub async fn list_providers(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
) -> Json<Value> {
|
||||
let mut out: Vec<Value> = Vec::new();
|
||||
if !state.cfg.public_base_url.is_empty() {
|
||||
if let Ok(providers) = state.db.oidc_provider_list_enabled().await {
|
||||
for p in providers {
|
||||
out.push(json!({
|
||||
"name": p.name,
|
||||
"display_name": p.display_name.unwrap_or_else(|| p.name.clone()),
|
||||
"icon_url": p.icon_url,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Json(json!(out))
|
||||
}
|
||||
|
||||
pub async fn start_login(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
Path(provider_name): Path<String>,
|
||||
) -> Result<Redirect, ApiError> {
|
||||
if state.cfg.public_base_url.is_empty() {
|
||||
return Err(ApiError::Internal(
|
||||
"OIDC requires --public-base-url to be set".into(),
|
||||
));
|
||||
}
|
||||
let provider = require_provider(&state, &provider_name).await?;
|
||||
let disc = discovery::discover(&provider.issuer_url)
|
||||
.await
|
||||
.map_err(ApiError::Internal)?;
|
||||
|
||||
let code = random_token();
|
||||
let csrf_state = random_token();
|
||||
let expires_at = chrono::Utc::now().timestamp() + OIDC_SESSION_TTL_SECS;
|
||||
state
|
||||
.db
|
||||
.oidc_session_create(&OidcSessionInsert {
|
||||
code: &code,
|
||||
provider: &provider.name,
|
||||
state: &csrf_state,
|
||||
client_id_str: ADMIN_SENTINEL,
|
||||
client_uuid: ADMIN_SENTINEL,
|
||||
device_info_json: r#"{"source":"admin-ui"}"#,
|
||||
expires_at,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
let url = format!(
|
||||
"{auth}?response_type=code&client_id={cid}&redirect_uri={ru}&scope={scope}&state={st}",
|
||||
auth = disc.authorization_endpoint,
|
||||
cid = url_encode(&provider.client_id),
|
||||
ru = url_encode(&provider.redirect_url),
|
||||
scope = url_encode(&provider.scopes),
|
||||
st = url_encode(&csrf_state),
|
||||
);
|
||||
Ok(Redirect::temporary(&url))
|
||||
}
|
||||
|
||||
fn url_encode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.as_bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(*b as char);
|
||||
}
|
||||
_ => {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(out, "%{:02X}", b);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
//! Address books — read-only overview. Showing every AB on the server with
|
||||
//! its owner, kind (personal/shared), and peer count. Mutations live in the
|
||||
//! desktop client; admins use this page to confirm what's in place.
|
||||
//! Address books — list, create shared books, manage shares, delete.
|
||||
//! Personal books are owned by individual users and managed from the
|
||||
//! desktop client (the dashboard refuses to mutate them). Shared books
|
||||
//! are server-side artifacts: an admin creates one, then grants users
|
||||
//! `read` / `read+write` / `full` access; the desktop client picks them
|
||||
//! up via `/api/ab/shared/profiles` on the next AB sync (~30 s).
|
||||
|
||||
use super::shared::{fmt_unix, html_escape, require_admin};
|
||||
use super::shared::{fmt_unix, html_escape, notice_html, require_admin};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use crate::api::state::AppState;
|
||||
use axum::extract::Extension;
|
||||
use axum::extract::{Extension, Form, Path};
|
||||
use axum::response::Html;
|
||||
use serde::Deserialize;
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -16,25 +20,156 @@ pub async fn index(
|
||||
admin: AuthedUser,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
Ok(Html(render_index(&state, None).await?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateForm {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
Form(f): Form<CreateForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
let name = f.name.trim();
|
||||
if name.is_empty() {
|
||||
return Ok(Html(render_index(&state, Some(("error", "Name is required."))).await?));
|
||||
}
|
||||
let res = state.db.ab_create_shared(admin.user_id, name).await;
|
||||
let notice = match res {
|
||||
Ok(_) => Some(("ok", format!("Shared address book '{}' created.", name))),
|
||||
Err(e) => {
|
||||
// The unique index trips when the same admin creates two books
|
||||
// with the same name. Surface that cleanly instead of leaking
|
||||
// the raw SQL error.
|
||||
let msg = if e.to_string().to_lowercase().contains("unique") {
|
||||
"An address book with that name already exists.".to_string()
|
||||
} else {
|
||||
format!("Create failed: {}", e)
|
||||
};
|
||||
Some(("error", msg))
|
||||
}
|
||||
};
|
||||
let n = notice.as_ref().map(|(k, m)| (*k, m.as_str()));
|
||||
Ok(Html(render_index(&state, n).await?))
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
Path(guid): Path<String>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
let ok = state
|
||||
.db
|
||||
.ab_delete(&guid)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let notice = if ok {
|
||||
("ok", "Deleted.")
|
||||
} else {
|
||||
("error", "Address book not found.")
|
||||
};
|
||||
Ok(Html(render_index(&state, Some(notice)).await?))
|
||||
}
|
||||
|
||||
pub async fn manage(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
Path(guid): Path<String>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
Ok(Html(render_manage(&state, &guid, None).await?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ShareForm {
|
||||
pub user_id: i64,
|
||||
pub rule: i64,
|
||||
}
|
||||
|
||||
pub async fn share_add(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
Path(guid): Path<String>,
|
||||
Form(f): Form<ShareForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
if !(1..=3).contains(&f.rule) {
|
||||
return Ok(Html(render_manage(&state, &guid, Some(("error", "Invalid rule."))).await?));
|
||||
}
|
||||
state
|
||||
.db
|
||||
.ab_share_set(&guid, f.user_id, f.rule)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Html(render_manage(&state, &guid, Some(("ok", "Share saved."))).await?))
|
||||
}
|
||||
|
||||
pub async fn share_remove(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
Path((guid, user_id)): Path<(String, i64)>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
let _ = state
|
||||
.db
|
||||
.ab_share_remove(&guid, user_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
Ok(Html(render_manage(&state, &guid, Some(("ok", "Share removed."))).await?))
|
||||
}
|
||||
|
||||
// ---------- rendering ----------
|
||||
|
||||
async fn render_index(
|
||||
state: &Arc<AppState>,
|
||||
notice: Option<(&str, &str)>,
|
||||
) -> Result<String, ApiError> {
|
||||
let books = state
|
||||
.db
|
||||
.ab_list_all_with_owner()
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let notice_html = notice.map(|(k, m)| notice_html(k, m)).unwrap_or_default();
|
||||
let mut s = String::new();
|
||||
s.push_str(
|
||||
r##"<div class="space-y-4">
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<div id="ab-region" class="space-y-6">
|
||||
<header>
|
||||
<h2 class="text-lg font-semibold">Address books</h2>
|
||||
<p class="text-xs text-slate-500 mt-1">Read-only. Address-book contents are mutated from the desktop client; this page surfaces who owns what and how big each book is.</p>
|
||||
</header>"##,
|
||||
<p class="text-xs text-slate-500 mt-1">Personal books are owned by users and managed from their desktop client. Shared books are server-side: create one here, share it with users / rules, and the client picks it up on its next AB sync.</p>
|
||||
</header>
|
||||
|
||||
{notice_html}
|
||||
|
||||
<form
|
||||
class="flex items-end gap-2 bg-slate-900 border border-slate-800 rounded-lg p-3"
|
||||
hx-post="/admin/pages/address-books/create"
|
||||
hx-target="#ab-region" hx-swap="outerHTML"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="ab-name">New shared book</label>
|
||||
<input id="ab-name" name="name" type="text" required
|
||||
placeholder="Engineering laptops"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500" />
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition">
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
"##
|
||||
);
|
||||
if books.is_empty() {
|
||||
s.push_str(r##"<p class="text-slate-500 text-sm">No address books exist yet.</p></div>"##);
|
||||
return Ok(Html(s));
|
||||
return Ok(s);
|
||||
}
|
||||
s.push_str(
|
||||
r##"<div class="rounded-md border border-slate-800 bg-slate-900 overflow-hidden">
|
||||
r##"<div class="rounded-md border border-slate-800 bg-slate-900">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr>
|
||||
<th class="text-left font-medium px-3 py-2">Owner</th>
|
||||
@@ -43,15 +178,60 @@ pub async fn index(
|
||||
<th class="text-left font-medium px-3 py-2">Peers</th>
|
||||
<th class="text-left font-medium px-3 py-2">GUID</th>
|
||||
<th class="text-left font-medium px-3 py-2">Created</th>
|
||||
<th class="text-right font-medium px-3 py-2 w-1">Actions</th>
|
||||
</tr></thead>
|
||||
<tbody class="divide-y divide-slate-800">"##,
|
||||
);
|
||||
for b in &books {
|
||||
let kind = match b.kind {
|
||||
let kind_pill = match b.kind {
|
||||
0 => r#"<span class="text-xs px-1.5 py-0.5 rounded bg-slate-800 border border-slate-700 text-slate-300">personal</span>"#,
|
||||
1 => r#"<span class="text-xs px-1.5 py-0.5 rounded bg-violet-900/40 border border-violet-700/50 text-violet-300">shared</span>"#,
|
||||
_ => "",
|
||||
};
|
||||
// Both kinds get a delete action. Shared books additionally get
|
||||
// "Manage shares". Personal books carry an extra warning in the
|
||||
// confirm because the owning user's desktop client may resync
|
||||
// and recreate the book on next AB tick — deletion is "reset to
|
||||
// empty", not "permanently revoked".
|
||||
let actions = if b.kind == 1 {
|
||||
format!(
|
||||
r##"<details class="text-right relative">
|
||||
<summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary>
|
||||
<div class="absolute right-2 mt-1 z-10 w-48 bg-slate-900 border border-slate-700 rounded shadow-lg p-2 space-y-1 text-left">
|
||||
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
|
||||
hx-get="/admin/pages/address-books/{guid}/manage"
|
||||
hx-target="#ab-region" hx-swap="outerHTML">
|
||||
Manage shares
|
||||
</button>
|
||||
<hr class="border-slate-700 my-1" />
|
||||
<button class="w-full text-left px-2 py-1 text-xs text-rose-300 hover:bg-rose-900/40 rounded"
|
||||
hx-post="/admin/pages/address-books/{guid}/delete"
|
||||
hx-target="#ab-region" hx-swap="outerHTML"
|
||||
hx-confirm="Delete shared book '{name}'? Peers, tags, and shares will be removed.">
|
||||
Delete book
|
||||
</button>
|
||||
</div>
|
||||
</details>"##,
|
||||
guid = html_escape(&b.guid),
|
||||
name = html_escape(&b.name)
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r##"<details class="text-right relative">
|
||||
<summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary>
|
||||
<div class="absolute right-2 mt-1 z-10 w-56 bg-slate-900 border border-slate-700 rounded shadow-lg p-2 space-y-1 text-left">
|
||||
<button class="w-full text-left px-2 py-1 text-xs text-rose-300 hover:bg-rose-900/40 rounded"
|
||||
hx-post="/admin/pages/address-books/{guid}/delete"
|
||||
hx-target="#ab-region" hx-swap="outerHTML"
|
||||
hx-confirm="Delete {owner}'s personal book? This wipes all peers and tags on the server. If {owner}'s desktop client is still signed in, it will recreate an empty personal book on its next sync (~30 s).">
|
||||
Delete book
|
||||
</button>
|
||||
</div>
|
||||
</details>"##,
|
||||
guid = html_escape(&b.guid),
|
||||
owner = html_escape(&b.owner_username)
|
||||
)
|
||||
};
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<tr>
|
||||
@@ -61,15 +241,170 @@ pub async fn index(
|
||||
<td class="px-3 py-2 text-slate-400">{count}</td>
|
||||
<td class="px-3 py-2 font-mono text-xs text-slate-500">{guid}</td>
|
||||
<td class="px-3 py-2 text-slate-500 text-xs">{created}</td>
|
||||
<td class="px-3 py-2">{actions}</td>
|
||||
</tr>"##,
|
||||
owner = html_escape(&b.owner_username),
|
||||
kind = kind,
|
||||
kind = kind_pill,
|
||||
name = html_escape(&b.name),
|
||||
count = b.peer_count,
|
||||
guid = html_escape(&b.guid),
|
||||
created = html_escape(&fmt_unix(b.created_at)),
|
||||
actions = actions,
|
||||
);
|
||||
}
|
||||
s.push_str("</tbody></table></div></div>");
|
||||
Ok(Html(s))
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
async fn render_manage(
|
||||
state: &Arc<AppState>,
|
||||
guid: &str,
|
||||
notice: Option<(&str, &str)>,
|
||||
) -> Result<String, ApiError> {
|
||||
let owner_kind = state
|
||||
.db
|
||||
.ab_get_owner_kind(guid)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let Some((_owner_id, kind)) = owner_kind else {
|
||||
return Ok(format!(
|
||||
r##"<div id="ab-region">{notice}</div>"##,
|
||||
notice = notice_html("error", "Address book not found."),
|
||||
));
|
||||
};
|
||||
if kind != 1 {
|
||||
return Ok(format!(
|
||||
r##"<div id="ab-region">{notice}</div>"##,
|
||||
notice = notice_html("error", "Personal address books are managed from the desktop client."),
|
||||
));
|
||||
}
|
||||
let shares = state
|
||||
.db
|
||||
.ab_list_shares(guid)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let (_total, users) = state
|
||||
.db
|
||||
.users_list_all(0, 1000)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let already_shared: std::collections::HashSet<i64> =
|
||||
shares.iter().map(|s| s.user_id).collect();
|
||||
|
||||
let notice_html = notice.map(|(k, m)| notice_html(k, m)).unwrap_or_default();
|
||||
let mut s = String::new();
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<div id="ab-region" class="space-y-6">
|
||||
<header class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Manage shares</h2>
|
||||
<p class="text-xs text-slate-500 mt-1 font-mono">{guid}</p>
|
||||
</div>
|
||||
<button
|
||||
class="text-xs text-slate-300 hover:text-slate-100 px-2 py-1 rounded border border-slate-700 hover:border-slate-500"
|
||||
hx-get="/admin/pages/address-books"
|
||||
hx-target="#ab-region" hx-swap="outerHTML">
|
||||
← Back
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{notice_html}
|
||||
|
||||
<section class="rounded-md border border-slate-800 bg-slate-900 p-4 space-y-3">
|
||||
<h3 class="text-sm font-semibold text-slate-200">Add or update a share</h3>
|
||||
<form
|
||||
class="flex flex-wrap items-end gap-2"
|
||||
hx-post="/admin/pages/address-books/{guid}/shares/add"
|
||||
hx-target="#ab-region" hx-swap="outerHTML"
|
||||
>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="share-user">User</label>
|
||||
<select id="share-user" name="user_id" required
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500">"##,
|
||||
guid = html_escape(guid),
|
||||
notice_html = notice_html,
|
||||
);
|
||||
if users.is_empty() {
|
||||
s.push_str(r#"<option disabled>No users defined</option>"#);
|
||||
}
|
||||
for u in &users {
|
||||
let already = if already_shared.contains(&u.id) { " (existing — will update rule)" } else { "" };
|
||||
let _ = write!(
|
||||
s,
|
||||
r#"<option value="{id}">{name}{already}</option>"#,
|
||||
id = u.id,
|
||||
name = html_escape(&u.username),
|
||||
already = html_escape(already),
|
||||
);
|
||||
}
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="share-rule">Rule</label>
|
||||
<select id="share-rule" name="rule"
|
||||
class="bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500">
|
||||
<option value="1">Read</option>
|
||||
<option value="2" selected>Read + write</option>
|
||||
<option value="3">Full</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="rounded-md border border-slate-800 bg-slate-900">
|
||||
<header class="px-4 py-2 text-xs uppercase text-slate-500 border-b border-slate-800">
|
||||
Current shares ({n})
|
||||
</header>
|
||||
"##,
|
||||
n = shares.len()
|
||||
);
|
||||
if shares.is_empty() {
|
||||
s.push_str(r##"<p class="px-4 py-3 text-slate-500 text-sm">No shares yet. The book is invisible to non-owners until you add at least one user share.</p></section></div>"##);
|
||||
return Ok(s);
|
||||
}
|
||||
s.push_str(
|
||||
r##"<table class="w-full text-sm">
|
||||
<thead class="text-xs uppercase text-slate-500 bg-slate-950"><tr>
|
||||
<th class="text-left font-medium px-3 py-2">User</th>
|
||||
<th class="text-left font-medium px-3 py-2">Rule</th>
|
||||
<th class="text-right font-medium px-3 py-2 w-1"></th>
|
||||
</tr></thead>
|
||||
<tbody class="divide-y divide-slate-800">"##,
|
||||
);
|
||||
for sh in &shares {
|
||||
let rule = match sh.rule {
|
||||
1 => "Read",
|
||||
2 => "Read + write",
|
||||
3 => "Full",
|
||||
_ => "?",
|
||||
};
|
||||
let _ = write!(
|
||||
s,
|
||||
r##"<tr>
|
||||
<td class="px-3 py-2 text-slate-200">{user}</td>
|
||||
<td class="px-3 py-2 text-slate-400">{rule}</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<button class="text-xs text-rose-300 hover:text-rose-200 px-2 py-1 rounded hover:bg-rose-900/40"
|
||||
hx-post="/admin/pages/address-books/{guid}/shares/{uid}/remove"
|
||||
hx-target="#ab-region" hx-swap="outerHTML"
|
||||
hx-confirm="Remove {user}'s access?">
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>"##,
|
||||
user = html_escape(&sh.username),
|
||||
rule = rule,
|
||||
guid = html_escape(guid),
|
||||
uid = sh.user_id,
|
||||
);
|
||||
}
|
||||
s.push_str("</tbody></table></section></div>");
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
//! Deploy page — generates a `CustomServer` blob the Windows / macOS / Linux
|
||||
//! client accepts via `rustdesk --config <blob>`, plus the equivalent
|
||||
//! rename-the-installer filename. The blob format is documented at
|
||||
//! `<rustdesk>/src/custom_server.rs`: JSON → URL-safe-no-pad base64 →
|
||||
//! reverse-the-string. The client tries the unsigned JSON path before
|
||||
//! signature verification, so we don't need the Pro private key.
|
||||
|
||||
use super::shared::{html_escape, require_admin};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::api::middleware::AuthedUser;
|
||||
use axum::extract::Form;
|
||||
use axum::response::Html;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
pub async fn index(admin: AuthedUser) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
let pubkey = read_pubkey();
|
||||
Ok(Html(render_form(&pubkey, "", "", "", "", None)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeployForm {
|
||||
#[serde(default)]
|
||||
pub host: String,
|
||||
#[serde(default)]
|
||||
pub api: String,
|
||||
#[serde(default)]
|
||||
pub relay: String,
|
||||
#[serde(default)]
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
pub async fn generate(
|
||||
admin: AuthedUser,
|
||||
Form(f): Form<DeployForm>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
if f.host.trim().is_empty() {
|
||||
return Ok(Html(render_form(
|
||||
&f.key,
|
||||
&f.host,
|
||||
&f.api,
|
||||
&f.relay,
|
||||
"",
|
||||
Some(("error", "Host is required.")),
|
||||
)));
|
||||
}
|
||||
let blob = encode_blob(&f.host, &f.key, &f.api, &f.relay);
|
||||
let result = render_result(&f.host, &f.key, &f.api, &f.relay, &blob);
|
||||
Ok(Html(render_form(
|
||||
&f.key,
|
||||
&f.host,
|
||||
&f.api,
|
||||
&f.relay,
|
||||
&result,
|
||||
None,
|
||||
)))
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
/// Best-effort read of the server's public key from `id_ed25519.pub` in CWD —
|
||||
/// the same path `common::gen_sk` writes it to. If the file is missing
|
||||
/// (operator passed `--key` explicitly, or the binary runs from a directory
|
||||
/// they can't read), the field is left blank for them to paste.
|
||||
fn read_pubkey() -> String {
|
||||
std::fs::read_to_string("id_ed25519.pub")
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Encode a `CustomServer` payload the way the client's
|
||||
/// `get_custom_server_from_config_string` expects: JSON → URL-safe-no-pad
|
||||
/// base64 → reverse the resulting string. The client reverses it back, base64
|
||||
/// decodes, then JSON parses.
|
||||
fn encode_blob(host: &str, key: &str, api: &str, relay: &str) -> String {
|
||||
let payload = json!({
|
||||
"host": host,
|
||||
"key": key,
|
||||
"api": api,
|
||||
"relay": relay,
|
||||
});
|
||||
let b64 = base64::encode_config(payload.to_string().as_bytes(), base64::URL_SAFE_NO_PAD);
|
||||
b64.chars().rev().collect()
|
||||
}
|
||||
|
||||
fn render_form(
|
||||
key: &str,
|
||||
host: &str,
|
||||
api: &str,
|
||||
relay: &str,
|
||||
result_html: &str,
|
||||
notice: Option<(&str, &str)>,
|
||||
) -> String {
|
||||
let notice_html = match notice {
|
||||
Some((kind, msg)) => super::shared::notice_html(kind, msg),
|
||||
None => String::new(),
|
||||
};
|
||||
format!(
|
||||
r##"<div class="space-y-6">
|
||||
<header>
|
||||
<h2 class="text-lg font-semibold">Deploy</h2>
|
||||
<p class="text-xs text-slate-500 mt-1">Generate a config blob the stock client accepts via <code>rustdesk --config <blob></code>, or the equivalent renamed-installer filename. The public key below is read from <code>id_ed25519.pub</code> on the server; override if you bootstrapped a custom keypair.</p>
|
||||
</header>
|
||||
|
||||
{notice_html}
|
||||
|
||||
<form
|
||||
class="space-y-3 bg-slate-900 border border-slate-800 rounded-lg p-4"
|
||||
hx-post="/admin/pages/deploy/generate"
|
||||
hx-target="#main"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="host">Rendezvous host (required)</label>
|
||||
<input id="host" name="host" type="text" required value="{host}"
|
||||
placeholder="rustdesk.example.com or 203.0.113.10"
|
||||
oninput="document.getElementById('api').placeholder='http://'+this.value+':21114'"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500" />
|
||||
<p class="text-xs text-slate-500 mt-1">The hostname or IP clients reach hbbs at (TCP/UDP 21116).</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="api">API URL (optional)</label>
|
||||
<input id="api" name="api" type="text" value="{api}"
|
||||
placeholder="http://host:21114"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500" />
|
||||
<p class="text-xs text-slate-500 mt-1">Full URL of this admin/login API. Leave blank to disable login on the client.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="relay">Relay host (optional)</label>
|
||||
<input id="relay" name="relay" type="text" value="{relay}"
|
||||
placeholder="rustdesk.example.com"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500" />
|
||||
<p class="text-xs text-slate-500 mt-1">Only set if hbbr runs on a separate host; otherwise leave blank.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1" for="key">Public key</label>
|
||||
<textarea id="key" name="key" rows="2"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-xs font-mono focus:outline-none focus:border-sky-500">{key}</textarea>
|
||||
<p class="text-xs text-slate-500 mt-1">Base64 contents of <code>id_ed25519.pub</code>.</p>
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition">
|
||||
Generate
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{result_html}
|
||||
</div>"##,
|
||||
host = html_escape(host),
|
||||
api = html_escape(api),
|
||||
relay = html_escape(relay),
|
||||
key = html_escape(key),
|
||||
notice_html = notice_html,
|
||||
result_html = result_html,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_result(host: &str, key: &str, api: &str, relay: &str, blob: &str) -> String {
|
||||
// Build the rename-the-installer alternative. Windows filenames disallow
|
||||
// `:` and `/`, which the API URL is full of (`http://host:21114`). The
|
||||
// client falls back to `http://<host>:21114` when `api` is empty
|
||||
// (rustdesk/src/common.rs:get_api_server_), so we can omit the API field
|
||||
// from the filename whenever it matches that default. If the operator
|
||||
// supplied a non-default API URL we still build a "renamed" string for
|
||||
// reference but mark it as unusable on Windows and steer them to the
|
||||
// --config path.
|
||||
let default_api = format!("http://{}:21114", host);
|
||||
let api_is_default = api.is_empty() || api == default_api;
|
||||
let unsafe_chars = str_has_filename_unsafe_chars(api) && !api_is_default;
|
||||
|
||||
let mut renamed = format!("rustdesk-host={}", host);
|
||||
if !key.is_empty() {
|
||||
renamed.push_str(&format!(",key={}", key));
|
||||
}
|
||||
if !api_is_default && !unsafe_chars {
|
||||
renamed.push_str(&format!(",api={}", api));
|
||||
}
|
||||
if !relay.is_empty() && !str_has_filename_unsafe_chars(relay) {
|
||||
renamed.push_str(&format!(",relay={}", relay));
|
||||
}
|
||||
renamed.push_str(".exe");
|
||||
|
||||
let renamed_note = if unsafe_chars {
|
||||
r##"<p class="text-xs text-rose-300 mt-1">⚠ Your API URL contains <code>:</code> or <code>/</code>, which Windows forbids in filenames. The renamed-installer approach cannot carry it — use approach A above instead.</p>"##.to_string()
|
||||
} else if !api_is_default {
|
||||
format!(
|
||||
r##"<p class="text-xs text-amber-300 mt-1">⚠ Your API URL ({api_disp}) is not the default <code>http://<host>:21114</code>. The client will auto-derive the default from the rendezvous host on first launch, so this filename will deploy with the wrong API URL. Use approach A instead.</p>"##,
|
||||
api_disp = html_escape(api)
|
||||
)
|
||||
} else if !api.is_empty() {
|
||||
r##"<p class="text-xs text-slate-500 mt-1">API URL omitted from filename (Windows can't store <code>:</code> / <code>/</code>); the client auto-derives <code>http://<host>:21114</code> from the rendezvous host.</p>"##.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let licensed = format!("rustdesk-licensed-{}", blob);
|
||||
let cmd_win = format!(
|
||||
r#""C:\Program Files\RustDesk\rustdesk.exe" --config {licensed}"#,
|
||||
licensed = licensed
|
||||
);
|
||||
let cmd_unix = format!("rustdesk --config {}", licensed);
|
||||
|
||||
format!(
|
||||
r##"<section class="space-y-4 bg-slate-900 border border-slate-800 rounded-lg p-4">
|
||||
<header>
|
||||
<h3 class="text-sm font-semibold text-slate-200">Deployment artifact</h3>
|
||||
<p class="text-xs text-slate-500 mt-1">Pick whichever path fits your rollout. Both produce the same client config.</p>
|
||||
</header>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1">A. Post-install command (Windows, Administrator)</label>
|
||||
<pre class="text-xs bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{cmd_win}</pre>
|
||||
<p class="text-xs text-slate-500 mt-1">Requires the client to be installed and running as admin. Equivalent on macOS/Linux: <code class="text-slate-300">{cmd_unix}</code>.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-slate-400 mb-1">B. Renamed installer (drop-in)</label>
|
||||
<pre class="text-xs bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{renamed}</pre>
|
||||
<p class="text-xs text-slate-500 mt-1">Rename the official RustDesk installer to this exact name and run it; the client reads its own filename on first launch and writes the config into the registry.</p>
|
||||
{renamed_note}
|
||||
</div>
|
||||
|
||||
<details class="text-xs text-slate-400">
|
||||
<summary class="cursor-pointer text-slate-300 select-none">Raw blob</summary>
|
||||
<pre class="mt-2 bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{blob}</pre>
|
||||
</details>
|
||||
</section>"##,
|
||||
cmd_win = html_escape(&cmd_win),
|
||||
cmd_unix = html_escape(&cmd_unix),
|
||||
renamed = html_escape(&renamed),
|
||||
renamed_note = renamed_note,
|
||||
blob = html_escape(blob),
|
||||
)
|
||||
}
|
||||
|
||||
/// Rough check for characters Windows disallows in filenames. We don't try
|
||||
/// to be exhaustive (NUL, control chars etc. won't realistically appear in a
|
||||
/// hostname or URL), just the ones a typical URL/relay value will trip on.
|
||||
fn str_has_filename_unsafe_chars(s: &str) -> bool {
|
||||
s.chars()
|
||||
.any(|c| matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|'))
|
||||
}
|
||||
@@ -75,6 +75,25 @@ pub async fn force_sysinfo(
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
admin: AuthedUser,
|
||||
Path(peer_id): Path<String>,
|
||||
) -> Result<Html<String>, ApiError> {
|
||||
require_admin(&admin)?;
|
||||
let ok = state
|
||||
.db
|
||||
.device_delete(&peer_id)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let msg = if ok {
|
||||
format!("Deleted device {}.", peer_id)
|
||||
} else {
|
||||
"Device already gone.".to_string()
|
||||
};
|
||||
notice_then_table(&state, if ok { "ok" } else { "error" }, &msg).await
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
|
||||
@@ -161,6 +180,13 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow) {
|
||||
hx-target="#devices-region" hx-swap="innerHTML">
|
||||
Force sysinfo refresh
|
||||
</button>
|
||||
<hr class="border-slate-700 my-1" />
|
||||
<button class="w-full text-left px-2 py-1 text-xs text-rose-300 hover:bg-rose-900/40 rounded"
|
||||
hx-post="/admin/pages/devices/{id}/delete"
|
||||
hx-target="#devices-region" hx-swap="innerHTML"
|
||||
hx-confirm="Delete {id}? This removes the dashboard row and the rendezvous identity. Audit logs and recordings are kept.">
|
||||
Delete device
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
pub mod address_books;
|
||||
pub mod audit;
|
||||
pub mod deploy;
|
||||
pub mod devices;
|
||||
pub mod groups;
|
||||
pub mod oidc;
|
||||
|
||||
+20
-5
@@ -75,9 +75,16 @@ 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.
|
||||
// The desktop client reuses the email-code dialog for the TOTP second
|
||||
// leg: it POSTs `type: "email_code"` with `tfaCode` set (and the email
|
||||
// `verificationCode` field also set, but we ignore that when tfaCode is
|
||||
// present). Detect that shape up-front and route to the TOTP verifier;
|
||||
// otherwise dispatch on the declared `type`.
|
||||
let has_tfa = req.tfa_code.as_deref().is_some_and(|s| !s.is_empty())
|
||||
&& req.secret.as_deref().is_some_and(|s| !s.is_empty());
|
||||
if has_tfa {
|
||||
return login_tfa_code(state, req).await;
|
||||
}
|
||||
let kind = req.kind.as_deref().unwrap_or("account");
|
||||
match kind {
|
||||
"account" | "" => login_account(state, req).await,
|
||||
@@ -213,14 +220,22 @@ async fn login_account(
|
||||
// 2FA gate: if the user has TOTP enrolled, mint a short-lived nonce and
|
||||
// tell the client we want the TOTP code in a follow-up POST. The client
|
||||
// echoes the nonce back as `secret`.
|
||||
//
|
||||
// Wire shape matches the Flutter client's expectations
|
||||
// (flutter/lib/common/widgets/login.dart:485): the outer `type` is the
|
||||
// generic `email_check` envelope (the dialog the client opens for any
|
||||
// second-leg challenge), and `tfa_type` distinguishes TOTP (`tfa_check`)
|
||||
// from email (`email_check`). Returning `type:"tfa_check"` directly
|
||||
// would miss the switch's only branch and surface as the unhelpful
|
||||
// "bad response from server" toast.
|
||||
if state.db.totp_get_secret(user.id).await?.is_some() {
|
||||
let nonce = state
|
||||
.db
|
||||
.tfa_challenge_create(user.id, TFA_CHALLENGE_TTL_SECS)
|
||||
.await?;
|
||||
return Ok(Json(json!({
|
||||
"type": "tfa_check",
|
||||
"tfa_type": "totp",
|
||||
"type": "email_check",
|
||||
"tfa_type": "tfa_check",
|
||||
"secret": nonce,
|
||||
})));
|
||||
}
|
||||
|
||||
+138
-17
@@ -6,12 +6,15 @@
|
||||
//! The browser sees a small "you can close this window" page; the desktop
|
||||
//! client picks up the token via `/api/oidc/auth-query`.
|
||||
|
||||
use crate::api::admin::oidc_login::ADMIN_SENTINEL;
|
||||
use crate::api::auth::mint_token;
|
||||
use crate::api::middleware::sha256_token;
|
||||
use crate::api::middleware::{sha256_token, SESSION_COOKIE};
|
||||
use crate::api::oidc::{discovery, require_provider};
|
||||
use crate::api::state::AppState;
|
||||
use axum::extract::{Extension, Query};
|
||||
use axum::response::Html;
|
||||
use axum::http::header::{LOCATION, SET_COOKIE};
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
@@ -33,17 +36,50 @@ pub struct CallbackQuery {
|
||||
pub async fn callback(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
Query(q): Query<CallbackQuery>,
|
||||
) -> Html<String> {
|
||||
match handle(state, q).await {
|
||||
Ok(()) => Html(html_page(
|
||||
) -> Response {
|
||||
match handle(state.clone(), q).await {
|
||||
Ok(ok) if ok.is_admin_flow => {
|
||||
if !ok.user_is_admin {
|
||||
return Html(html_page(
|
||||
"Sign-in failed",
|
||||
"This account does not have admin access. Ask an existing admin to grant it on the Users page, then try again.",
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
// Set the dashboard session cookie and redirect to /admin/.
|
||||
// Same cookie shape /admin/login uses on success.
|
||||
let cookie = format!(
|
||||
"{name}={token}; HttpOnly; Path=/; SameSite=Strict; Max-Age={ttl}",
|
||||
name = SESSION_COOKIE,
|
||||
token = ok.token,
|
||||
ttl = state.cfg.session_ttl_secs,
|
||||
);
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Ok(v) = HeaderValue::from_str(&cookie) {
|
||||
headers.insert(SET_COOKIE, v);
|
||||
}
|
||||
headers.insert(LOCATION, HeaderValue::from_static("/admin/"));
|
||||
(StatusCode::SEE_OTHER, headers).into_response()
|
||||
}
|
||||
Ok(_) => Html(html_page(
|
||||
"Sign-in complete",
|
||||
"You can close this window and return to RustDesk.",
|
||||
)),
|
||||
Err(msg) => Html(html_page("Sign-in failed", &html_escape(&msg))),
|
||||
))
|
||||
.into_response(),
|
||||
Err(msg) => Html(html_page("Sign-in failed", &html_escape(&msg))).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle(state: Arc<AppState>, q: CallbackQuery) -> Result<(), String> {
|
||||
struct HandleOk {
|
||||
/// Bearer token freshly minted for the local user. For the admin flow
|
||||
/// we set it as `rd_admin_session`; for the desktop flow it's already
|
||||
/// stashed on the OidcSession row for `/api/oidc/auth-query` polling.
|
||||
token: String,
|
||||
user_is_admin: bool,
|
||||
is_admin_flow: bool,
|
||||
}
|
||||
|
||||
async fn handle(state: Arc<AppState>, q: CallbackQuery) -> Result<HandleOk, String> {
|
||||
if q.state.is_empty() {
|
||||
return Err("missing state parameter".into());
|
||||
}
|
||||
@@ -123,9 +159,22 @@ async fn handle(state: Arc<AppState>, q: CallbackQuery) -> Result<(), String> {
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| userinfo.get("preferred_username").and_then(|v| v.as_str()));
|
||||
|
||||
// Optional role-based admin sync. When the provider is configured with
|
||||
// `admin_role`, we look up the userinfo claim at `roles_claim` (default
|
||||
// "roles") and set is_admin accordingly. Two shapes are supported:
|
||||
// - object: presence of the role name as a key
|
||||
// (Zitadel default: `"urn:zitadel:iam:org:project:roles":
|
||||
// {"admin": {"<orgid>": "<orgname>"}}`)
|
||||
// - array of strings: presence of the role name as an element
|
||||
// (e.g. a custom claim mapping `"roles": ["admin", "user"]`)
|
||||
let desired_admin = provider.admin_role.as_deref().map(|role| {
|
||||
let claim_name = provider.roles_claim.as_deref().unwrap_or("roles");
|
||||
eval_admin_role(&userinfo, claim_name, role)
|
||||
});
|
||||
|
||||
let user = state
|
||||
.db
|
||||
.user_upsert_oidc(sub, email, display_name)
|
||||
.user_upsert_oidc(sub, email, display_name, desired_admin)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if user.status == 0 {
|
||||
@@ -135,30 +184,50 @@ async fn handle(state: Arc<AppState>, q: CallbackQuery) -> Result<(), String> {
|
||||
// Mint our own access token, store hashed, mark session complete.
|
||||
let token = mint_token();
|
||||
let sha = sha256_token(&token);
|
||||
let is_admin_flow = session.client_uuid == ADMIN_SENTINEL;
|
||||
// For admin-UI OIDC the "device id/uuid" fields carry the sentinel —
|
||||
// don't pollute the tokens.peer_* columns with it.
|
||||
let (token_peer_id, token_peer_uuid): (&str, &str) = if is_admin_flow {
|
||||
("", "")
|
||||
} else {
|
||||
(
|
||||
session.client_id_str.as_str(),
|
||||
session.client_uuid.as_str(),
|
||||
)
|
||||
};
|
||||
state
|
||||
.db
|
||||
.token_insert(
|
||||
user.id,
|
||||
&sha,
|
||||
&session.client_id_str,
|
||||
&session.client_uuid,
|
||||
token_peer_id,
|
||||
token_peer_uuid,
|
||||
&session.device_info_json,
|
||||
state.cfg.session_ttl_secs,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Best-effort device claim — same path as `/api/login`.
|
||||
state
|
||||
.db
|
||||
.device_claim(user.id, &session.client_id_str, &session.client_uuid)
|
||||
.await;
|
||||
// Best-effort device claim — same path as `/api/login`. Skipped for
|
||||
// admin-UI flow because the "device" is the operator's browser, not a
|
||||
// real RustDesk peer; calling device_claim with the sentinel would
|
||||
// insert a phantom row in device_sysinfo.
|
||||
if !is_admin_flow {
|
||||
state
|
||||
.db
|
||||
.device_claim(user.id, &session.client_id_str, &session.client_uuid)
|
||||
.await;
|
||||
}
|
||||
|
||||
state
|
||||
.db
|
||||
.oidc_session_complete(&session.code, &token, user.id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
Ok(HandleOk {
|
||||
token,
|
||||
user_is_admin: user.is_admin,
|
||||
is_admin_flow,
|
||||
})
|
||||
}
|
||||
|
||||
fn html_page(title: &str, body: &str) -> String {
|
||||
@@ -189,3 +258,55 @@ fn html_escape(s: &str) -> String {
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
/// Returns true iff the userinfo's `claim_name` field carries `role` —
|
||||
/// either as an object key (Zitadel) or as an element of a string array
|
||||
/// (generic). Anything else (missing claim, wrong type, role not present)
|
||||
/// is treated as "not admin" so a misconfigured claim demotes rather than
|
||||
/// silently grants.
|
||||
fn eval_admin_role(userinfo: &Value, claim_name: &str, role: &str) -> bool {
|
||||
let Some(node) = userinfo.get(claim_name) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(obj) = node.as_object() {
|
||||
return obj.contains_key(role);
|
||||
}
|
||||
if let Some(arr) = node.as_array() {
|
||||
return arr
|
||||
.iter()
|
||||
.any(|v| v.as_str().map(|s| s == role).unwrap_or(false));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn admin_role_zitadel_object_shape() {
|
||||
let u = json!({
|
||||
"sub": "1",
|
||||
"urn:zitadel:iam:org:project:roles": {
|
||||
"admin": {"123": "myorg"},
|
||||
"user": {"123": "myorg"},
|
||||
},
|
||||
});
|
||||
assert!(eval_admin_role(&u, "urn:zitadel:iam:org:project:roles", "admin"));
|
||||
assert!(!eval_admin_role(&u, "urn:zitadel:iam:org:project:roles", "owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_role_generic_array_shape() {
|
||||
let u = json!({"sub": "1", "roles": ["admin", "user"]});
|
||||
assert!(eval_admin_role(&u, "roles", "admin"));
|
||||
assert!(!eval_admin_role(&u, "roles", "owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_role_missing_claim_is_not_admin() {
|
||||
let u = json!({"sub": "1"});
|
||||
assert!(!eval_admin_role(&u, "roles", "admin"));
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ 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
|
||||
pub(crate) const OIDC_SESSION_TTL_SECS: i64 = 600; // 10 minutes — the user has to sign in fast
|
||||
|
||||
/// Convenience: resolve a provider name to its row, or an ApiError if it
|
||||
/// doesn't exist or is disabled.
|
||||
|
||||
+23
-17
@@ -1,13 +1,17 @@
|
||||
//! `GET /api/oidc/auth-query?code=&id=&uuid=` — client poll loop.
|
||||
//! `GET /api/oidc/auth-query?code=&id=&uuid=` — desktop-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:
|
||||
//! Wire shape: return the inner payload as the HTTP body directly. Do NOT
|
||||
//! wrap in another `{ "body": ... }` envelope — the desktop client's
|
||||
//! transport (`http_request_sync` in src/common.rs) already wraps every
|
||||
//! response in `{ status_code, headers, body }` and feeds the inner `body`
|
||||
//! string to `HbbHttpResponse::parse`. An extra envelope makes the parser
|
||||
//! see `{"body": "..."}`, fail to deserialize as `AuthBody`, and silently
|
||||
//! retry until the 180 s client timeout. Spent half a day on this — keep
|
||||
//! the bare shape.
|
||||
//!
|
||||
//! `{ "body": "<inner-json-string>" }`
|
||||
//!
|
||||
//! The inner JSON is one of:
|
||||
//! Inner payloads:
|
||||
//! - while pending: `{"error":"No authed oidc is found"}` — client keeps polling.
|
||||
//! - on success: the standard AuthBody (`{access_token, type:"access_token", user}`).
|
||||
//! - on success: `{access_token, type:"access_token", user}` — client stops.
|
||||
//! - on error: `{"error":"<message>"}` — client surfaces and stops polling.
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
@@ -39,19 +43,26 @@ pub async fn auth_query(
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||
.ok_or_else(|| ApiError::BadRequest("unknown oidc session".into()))?;
|
||||
hbb_common::log::info!(
|
||||
"oidc poll: code={} status={} user_id={:?} elapsed_to_expiry={}",
|
||||
q.code,
|
||||
session.status,
|
||||
session.user_id,
|
||||
session.expires_at - now,
|
||||
);
|
||||
if session.expires_at <= now && session.status == "pending" {
|
||||
// The client treats this as an ordinary "still pending" tick and
|
||||
// gives up on its own timeout (180 s).
|
||||
return Ok(wrap_inner(json!({"error": "No authed oidc is found"})));
|
||||
return Ok(Json(json!({"error": "No authed oidc is found"})));
|
||||
}
|
||||
match session.status.as_str() {
|
||||
"pending" => Ok(wrap_inner(json!({"error": "No authed oidc is found"}))),
|
||||
"pending" => Ok(Json(json!({"error": "No authed oidc is found"}))),
|
||||
"error" => {
|
||||
let msg = session
|
||||
.error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "OIDC sign-in failed".to_string());
|
||||
Ok(wrap_inner(json!({ "error": msg })))
|
||||
Ok(Json(json!({ "error": msg })))
|
||||
}
|
||||
"success" => {
|
||||
let access_token = session
|
||||
@@ -67,12 +78,11 @@ pub async fn auth_query(
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?
|
||||
.ok_or_else(|| ApiError::Internal("user vanished mid-flow".into()))?;
|
||||
let body = json!({
|
||||
Ok(Json(json!({
|
||||
"access_token": access_token,
|
||||
"type": "access_token",
|
||||
"user": UserPayload::from(&user),
|
||||
});
|
||||
Ok(wrap_inner(body))
|
||||
})))
|
||||
}
|
||||
other => Err(ApiError::Internal(format!(
|
||||
"unknown oidc status {:?}",
|
||||
@@ -80,7 +90,3 @@ pub async fn auth_query(
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_inner(inner: Value) -> Json<Value> {
|
||||
Json(json!({ "body": inner.to_string() }))
|
||||
}
|
||||
|
||||
@@ -45,6 +45,16 @@ struct ProviderEntry {
|
||||
redirect_url: Option<String>,
|
||||
#[serde(default = "default_true")]
|
||||
enabled: bool,
|
||||
/// Role-based admin sync. Set both to drive `is_admin` from the IdP:
|
||||
/// admin_role = "admin"
|
||||
/// roles_claim = "urn:zitadel:iam:org:project:roles" # Zitadel
|
||||
/// Or for a generic IdP that emits `roles: ["admin","user"]`:
|
||||
/// admin_role = "admin"
|
||||
/// # roles_claim defaults to "roles"
|
||||
#[serde(default)]
|
||||
admin_role: Option<String>,
|
||||
#[serde(default)]
|
||||
roles_claim: Option<String>,
|
||||
}
|
||||
|
||||
fn default_scopes() -> String {
|
||||
@@ -83,6 +93,8 @@ pub async fn load_from_file(
|
||||
scopes: p.scopes,
|
||||
redirect_url,
|
||||
enabled: p.enabled,
|
||||
admin_role: p.admin_role.filter(|s| !s.is_empty()),
|
||||
roles_claim: p.roles_claim.filter(|s| !s.is_empty()),
|
||||
};
|
||||
db.oidc_provider_upsert(&row)
|
||||
.await
|
||||
|
||||
@@ -7,6 +7,7 @@ use axum::extract::{Extension, Query};
|
||||
use axum::Json;
|
||||
use hbb_common::ResultType;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -18,6 +19,13 @@ pub struct UserPayload {
|
||||
pub note: String,
|
||||
pub status: i64,
|
||||
pub is_admin: bool,
|
||||
/// The desktop client's OIDC poll loop deserializes the AuthBody using
|
||||
/// the Rust struct in src/hbbs_http/account.rs, where `info` is a
|
||||
/// REQUIRED field (no #[serde(default)]). Missing it makes serde fail,
|
||||
/// the poll loop's `Ok(_)` arm fires, and the client polls forever
|
||||
/// even though the OIDC session was successful. Emit an empty object
|
||||
/// — the client's own UserInfo defaults handle the rest.
|
||||
pub info: Value,
|
||||
}
|
||||
|
||||
impl From<&UserRow> for UserPayload {
|
||||
@@ -30,6 +38,7 @@ impl From<&UserRow> for UserPayload {
|
||||
note: u.note.clone(),
|
||||
status: u.status,
|
||||
is_admin: u.is_admin,
|
||||
info: json!({}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+214
-6
@@ -118,6 +118,15 @@ pub struct AbOverviewRow {
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AbShareDetailRow {
|
||||
pub user_id: i64,
|
||||
pub username: String,
|
||||
/// 1=read, 2=read+write, 3=full (matches the desktop client's enum
|
||||
/// in src/hbbs_http/account.rs and the §4.3 wire contract).
|
||||
pub rule: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StrategyRow {
|
||||
pub id: i64,
|
||||
@@ -234,6 +243,15 @@ pub struct OidcProviderRow {
|
||||
pub scopes: String,
|
||||
pub redirect_url: String,
|
||||
pub enabled: bool,
|
||||
/// If `Some`, every successful sign-in via this provider sets the local
|
||||
/// user's `is_admin` to whether the userinfo claim at `roles_claim`
|
||||
/// contains this role name. `None` means "don't touch is_admin"
|
||||
/// (existing behavior — admins are managed in the dashboard).
|
||||
pub admin_role: Option<String>,
|
||||
/// Userinfo claim that holds the user's roles. Defaults to `"roles"`
|
||||
/// when `admin_role` is set but this is not. Zitadel's default format
|
||||
/// lives at `"urn:zitadel:iam:org:project:roles"`.
|
||||
pub roles_claim: Option<String>,
|
||||
}
|
||||
|
||||
pub struct OidcSessionInsert<'a> {
|
||||
@@ -497,6 +515,35 @@ impl Database {
|
||||
Ok(res.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Drop a device by `peer_id`. Removes the dashboard-visible row
|
||||
/// (`device_sysinfo`), the rendezvous-side identity (`peer` — so a
|
||||
/// stale public key doesn't reject a fresh reinstall under the same
|
||||
/// ID), and any pending peer-scoped operational state
|
||||
/// (`heartbeat_commands`, peer-scoped `strategy_assignments`).
|
||||
/// Audit rows, recordings, and address-book entries are intentionally
|
||||
/// preserved — they're historical/manual data the operator may still
|
||||
/// want even after the device is gone. Returns `true` iff a
|
||||
/// `device_sysinfo` row actually existed.
|
||||
pub async fn device_delete(&self, peer_id: &str) -> ResultType<bool> {
|
||||
let _ = sqlx::query("DELETE FROM heartbeat_commands WHERE peer_id = ?")
|
||||
.bind(peer_id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM strategy_assignments WHERE peer_id = ?")
|
||||
.bind(peer_id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM peer WHERE id = ?")
|
||||
.bind(peer_id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await;
|
||||
let res = sqlx::query("DELETE FROM device_sysinfo WHERE id = ?")
|
||||
.bind(peer_id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(res.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Devices listed for the dashboard. Returns each row of device_sysinfo
|
||||
/// joined to its owner's username, sorted by recency.
|
||||
pub async fn devices_list_all(
|
||||
@@ -1036,6 +1083,132 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new shared (kind=1) address book owned by `owner_user_id`.
|
||||
/// The unique index on (owner_user_id, kind, name) means a duplicate
|
||||
/// name from the same owner surfaces as a SQL error — caller should
|
||||
/// translate to a friendly notice.
|
||||
pub async fn ab_create_shared(
|
||||
&self,
|
||||
owner_user_id: i64,
|
||||
name: &str,
|
||||
) -> ResultType<String> {
|
||||
let guid = uuid::Uuid::new_v4().to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO address_books(guid, owner_user_id, name, kind) VALUES(?, ?, ?, 1)",
|
||||
)
|
||||
.bind(&guid)
|
||||
.bind(owner_user_id)
|
||||
.bind(name)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(guid)
|
||||
}
|
||||
|
||||
/// Drop an address book and every dependent row. Used by the dashboard
|
||||
/// to remove shared books; personal books are protected at the handler
|
||||
/// layer (the desktop client owns those).
|
||||
pub async fn ab_delete(&self, guid: &str) -> ResultType<bool> {
|
||||
let _ = sqlx::query("DELETE FROM address_book_peer_tags WHERE ab_guid = ?")
|
||||
.bind(guid)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM address_book_tags WHERE ab_guid = ?")
|
||||
.bind(guid)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM address_book_peers WHERE ab_guid = ?")
|
||||
.bind(guid)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM address_book_shares WHERE ab_guid = ?")
|
||||
.bind(guid)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await;
|
||||
let res = sqlx::query("DELETE FROM address_books WHERE guid = ?")
|
||||
.bind(guid)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(res.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Returns (owner_user_id, kind) for a book, or None if unknown.
|
||||
/// Handlers use this to check ownership and to refuse mutations on
|
||||
/// personal (kind=0) books.
|
||||
pub async fn ab_get_owner_kind(&self, guid: &str) -> ResultType<Option<(i64, i64)>> {
|
||||
let row =
|
||||
sqlx::query("SELECT owner_user_id, kind FROM address_books WHERE guid = ? LIMIT 1")
|
||||
.bind(guid)
|
||||
.fetch_optional(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(row.map(|r| {
|
||||
let owner: i64 = r.try_get("owner_user_id").unwrap_or(0);
|
||||
let kind: i64 = r.try_get("kind").unwrap_or(0);
|
||||
(owner, kind)
|
||||
}))
|
||||
}
|
||||
|
||||
/// Per-user shares attached to a book (group shares aren't surfaced in
|
||||
/// the dashboard yet — operators can use device-groups when they want
|
||||
/// many users at once and assign by group via SQL; the common case is
|
||||
/// per-user). Returns rows of (user_id, username, rule) for direct
|
||||
/// `address_book_shares.user_id IS NOT NULL` rows.
|
||||
pub async fn ab_list_shares(&self, guid: &str) -> ResultType<Vec<AbShareDetailRow>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT s.user_id, COALESCE(u.username,'') AS username, s.rule \
|
||||
FROM address_book_shares s LEFT JOIN users u ON u.id = s.user_id \
|
||||
WHERE s.ab_guid = ? AND s.user_id IS NOT NULL \
|
||||
ORDER BY u.username",
|
||||
)
|
||||
.bind(guid)
|
||||
.fetch_all(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| AbShareDetailRow {
|
||||
user_id: r.try_get("user_id").unwrap_or(0),
|
||||
username: r.try_get("username").unwrap_or_default(),
|
||||
rule: r.try_get("rule").unwrap_or(1),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Idempotent upsert of a per-user share. Replaces an existing rule
|
||||
/// row for the same (ab, user) pair so admins can promote/demote
|
||||
/// without having to remove first.
|
||||
pub async fn ab_share_set(
|
||||
&self,
|
||||
guid: &str,
|
||||
user_id: i64,
|
||||
rule: i64,
|
||||
) -> ResultType<()> {
|
||||
let _ = sqlx::query(
|
||||
"DELETE FROM address_book_shares WHERE ab_guid = ? AND user_id = ?",
|
||||
)
|
||||
.bind(guid)
|
||||
.bind(user_id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await;
|
||||
sqlx::query(
|
||||
"INSERT INTO address_book_shares(ab_guid, user_id, rule) VALUES(?, ?, ?)",
|
||||
)
|
||||
.bind(guid)
|
||||
.bind(user_id)
|
||||
.bind(rule)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn ab_share_remove(&self, guid: &str, user_id: i64) -> ResultType<bool> {
|
||||
let res =
|
||||
sqlx::query("DELETE FROM address_book_shares WHERE ab_guid = ? AND user_id = ?")
|
||||
.bind(guid)
|
||||
.bind(user_id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(res.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Look up the personal AB for a user, creating it if missing.
|
||||
pub async fn ab_get_or_create_personal(&self, user_id: i64) -> ResultType<String> {
|
||||
let row = sqlx::query("SELECT guid FROM address_books WHERE owner_user_id = ? AND kind = 0")
|
||||
@@ -2221,8 +2394,9 @@ impl Database {
|
||||
pub async fn oidc_provider_upsert(&self, p: &OidcProviderRow) -> ResultType<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO oidc_providers(name, display_name, icon_url, issuer_url, \
|
||||
client_id, client_secret, scopes, redirect_url, enabled) \
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) \
|
||||
client_id, client_secret, scopes, redirect_url, enabled, \
|
||||
admin_role, roles_claim) \
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
|
||||
ON CONFLICT(name) DO UPDATE SET \
|
||||
display_name = excluded.display_name, \
|
||||
icon_url = excluded.icon_url, \
|
||||
@@ -2231,7 +2405,9 @@ impl Database {
|
||||
client_secret = excluded.client_secret, \
|
||||
scopes = excluded.scopes, \
|
||||
redirect_url = excluded.redirect_url, \
|
||||
enabled = excluded.enabled",
|
||||
enabled = excluded.enabled, \
|
||||
admin_role = excluded.admin_role, \
|
||||
roles_claim = excluded.roles_claim",
|
||||
)
|
||||
.bind(&p.name)
|
||||
.bind(p.display_name.as_deref())
|
||||
@@ -2242,6 +2418,8 @@ impl Database {
|
||||
.bind(&p.scopes)
|
||||
.bind(&p.redirect_url)
|
||||
.bind(if p.enabled { 1 } else { 0 })
|
||||
.bind(p.admin_role.as_deref())
|
||||
.bind(p.roles_claim.as_deref())
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -2250,7 +2428,7 @@ impl Database {
|
||||
pub async fn oidc_provider_get(&self, name: &str) -> ResultType<Option<OidcProviderRow>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT name, display_name, icon_url, issuer_url, client_id, client_secret, \
|
||||
scopes, redirect_url, enabled \
|
||||
scopes, redirect_url, enabled, admin_role, roles_claim \
|
||||
FROM oidc_providers WHERE name = ? AND enabled = 1",
|
||||
)
|
||||
.bind(name)
|
||||
@@ -2262,7 +2440,7 @@ impl Database {
|
||||
pub async fn oidc_provider_list_enabled(&self) -> ResultType<Vec<OidcProviderRow>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT name, display_name, icon_url, issuer_url, client_id, client_secret, \
|
||||
scopes, redirect_url, enabled \
|
||||
scopes, redirect_url, enabled, admin_role, roles_claim \
|
||||
FROM oidc_providers WHERE enabled = 1 ORDER BY name",
|
||||
)
|
||||
.fetch_all(self.pool.get().await?.deref_mut())
|
||||
@@ -2377,11 +2555,18 @@ impl Database {
|
||||
/// Create or update a user from an OIDC identity. The local username is
|
||||
/// either the email (preferred) or the sub if no email. Subsequent
|
||||
/// logins re-use the same row via oidc_subject.
|
||||
/// Find-or-create a local user from an OIDC sign-in. When
|
||||
/// `desired_admin` is `Some(b)`, the user's `is_admin` flag is forced
|
||||
/// to `b` on every login (used when role-based admin sync is configured
|
||||
/// on the provider — both promotion and demotion at the IdP propagate).
|
||||
/// `None` leaves `is_admin` untouched on existing rows and defaults to
|
||||
/// `false` for new ones.
|
||||
pub async fn user_upsert_oidc(
|
||||
&self,
|
||||
oidc_subject: &str,
|
||||
email: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
desired_admin: Option<bool>,
|
||||
) -> ResultType<UserRow> {
|
||||
let username = email
|
||||
.filter(|s| !s.is_empty())
|
||||
@@ -2400,6 +2585,13 @@ impl Database {
|
||||
.bind(existing.id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
if let Some(want) = desired_admin {
|
||||
sqlx::query("UPDATE users SET is_admin = ? WHERE id = ?")
|
||||
.bind(if want { 1i64 } else { 0 })
|
||||
.bind(existing.id)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
}
|
||||
return Ok(self
|
||||
.user_find_by_id(existing.id)
|
||||
.await?
|
||||
@@ -2407,13 +2599,15 @@ impl Database {
|
||||
}
|
||||
// New user. Empty password_hash blocks password login until the
|
||||
// operator (or the user) sets one.
|
||||
let initial_admin: i64 = if matches!(desired_admin, Some(true)) { 1 } else { 0 };
|
||||
sqlx::query(
|
||||
"INSERT INTO users(username, password_hash, display_name, email, status, is_admin, oidc_subject) \
|
||||
VALUES(?, '', ?, ?, 1, 0, ?)",
|
||||
VALUES(?, '', ?, ?, 1, ?, ?)",
|
||||
)
|
||||
.bind(&username)
|
||||
.bind(display_name.unwrap_or(""))
|
||||
.bind(email.unwrap_or(""))
|
||||
.bind(initial_admin)
|
||||
.bind(oidc_subject)
|
||||
.execute(self.pool.get().await?.deref_mut())
|
||||
.await?;
|
||||
@@ -2561,6 +2755,14 @@ fn row_to_oidc_provider(row: sqlx::sqlite::SqliteRow) -> OidcProviderRow {
|
||||
.unwrap_or_else(|_| "openid email profile".to_string()),
|
||||
redirect_url: row.try_get("redirect_url").unwrap_or_default(),
|
||||
enabled: enabled != 0,
|
||||
admin_role: row
|
||||
.try_get::<Option<String>, _>("admin_role")
|
||||
.ok()
|
||||
.flatten(),
|
||||
roles_claim: row
|
||||
.try_get::<Option<String>, _>("roles_claim")
|
||||
.ok()
|
||||
.flatten(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2703,6 +2905,12 @@ const M2_SOFT_ALTERS: &[&str] = &[
|
||||
// OIDC `sub` claim, used to map an IdP identity to a local user across
|
||||
// sessions. Nullable so password-only users keep working.
|
||||
"ALTER TABLE users ADD COLUMN oidc_subject TEXT",
|
||||
// Optional role-based admin sync. When `admin_role` is non-NULL the
|
||||
// OIDC callback evaluates the userinfo claim at `roles_claim`
|
||||
// (defaulting to "roles") and sets is_admin accordingly on every
|
||||
// login — promotion AND demotion at the IdP propagate.
|
||||
"ALTER TABLE oidc_providers ADD COLUMN admin_role TEXT",
|
||||
"ALTER TABLE oidc_providers ADD COLUMN roles_claim TEXT",
|
||||
];
|
||||
|
||||
const M3_SCHEMA: &[&str] = &[
|
||||
|
||||
Reference in New Issue
Block a user