From c86726ec9a2b2999aeb3d2937a67aa657b9e7710 Mon Sep 17 00:00:00 2001 From: urania Date: Fri, 26 Jun 2026 00:31:29 +0200 Subject: [PATCH] feat: refined ui --- .gitignore | 1 + README.md | 167 +++++++--- CLAUDE.md => context.md | 0 db.sqlite | Bin 94208 -> 102400 bytes .../migration.sql | 1 - .../migration.sql | 5 + .../snapshot.json | 181 ++++++----- messages/en.json | 38 ++- src/hooks.server.ts | 10 +- src/lib/auth/server.ts | 181 ++++++----- .../components/blocks/terminal-dialog.svelte | 76 +++-- .../components/dashboard/data-table.svelte | 3 +- .../components/ui/field/field-label.svelte | 2 +- src/lib/const/schema.ts | 6 + src/lib/remotes/auth.remote.ts | 26 +- src/lib/remotes/config.remote.ts | 94 ++++++ src/lib/remotes/networking.remote.ts | 28 +- src/lib/remotes/pam-users.remote.ts | 65 ++-- src/lib/remotes/storage.remote.ts | 30 +- src/lib/remotes/system.remote.ts | 37 ++- src/lib/remotes/users.remote.ts | 20 +- src/lib/remotes/utils.ts | 1 + src/lib/schemas/config.ts | 39 +++ src/lib/schemas/hostname.ts | 15 + src/lib/schemas/networking-host.ts | 17 + src/lib/schemas/pam.ts | 39 +++ src/lib/schemas/storage-mount.ts | 12 + src/lib/schemas/system-time.ts | 7 + src/lib/server/config.ts | 103 ++++++ src/lib/server/db/schema.ts | 8 + src/lib/server/emails/send.ts | 15 +- src/lib/utils.ts | 1 + src/routes/+layout.svelte | 2 +- src/routes/+page.svelte | 24 +- src/routes/admin/config/+page.svelte | 303 ++++++++++++++++++ src/routes/admin/users/+page.svelte | 39 ++- .../[machineId]/networking/hosts/+page.svelte | 91 +++--- .../interfaces/[name]/configure/+page.svelte | 68 ++-- .../packages/installed/+page.svelte | 21 +- .../[machineId]/services/+page.svelte | 25 +- .../[machineId]/services/[name]/+page.svelte | 41 +-- .../[machineId]/storage/mounts/+page.svelte | 119 +++---- .../[machineId]/system/date-time/+page.svelte | 207 ++++++------ .../[machineId]/system/hostname/+page.svelte | 68 ++-- .../dashboard/[machineId]/users/+page.svelte | 188 +++++------ .../[machineId]/users/[username]/+page.svelte | 61 ++-- .../[machineId]/users/groups/+page.svelte | 94 +++--- .../users/groups/[group]/+page.svelte | 13 +- 48 files changed, 1720 insertions(+), 872 deletions(-) rename CLAUDE.md => context.md (100%) delete mode 100644 drizzle/20260623171651_glossy_star_brand/migration.sql create mode 100644 drizzle/20260625212440_modern_spiral/migration.sql rename drizzle/{20260623171651_glossy_star_brand => 20260625212440_modern_spiral}/snapshot.json (95%) create mode 100644 src/lib/remotes/config.remote.ts create mode 100644 src/lib/schemas/config.ts create mode 100644 src/lib/schemas/hostname.ts create mode 100644 src/lib/schemas/networking-host.ts create mode 100644 src/lib/schemas/pam.ts create mode 100644 src/lib/schemas/storage-mount.ts create mode 100644 src/lib/schemas/system-time.ts create mode 100644 src/lib/server/config.ts diff --git a/.gitignore b/.gitignore index 34bf0f1..7b8c9c8 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ src/lib/paraglide project.inlang/cache/ # SQLite *.db +CONTEXT.md \ No newline at end of file diff --git a/README.md b/README.md index ef4e955..221dbab 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,23 @@ # Nadir Web UI -SvelteKit dashboard for [nadir-agent](https://tea.urania.dev/urania/nadir-agent) - +SvelteKit dashboard for [nadir-agent](https://tea.urania.dev/urania/nadir-agent) — a central web console that talks to one or many Nadir backend nodes over their typed REST API. -The agent does the system-administration work (systemd services, users, -packages, networking, audit, terminal, ...). This UI is the operator's view of -it: sign in, register machines with their bearer token, see live host metrics -on the dashboard, and drive everyday tasks from the browser. +The agent does the system-administration work (systemd services, users, packages, +networking, storage, audit, terminal, ...). This UI is the operator's view of it: +sign in, register machines with their bearer token, see live host metrics on the +dashboard, and drive everyday tasks from the browser. --- ## Stack -- **SvelteKit** (Svelte 5, adapter-node) + **TailwindCSS 4** + **shadcn-svelte** +- **SvelteKit 2** (Svelte 5, adapter-node) + **TailwindCSS 4** + **shadcn-svelte** - **Bun** as the runtime / package manager / dev server - **Drizzle ORM** on **SQLite** (libSQL driver) for the UI's own state (users, - machines, encrypted tokens) -- **Better Auth** with email/password, OAuth, optional 2FA, admin & username - plugins + machines, encrypted tokens, settings) +- **Better Auth** with email/password, OAuth, optional 2FA, admin & username plugins - **Paraglide** for i18n (messages in `messages/`) - **openapi-fetch** + typed client generated from the nadir-agent OpenAPI spec (`src/lib/server/nadir-agent/schema.d.ts`) @@ -41,15 +40,21 @@ bun run dev # starts on http://localhost:5173 Set in `.env` (validated at startup via `src/lib/const/schema.ts`): -| Var | Default | Purpose | -| --------------------------- | ----------------------- | ------------------------------------------------------ | -| `CRYPTO_SECRET` | (required) | Encrypts machine bearer tokens at rest in the local DB | -| `DATABASE_URL` | `file:db.sqlite` | libSQL connection string | -| `ORIGIN` | `http://localhost:5173` | Public origin (used by Better Auth) | -| `DISABLE_SIGNUP` | `false` | Lock down registration | -| `ENABLE_2FA` | `false` | Enable the TOTP 2FA flow | -| `ENABLE_EMAIL_AND_PASSWORD` | `true` | Toggle email/password auth | -| `SMTP_*` | - | Outbound mail for verification / reset / 2FA | +| Var | Default | Purpose | +| --------------------------- | ----------------------- | ---------------------------------------------------------- | +| `CRYPTO_SECRET` | (required) | Encrypts machine bearer tokens at rest in the local DB | +| `DATABASE_URL` | `file:db.sqlite` | libSQL connection string | +| `ORIGIN` | `http://localhost:5173` | Public origin (used by Better Auth) | +| `REPOSITORY_URL` | (required) | Gitea API URL for the nadir-agent releases; enables `GET /install.sh` | +| `DISABLE_SIGNUP` | `false` | Lock down registration | +| `ENABLE_2FA` | `false` | Enable the TOTP 2FA flow | +| `ENABLE_EMAIL_AND_PASSWORD` | `true` | Toggle email/password auth | +| `SMTP_HOST` | - | Outbound mail server hostname | +| `SMTP_PORT` | - | SMTP port | +| `SMTP_USER` | - | SMTP username | +| `SMTP_PASS` | - | SMTP password | +| `SMTP_FROM` | - | From address for verification / reset / 2FA emails | +| `SMTP_SSL` | - | Use SSL/TLS for SMTP | OAuth providers (optional) live in `config/oauth.json` and are passed straight to Better Auth's `genericOAuth` plugin. @@ -59,19 +64,23 @@ to Better Auth's `genericOAuth` plugin. ## Scripts ```sh -bun run dev # vite dev server -bun run type:generate # generate typed client for nadir-agent +bun run dev # vite dev server (binds --host for LAN access) +bun run dev:types # regenerate typed client, then start dev server +bun run type:generate # generate typed client from live nadir-agent OpenAPI spec bun run build # production build (adapter-node -> build/) bun run preview # preview the production build -bun run check # svelte-check +bun run check # svelte-check (must pass 0 errors before done) bun run lint # prettier + eslint bun run format # prettier --write -bun run db:push # apply schema to the DB +bun run db:push # apply schema to the DB (dev) bun run db:generate # generate migration from schema changes bun run db:migrate # run pending migrations bun run db:studio # drizzle-kit studio ``` +`type:generate` fetches the OpenAPI spec from the live agent configured in the script +(`http://100.64.0.189:9999/openapi.json` by default — adjust for your environment). + --- ## Project layout @@ -79,27 +88,105 @@ bun run db:studio # drizzle-kit studio ``` src/ routes/ - auth/ sign-in, sign-up, forgot/reset password, 2fa setup - dashboard/ machine list and per-machine live dashboard - system/ date/time, localization - admin/ users, config - api/ internal endpoints (e.g. emailer) + auth/ sign-in, sign-up, forgot/reset password, 2fa, 2fa setup + dashboard/ machine list; per-machine live dashboard + [machineId]/ + networking/ interfaces (list/detail/configure), routes, DNS, /etc/hosts + packages/ installed packages, available updates + services/ service list + per-service control and logs + storage/ active mounts, fstab entries + system/ overview, hostname, date-time, localization, power, nadir self-update + users/ local accounts, groups (list + detail) + admin/ dashboard users (Better Auth admin), config + docs/ built-in operator documentation (architecture, installation, + security, limitations) + api/emailer/send/ internal SvelteKit server route for outbound email + install.sh/ serves the nadir-agent installer shell script (enabled + when REPOSITORY_URL is set; returns 404 otherwise) lib/ - auth/ Better Auth server + client - components/ shadcn-svelte UI + dashboard panels (cpu, network, storage, ...) - machines/ valibot schemas - remotes/ SvelteKit remote functions (server.remote.ts, machines.remote.ts, ...) + auth/ Better Auth server instance + browser client + components/ shadcn-svelte UI + reusable dashboard panels + remotes/ SvelteKit remote functions, one file per agent domain: + auth, config, machines, networking, packages, + pam-users, server, services, storage, system, + terminal, users, utils + schemas/ valibot schemas (shared validation types) server/ - db/ Drizzle schema + custom encrypted column type - emails/ nodemailer + better-svelte-email templates - nadir-agent/ generated OpenAPI types + typed client - paraglide/ generated i18n runtime -messages/ translation source (en, ...) -config/oauth.json optional OAuth providers passed to Better Auth + db/ Drizzle schema + auth-schema + encrypted column type + emails/ nodemailer + better-svelte-email templates + nadir-agent/ generated OpenAPI types + typed fetch client + terminal/ SSH session management (open/stream/write/resize/close) + paraglide/ generated i18n runtime +messages/ translation source (en.json, ...) +config/oauth.json optional OAuth providers passed to Better Auth ``` --- +## Remote functions + +`src/lib/remotes/*.remote.ts` are the SvelteKit server-side functions the browser +triggers via `query` / `command`. They call the agent through +`nadirForMachine(machineId)`, which handles token decryption and 401/404/500 routing. +After a mutating `command`, the relevant `query`s are `.refresh()`ed inside the command. + +| File | Agent domain covered | +| ------------------------ | --------------------------------------------- | +| `auth.remote.ts` | Sign-in, sign-out, session | +| `config.remote.ts` | UI application config | +| `machines.remote.ts` | Machine CRUD, reordering, health batch | +| `networking.remote.ts` | Interfaces, routing, DNS, `/etc/hosts` | +| `packages.remote.ts` | Installed, updates, install/remove (SSE) | +| `pam-users.remote.ts` | System user listing (PAM side) | +| `server.remote.ts` | Agent self-update (`POST /api/update`) | +| `services.remote.ts` | systemd unit list/control/logs (SSE) | +| `storage.remote.ts` | Mounts, fstab entries | +| `system.remote.ts` | Host info, hostname, time/tz/NTP, locale, power | +| `terminal.remote.ts` | SSH terminal sessions (open/stream/write/resize/close) | +| `users.remote.ts` | Local accounts + groups | +| `utils.ts` | Shared helpers (not a remote file) | + +--- + +## Database + +The UI keeps its own SQLite database (Drizzle ORM, libSQL driver): + +| Table | Contents | +| -------------- | --------------------------------------------------------------------------------- | +| `user` | Dashboard user (email, username, name, UI role, ban state) | +| `session` | Better Auth sessions | +| `account` | Better Auth OAuth accounts | +| `verification` | Better Auth verification tokens | +| `twoFactor` | TOTP secrets / backup codes | +| `machines` | `{ id, name, address, order, token(encrypted) }` — registered agent nodes | +| `settings` | Key/value store for UI-side application settings | + +`token` in `machines` is stored with a custom `encryptedText` column type and is +decrypted on demand (`autoDecrypt: false`) only when making a server-side call to +the agent. + +--- + +## SSH Terminal + +The UI includes a browser-based SSH terminal (`terminal.remote.ts`). Operators +can open an SSH session to any registered machine's host directly from the +dashboard. Sessions are managed server-side (SSH client runs in the SvelteKit +server process); the browser streams output via SSE and sends keystrokes via +`command`. Sessions are closed on disconnect or explicit `closeSshTerminal`. + +--- + +## `GET /install.sh` + +When `REPOSITORY_URL` is set, the UI serves a shell installer at `/install.sh` +that bootstraps `nadir-agent` on a new host. The script is generated from +`src/routes/install.sh/install.sh.tmpl` with the release repo URL injected. If +`REPOSITORY_URL` is not set the route returns 404 with an explanation. + +--- + ## Deploying `adapter-node` produces a plain Node/Bun server under `build/`: @@ -110,8 +197,10 @@ PORT=3000 ORIGIN=https://nadir.example.com bun run build/index.js ``` Put it behind the same reverse proxy you use for nadir-agent, or co-host them. -The agent's CSRF rules apply when the UI calls it cross-origin - see the agent -README's _Connecting a dashboard_ section. +The agent's CSRF rules apply when the UI calls it cross-origin — see the agent +README's _Connecting a dashboard_ section. The recommended pattern is to call +the agent server-to-server from the SvelteKit server layer; the browser never +holds or sends the agent token. --- diff --git a/CLAUDE.md b/context.md similarity index 100% rename from CLAUDE.md rename to context.md diff --git a/db.sqlite b/db.sqlite index 8b162dc97f02c0554fa119c88df16184a94686ef..3916a451bb1bb7370ea4a7e60c592840d0a6b524 100644 GIT binary patch delta 812 zcmZp8z}m2YZGyDmYz77fNg##+!HGJ?JhK_}dVlfq>oQ0%sZGq*Wnofd+PHCH7o&Y6 zhd8^qtSn<=a!F!RPHJ&#Nl9j2dNGWaa1L^H3~^Nmadh%=RY-sdCn#ueaVBJ^R!&x6 z7MtwKp)h$nn+OY+roP1F_slZFFlF(EFqY)zM@&k*JS@EL82B6c{_+*^zS}G)aF4gX zk&%U+p^=x>)z-k&BsnSB%)&I;)WA5^!qUvb%pxh(GC9S}#4O1;*~HM;BsJO4+&nQg z)g;-{*ucUd(ZJj=(ZDRtAkES!$&7J6!|II(j0}v-49tv7jSP)UObp_4^HWlb^5TmN zGK&&(KytbUX1YeEAx4H)MkZD!Caijsbqf&U@@G5)#y znf#9Yviz)kZ}~247Btw#S1--UBF`!t>>Cmg9~$f$1hjMgl*qh%-J;ZjoJuhc7FkwN zuw-zs4@9n{s5DiK9jHkdEFRz=6atYjF*8+S1Bz>d#XbCkq2k54B?Wq=MTvQtiFzri zWooQIHO62yZbAM!zNj`jAQT$0ut>57`UiQsd-_2XO@4Ausa~C##hz8v)ejVZ@vgp( zo<8x8elGC=j={m<{y{DXM@llWD6)z}6&krYLS)hsbBa@C8Cf)0#a%pu!G;8Ty8DF& zAW3t9!i>L=f&U%|+xS=T&*JaluLlY-Y!)^M<*(;umS+Te0TgDOJj}8X9x#wN z;8LKFVTbcT;lT#y0e$Yv%Ec_n2=N|^EVDf$PA@U@F)PAkf&O3;WERlmM0Gp>g1F!f delta 119 zcmZozz}E19b%M0uLoSNjJ(`%UyID|xgNdt2QiNSx zR+h1ab@Ce~Me)Ww2)9|kBrz!`H8(LiBQr0xc=IDBC0;IOz9I(xM!vs%MVkc;QuvyS MdG4=%l0AExdOaK4? diff --git a/drizzle/20260623171651_glossy_star_brand/migration.sql b/drizzle/20260623171651_glossy_star_brand/migration.sql deleted file mode 100644 index 1a42f04..0000000 --- a/drizzle/20260623171651_glossy_star_brand/migration.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE `machines` ADD `ca_cert` text; \ No newline at end of file diff --git a/drizzle/20260625212440_modern_spiral/migration.sql b/drizzle/20260625212440_modern_spiral/migration.sql new file mode 100644 index 0000000..49f8c93 --- /dev/null +++ b/drizzle/20260625212440_modern_spiral/migration.sql @@ -0,0 +1,5 @@ +CREATE TABLE `settings` ( + `key` text PRIMARY KEY, + `updated_at` integer NOT NULL, + `value` text NOT NULL +); diff --git a/drizzle/20260623171651_glossy_star_brand/snapshot.json b/drizzle/20260625212440_modern_spiral/snapshot.json similarity index 95% rename from drizzle/20260623171651_glossy_star_brand/snapshot.json rename to drizzle/20260625212440_modern_spiral/snapshot.json index 7fd3247..4dd1305 100644 --- a/drizzle/20260623171651_glossy_star_brand/snapshot.json +++ b/drizzle/20260625212440_modern_spiral/snapshot.json @@ -1,23 +1,27 @@ { "version": "7", "dialect": "sqlite", - "id": "1a550330-2944-441c-aea2-18b98455929c", + "id": "208e7c97-0071-4c2f-bc0c-7c4cbf18af2e", "prevIds": [ "b3403d40-d4c3-4f1a-8d1d-763c548f5bc7" ], "ddl": [ - { - "name": "machines", - "entityType": "tables" - }, { "name": "account", "entityType": "tables" }, + { + "name": "machines", + "entityType": "tables" + }, { "name": "session", "entityType": "tables" }, + { + "name": "settings", + "entityType": "tables" + }, { "name": "two_factor", "entityType": "tables" @@ -30,66 +34,6 @@ "name": "verification", "entityType": "tables" }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": "'http://127.0.0.1:9999'", - "generated": null, - "name": "address", - "entityType": "columns", - "table": "machines" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "ca_cert", - "entityType": "columns", - "table": "machines" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "machines" - }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "name", - "entityType": "columns", - "table": "machines" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "order", - "entityType": "columns", - "table": "machines" - }, - { - "type": "text", - "notNull": true, - "autoincrement": false, - "default": null, - "generated": null, - "name": "token", - "entityType": "columns", - "table": "machines" - }, { "type": "text", "notNull": false, @@ -220,6 +164,56 @@ "entityType": "columns", "table": "account" }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'http://127.0.0.1:9999'", + "generated": null, + "name": "address", + "entityType": "columns", + "table": "machines" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "machines" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "machines" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "order", + "entityType": "columns", + "table": "machines" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "machines" + }, { "type": "integer", "notNull": true, @@ -310,6 +304,36 @@ "entityType": "columns", "table": "session" }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "settings" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "settings" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "settings" + }, { "type": "text", "notNull": true, @@ -605,15 +629,6 @@ "entityType": "fks", "table": "two_factor" }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "machines_pk", - "table": "machines", - "entityType": "pks" - }, { "columns": [ "id" @@ -623,6 +638,15 @@ "table": "account", "entityType": "pks" }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "machines_pk", + "table": "machines", + "entityType": "pks" + }, { "columns": [ "id" @@ -632,6 +656,15 @@ "table": "session", "entityType": "pks" }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "settings_pk", + "table": "settings", + "entityType": "pks" + }, { "columns": [ "id" diff --git a/messages/en.json b/messages/en.json index 1370712..d969c27 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,6 +1,39 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", "account": "Account", + "admin_config_auth_description": "Sign-in, registration and second-factor policy.", + "admin_config_auth_title": "Authentication", + "admin_config_description": "Runtime configuration for the dashboard. Stored in the database; falls back to environment values.", + "admin_config_disable_signup": "Disable public registration", + "admin_config_disable_signup_hint": "Only existing admins can invite new users.", + "admin_config_enable_2fa": "Require two-factor authentication", + "admin_config_enable_2fa_hint": "All users must set up TOTP on next sign-in.", + "admin_config_enable_email_password": "Allow email + password sign-in", + "admin_config_enable_email_password_hint": "Disable to restrict logins to OAuth providers.", + "admin_config_origin": "Public dashboard URL", + "admin_config_origin_hint": "Used to build auth callback URLs and email links.", + "admin_config_restart_required": "applies after restart", + "admin_config_smtp_description": "Outbound mail for password resets, verification and 2FA codes. Password is encrypted at rest.", + "admin_config_smtp_from": "From address", + "admin_config_smtp_host": "Host", + "admin_config_smtp_missing": "Configure SMTP host before sending a test email.", + "admin_config_smtp_pass": "Password", + "admin_config_smtp_pass_hint": "Leave blank to keep the current password.", + "admin_config_smtp_port": "Port", + "admin_config_smtp_ssl": "Use TLS/SSL", + "admin_config_smtp_ssl_hint": "Enable for port 465; leave off for STARTTLS on 587.", + "admin_config_smtp_test": "Send test", + "admin_config_smtp_test_body": "This is a test email from your Nadir dashboard.", + "admin_config_smtp_test_sent": "Test email sent.", + "admin_config_smtp_test_subject": "Nadir SMTP test", + "admin_config_smtp_test_to": "Send test email to", + "admin_config_smtp_title": "Mail (SMTP)", + "admin_config_smtp_user": "Username", + "admin_config_social_client_id": "Client ID", + "admin_config_social_client_secret": "Client secret", + "admin_config_social_description": "OAuth client credentials for sign-in providers. Secrets are encrypted at rest.", + "admin_config_social_secret_hint": "Leave blank to keep the current secret.", + "admin_config_social_title": "Social providers", "agent_update_failed": "Agent update did not complete - check `nadir logs` on the host", "agent_update_started": "Updating agent... ({from} → {to})", "agent_update_success": "Agent updated to {version}", @@ -138,10 +171,12 @@ "errors_address_invalid": "Enter a valid URL, e.g. http://127.0.0.1:9999", "errors_email_invalid": "Enter a valid email address", "errors_generic": "An error occurred during this operation, please review Nadir Logs for more information.", + "errors_hostname_invalid": "Enter a valid hostname (RFC 1123).", "errors_invalid_code": "Invalid or expired code, try again.", "errors_non_empty": "This field is required", "errors_not_found": "This item has not been found", "errors_password_too_short": "Password must be at least {min} characters", + "errors_port_invalid": "Enter a port between 1 and 65535", "errors_password_weak": "Use upper- and lower-case letters and at least one number.", "errors_passwords_no_match": "Passwords do not match", "errors_unauthenticated": "Unauthenticated", @@ -223,7 +258,8 @@ "landing_how_step1_desc": "Deploy the stateless web dashboard using Docker. This interface will coordinate your registered agents. Access the web setup at port 3000 to configure your dashboard admin account.", "landing_how_step2_title": "Bootstrap the target server", "landing_how_step2_desc": "Run the installation script on the host machine you want to manage. The script automatically detects the host architecture (amd64/arm64), fetches the latest tagged binary release from Gitea, verifies SHA-256 integrity, configures a systemd unit, and installs a PAM configuration file.", - "landing_security_note": "Security note: The bootstrap installer requires root privileges. It generates a self-signed TLS certificate automatically if secure TLS is configured.", + "landing_security_note": "Security note:", + "landing_security_note_text": "The bootstrap installer requires root privileges. It generates a self-signed TLS certificate automatically if secure TLS is configured.", "landing_security_architecture_title": "Deployment security and architecture", "landing_security_architecture_desc": "Because the nadir-agent operates with root privileges, it should never be exposed directly to the public internet. By default, it binds to localhost. It is designed to be co-located on the same server as the Web UI communicating over loopback, or securely accessed across nodes over a private VPN (such as WireGuard, Tailscale, or Netbird). While agent installations support generating self-signed TLS certificates automatically, operating within a trusted private network boundary remains the recommended deployment model.", "landing_how_step3_title": "Link in the dashboard", diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 4941b96..7b3dfa5 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,13 +1,15 @@ import { type Handle, redirect } from '@sveltejs/kit'; import { sequence } from '@sveltejs/kit/hooks'; import { building, dev } from '$app/environment'; -import { auth } from '$lib/auth/server'; -import { env } from '$lib/const/schema'; +import { getAuth } from '$lib/auth/server'; +import { getConfig } from '$lib/server/config'; import { getTextDirection } from '$lib/paraglide/runtime'; import { paraglideMiddleware } from '$lib/paraglide/server'; import { svelteKitHandler } from 'better-auth/svelte-kit'; const handleBetterAuth: Handle = async ({ event, resolve }) => { + const cfg = getConfig(); + const auth = getAuth(); const session = await auth.api.getSession({ headers: event.request.headers }); @@ -23,7 +25,7 @@ const handleBetterAuth: Handle = async ({ event, resolve }) => { redirect(307, '/auth/sign-in'); } if ( - env.ENABLE_2FA && + cfg.ENABLE_2FA && session?.user !== undefined && 'twoFactorEnabled' in session.user && session?.user.twoFactorEnabled !== true && @@ -32,7 +34,7 @@ const handleBetterAuth: Handle = async ({ event, resolve }) => { ) redirect(307, '/auth/setup-2fa'); if (session?.user && 'twoFactorRedirect' in session.user) redirect(307, '/auth/2fa'); - if (dev && env.ORIGIN.startsWith('https:')) event.url.protocol = 'https:'; + if (dev && cfg.ORIGIN.startsWith('https:')) event.url.protocol = 'https:'; if (event.url.pathname.startsWith('/admin')) { const roles = (session?.user?.role ?? '') .split(',') diff --git a/src/lib/auth/server.ts b/src/lib/auth/server.ts index 2501a87..26cb94a 100644 --- a/src/lib/auth/server.ts +++ b/src/lib/auth/server.ts @@ -1,7 +1,7 @@ import { dash } from '@better-auth/infra'; import { getRequestEvent } from '$app/server'; -import { env } from '$lib/const/schema'; import { m } from '$lib/paraglide/messages'; +import { getConfig } from '$lib/server/config'; import { db } from '$lib/server/db'; import * as schema from '$lib/server/db/schema'; import { emailer } from '$lib/server/emails'; @@ -17,90 +17,105 @@ export const oauthConfig = (await Bun.file( path.join(cwd(), 'config/oauth.json') ).json()) as GenericOAuthConfig[]; -export const auth = betterAuth({ - basePath: '/api/auth', - baseURL: env.ORIGIN, - database: drizzleAdapter(db, { - provider: 'sqlite', - schema - }), - emailAndPassword: { - autoSignIn: false, - customSyntheticUser: ({ additionalFields, coreFields, id }) => ({ - ...coreFields, - banExpires: null, - banned: false, - banReason: null, - displayUsername: null, - role: 'user', - twoFactorEnabled: false, - username: null, - ...additionalFields, - id +function build() { + const cfg = getConfig(); + return betterAuth({ + basePath: '/api/auth', + baseURL: cfg.ORIGIN, + database: drizzleAdapter(db, { + provider: 'sqlite', + schema }), - disableSignUp: env.DISABLE_SIGNUP || false, - enabled: env.ENABLE_EMAIL_AND_PASSWORD || true, - requireEmailVerification: true, - sendResetPassword: async ({ token, url, user }) => { - if (url.endsWith('reset-password')) await emailer.sendResetPassword({ token, url, user }); + emailAndPassword: { + autoSignIn: false, + customSyntheticUser: ({ additionalFields, coreFields, id }) => ({ + ...coreFields, + banExpires: null, + banned: false, + banReason: null, + displayUsername: null, + role: 'user', + twoFactorEnabled: false, + username: null, + ...additionalFields, + id + }), + disableSignUp: cfg.DISABLE_SIGNUP || false, + enabled: cfg.ENABLE_EMAIL_AND_PASSWORD || true, + requireEmailVerification: true, + sendResetPassword: async ({ token, url, user }) => { + if (url.endsWith('reset-password')) await emailer.sendResetPassword({ token, url, user }); - if (url.endsWith('complete-registration')) - await emailer.sendCompleteRegistration({ token, url, user }); - } - }, - emailVerification: { - autoSignInAfterVerification: true, - sendOnSignIn: true, - sendOnSignUp: true, - sendVerificationEmail: async ({ url, user }) => { - await emailer.sendVerificationEmail({ url, user }); - } - }, - plugins: [ - admin(), - dash(), - genericOAuth({ config: oauthConfig }), - twoFactor({ - issuer: m.appname(), - otpOptions: { - sendOTP: async ({ otp, user }) => { - await emailer.sendOtp({ otp, user }); - } - }, - totpOptions: { - period: 30 + if (url.endsWith('complete-registration')) + await emailer.sendCompleteRegistration({ token, url, user }); + } + }, + emailVerification: { + autoSignInAfterVerification: true, + sendOnSignIn: true, + sendOnSignUp: true, + sendVerificationEmail: async ({ url, user }) => { + await emailer.sendVerificationEmail({ url, user }); + } + }, + plugins: [ + admin(), + dash(), + genericOAuth({ config: oauthConfig }), + twoFactor({ + issuer: m.appname(), + otpOptions: { + sendOTP: async ({ otp, user }) => { + await emailer.sendOtp({ otp, user }); + } + }, + totpOptions: { + period: 30 + } + }), + username(), + sveltekitCookies(getRequestEvent) + ], + rateLimit: { enabled: true }, + socialProviders: { + facebook: + (cfg.FACEBOOK_CLIENT_ID && { + clientId: cfg.FACEBOOK_CLIENT_ID, + clientSecret: cfg.FACEBOOK_CLIENT_SECRET ?? '' + }) || + undefined, + github: + (cfg.GITHUB_CLIENT_ID && { + clientId: cfg.GITHUB_CLIENT_ID, + clientSecret: cfg.GITHUB_CLIENT_SECRET ?? '' + }) || + undefined, + google: + (cfg.GOOGLE_CLIENT_ID && { + clientId: cfg.GOOGLE_CLIENT_ID, + clientSecret: cfg.GOOGLE_CLIENT_SECRET ?? '' + }) || + undefined + }, + telemetry: { enabled: false }, + user: { + deleteUser: { + enabled: true } - }), - username(), - sveltekitCookies(getRequestEvent) - ], - rateLimit: { enabled: true }, - socialProviders: { - facebook: - (process.env.FACEBOOK_CLIENT_ID && { - clientId: process.env.FACEBOOK_CLIENT_ID as string, - clientSecret: process.env.FACEBOOK_CLIENT_SECRET as string - }) || - undefined, - github: - (process.env.GITHUB_CLIENT_ID && { - clientId: process.env.GITHUB_CLIENT_ID as string, - clientSecret: process.env.GITHUB_CLIENT_SECRET as string - }) || - undefined, - google: - (process.env.GOOGLE_CLIENT_ID && { - clientId: process.env.GOOGLE_CLIENT_ID as string, - clientSecret: process.env.GOOGLE_CLIENT_SECRET as string - }) || - undefined - }, - telemetry: { enabled: false }, - user: { - deleteUser: { - enabled: true } - } -}); + }); +} -export type Auth = typeof auth; +// ponytail: single-slot cache, rebuilt on config edits via invalidateAuth(). +// In-flight rate-limit counters and 2FA flow state reset on invalidation. +let cached: ReturnType | null = null; + +export function getAuth(): ReturnType { + return (cached ??= build()); +} + +export function invalidateAuth(): void { + cached = null; +} + +export type Auth = ReturnType; diff --git a/src/lib/components/blocks/terminal-dialog.svelte b/src/lib/components/blocks/terminal-dialog.svelte index 61f5c94..07b9a2a 100644 --- a/src/lib/components/blocks/terminal-dialog.svelte +++ b/src/lib/components/blocks/terminal-dialog.svelte @@ -10,8 +10,8 @@ import { Button } from '$lib/components/ui/button'; import { Checkbox } from '$lib/components/ui/checkbox'; import * as Dialog from '$lib/components/ui/dialog'; + import * as Field from '$lib/components/ui/field'; import { Input } from '$lib/components/ui/input'; - import { Label } from '$lib/components/ui/label'; import { Separator } from '$lib/components/ui/separator'; import { Spinner } from '$lib/components/ui/spinner'; import * as Tabs from '$lib/components/ui/tabs'; @@ -331,24 +331,24 @@ {m.terminal_section_connection()}

-
- + + {m.username()}
-
-
- - -
+ + + {m.terminal_port()} + +
@@ -394,39 +394,45 @@ >
- - -
- - -
+ + + {m.password()} +
+ + +
+
- - -