diff --git a/AGENTS.md b/AGENTS.md index 17f32bd..ada69a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,13 +76,13 @@ Server functions use `index.server.ts` alongside `index.tsx` (e.g. `single-docum ## Shared Code Organization -- **`src/functions/backend/`** — Server-only: `auth/`, `db/`, `firewall/` (firewalld/ufw rules, status), `media/`, `setup/` (host/client setup, iptables script builder, network detection). +- **`src/functions/backend/`** — Server-only: `auth/`, `db/`, `firewall/` (firewalld/ufw rules, status), `media/`, `setup/` (host/client setup, iptables script builder, network detection), `caddy/` (Caddyfile generation, ingress sync, route validation), `acme/` (manual DNS-01 with `acme-client`, challenge state, certificate status). Certificates are configured per route in the route form; `/admin/setup/tls` is only for the global Caddy installation and status. The route page displays the `_acme-challenge` TXT record and validates after the user adds it at their DNS registrar. - **`src/functions/frontend/`** — Client-safe: CRUD/fetch handlers, media upload/delete, client rule label formatting. - **`src/functions/server/`** — Request-level server handlers (media server). - **`src/hooks/`** — `use-admin-crud-get` (with `refetch`), `use-client-rules-map`, `use-confirm`, `use-toast`, `use-theme`, `use-fetch-get`, `use-form-init`, `use-users-get`. - **`src/utils/`** — Cookies, crypto, dir names, IP/CIDR/port/interface validators, WireGuard interface naming. Most have `.test.ts` files. - **`src/dict/`**, **`src/data/`** — Constants (`app-data.ts`, `site-data.ts`), user types, client rules, links. -- **`src/scripts/`** — Shell scripts: `install-wg-ui.sh`, `setup-wireguard.sh`, `wg-quick-manage.sh`. +- **`src/scripts/`** — Shell scripts: `install-wg-ui.sh`, `install-caddy.sh` (idempotent Caddy/acme ingress installer used by the UI and the full installer), `setup-wireguard.sh`, `wg-quick-manage.sh`. - **`src/components/general/`** — Shared app components, all built from twui. Reach for these before writing page markup: - Page frame: `page-header` (title, meta, primary action + overflow menu), `page-body`, `panel` (the one card style), `form-section`, `form-actions` (submit is always rendered). - Text & status: `muted-text`, `mono-text` (IPs, keys, paths), `section-label`, `status-dot`, `inline-alert`, `empty-state`, `stat-tile`, `key-value-list`, `copy-button`, `copyable-value`. @@ -93,6 +93,8 @@ Server functions use `index.server.ts` alongside `index.tsx` (e.g. `single-docum ## Conventions +- **Numeric IDs are not booleans — the main WireGuard host has `id = 0`.** Never test an ID with truthiness (`if (!host?.id)`, `if (host_id)`, `id ? ... : ...`); `0` is a valid ID and `AppData.WireguardMainHostID` is `0`. Use `typeof id === "number"` / `Number.isSafeInteger(id)` for numeric IDs, or explicit `id !== undefined && id !== ""` when the ID may arrive as a string. This applies to host lookups especially, but to route/cert IDs too. +- **Every form that submits must ask for confirmation first.** Use `useConfirm()` and await `confirm({...})` before submitting; never use `window.confirm` or `window.alert`. This applies to create, update, settings, and auth forms. - Prefer one function/component per file over monolithic files with multiple exports. - **Every rendered array must have a separate component for each array item.** Always extract the item JSX into its own component and render it via `items.map((item) => )` — never inline the item's JSX inside the map callback. - Always use object parameters (`{ foo, bar }`) instead of comma-separated positional parameters. diff --git a/README.md b/README.md index 0d3271c..dc0c42c 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,9 @@ The install script will: 5. Install app dependencies (`bun install`) 6. Generate `.env` with encryption secrets 7. Run WireGuard setup (if available) -8. Install the `wg-quick` helper script -9. Set up a systemd or OpenRC service (`wgui`) +8. Install Caddy (`/opt/caddy`) +9. Install the `wg-quick` helper script +10. Set up systemd/OpenRC services for `wgui`, `wgui-caddy`, and certificate renewal The installer **never modifies your firewall** — it will print the commands to temporarily allow the dashboard port so you can reach the first login, and then @@ -82,6 +83,42 @@ Firewall rules are managed per host, automatically: Rules that no longer match any host (`temporary`/`orphaned`) are flagged with copyable removal commands on the setup page. +### TLS for Tunnel Services + +Client routes expose internal services to WireGuard clients over HTTPS. The +data plane is a dedicated Caddy service (`wgui-caddy`) on `8443`; wg-ui owns +the route and certificate records and regenerates the Caddyfile. Caddy never +binds ports 80 or 443, and the host's existing proxy is left alone. + +1. If Caddy isn't installed yet, open **Setup → TLS ingress** and click + **Install Caddy** (or use the Caddy action on **Setup → Maintenance**). + The installer sets up the binary, the `wgui-caddy` service and certificate support. + +2. Open a route form from **Routes** (or a host's **Routes** panel) and set + the ACME email and staging toggle. No DNS provider token is needed: + certificates use manual DNS-01. + + Let's Encrypt requires a public domain you control; internal-only names such + as `home.arpa` or `.local` need a private CA instead. + +3. Add an A/AAAA record for each route hostname pointing at the host's + WireGuard IP. Caddy also restricts each route to the subnet of the host that + owns it. + +4. Open the route page and click **Issue** (or **Renew**). The dashboard shows + the exact `_acme-challenge.` TXT record. Add it at Cloudflare, + wait for propagation, then click **Validate**. + +5. Clients use `https://:8443/`. Portless URLs require the host's + existing 443 proxy to forward to `127.0.0.1:8443`; wg-ui does not take over + 443. Caddy allows localhost so that proxy can reach it, which means the + proxy itself must enforce who may use those portless URLs. + +Manual DNS-01 certificates are renewed from the route page because validation +needs your registrar. The route list shows expiry warnings. A successful +issuance reloads Caddy without restarting wg-ui. Check +`journalctl -u wgui-caddy` for reload failures. + ### Manual (Development) ```bash @@ -105,6 +142,10 @@ The `.env` file (gitignored) controls runtime behavior. It is auto-generated dur | `ENCRYPTION_KEY` | Encryption key for sensitive data | Auto-generated | | `ENCRYPTION_SALT` | Encryption salt for sensitive data | Auto-generated | | `DATA_DIR` | Application data directory | `$INSTALL_DIR/.data` | +| `CADDY_HTTPS_PORT`| Caddy ingress HTTPS port (never 80/443) | `8443` | +| `CADDY_HTTP_PORT` | Caddy plain HTTP port (never 80) | `8081` | +| `CADDY_INGRESS_ENABLED` | Set to `0` to skip Caddy config sync | `1` | +| `ACME_STAGING` | Set to `1` to use the Let's Encrypt staging CA | `0` | ### Database Configuration @@ -192,6 +233,8 @@ The database consists of the following tables: | `clients` | WireGuard clients per host | | `hosts` | WireGuard host configurations | | `client_rules` | IP table rules for each client | +| `client_routes` | HTTPS routes exposed to WireGuard clients | +| `tls_certificates`| Certificates used by the Caddy ingress | | `variables` | Application-wide key-value variables | Generate types from the schema: @@ -206,9 +249,13 @@ bun run db:schema | ----------------------------------------------- | ------------ | ------------------------------------------------------------ | | `/admin` | Admin | Dashboard overview | | `/admin/hosts` | Admin | Host management (add, edit, per-host detail) | +| `/admin/routes` | Admin | Global HTTPS route list with host and certificate status | +| `/admin/hosts/[host_id]/routes` | Admin | HTTPS routes and upstreams exposed to tunnel clients | +| `/admin/hosts/[host_id]/routes/[route_id]` | Admin | Route details, TLS settings and certificate issue/renew | | `/admin/clients` | Admin | Client management (add, edit, client rules per host) | | `/admin/settings` | Admin | Account settings | | `/admin/setup` | Super admin | System setup (hosts, WireGuard tools, update, restart) | +| `/admin/setup/tls` | Super admin | Caddy ingress installation and status | ## Development diff --git a/bun.lock b/bun.lock index 5d5c58a..6708030 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@moduletrace/bun-sqlite": "^1.2.0", "@moduletrace/bunext": "^1.2.4", + "acme-client": "5.4.0", "html-to-react": "^1.7.0", "lodash": "^4.18.1", "lucide-react": "^1.31.0", @@ -236,6 +237,30 @@ "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw=="], + "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.11.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.11.0", "@peculiar/asn1-x509": "^2.11.0", "@peculiar/asn1-x509-attr": "^2.11.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-SlAiDoT/Cd8qaNmbTt78gqyaEvu2fQd6ML6KWJkdjXGrgDKnHHrusVkfQUelkfinL2p665IIADb0JBixtT8W1g=="], + + "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.11.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.11.0", "@peculiar/asn1-x509": "^2.11.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-M/ED0yJ05qMK+UADPKnIa8vhAeewOJALSxTAZB7RQ/batRHhIJZkwznCcWAbFO5saHJ1yEQ+m4lncEcG9RnQSg=="], + + "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.11.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.11.0", "@peculiar/asn1-x509": "^2.11.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-jj1alGEKev7/un+cI+exyVK9m31lmP7cCBMCZuCb0wkFQH2zmKmJzNiG4wqXFNzlcP8timHLnzf3lV3OxBpb/Q=="], + + "@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.11.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.11.0", "@peculiar/asn1-pkcs8": "^2.11.0", "@peculiar/asn1-rsa": "^2.11.0", "@peculiar/asn1-schema": "^2.11.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-ykSAWRu8KkmzU8LRpk9ZQC3zuLmBEtoSMjIv9XHVamD5W7amqreV5wttKqnvtjWOzzSJQxA/HVT8/tHwpEEGSQ=="], + + "@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.11.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.11.0", "@peculiar/asn1-x509": "^2.11.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-lragjkjQGnQ5luxC+sxoAfAO/jBiQ7hOEGlDKJUk7MC5zY3AzjB2DU0GyPUH1Sswdv10t7DurY0zylOYMLb88w=="], + + "@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.11.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.11.0", "@peculiar/asn1-pfx": "^2.11.0", "@peculiar/asn1-pkcs8": "^2.11.0", "@peculiar/asn1-schema": "^2.11.0", "@peculiar/asn1-x509": "^2.11.0", "@peculiar/asn1-x509-attr": "^2.11.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-gDdFjpqV0weJjgNUvueUVf4D8uVbpq+VbG0TsxLAY2QGljAkJyL9qwQ7avol9j0jaYSJ7Ey7v7RZp0M3GTcQSA=="], + + "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.11.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.11.0", "@peculiar/asn1-x509": "^2.11.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-S2sBJmbfI09Kzdt9cEhE6pDNjHpVTkURs+811+PxS8AvsTLxC4woMNRhxHYaIbClkkNfAVt0tZxJdINVfUmzuA=="], + + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.11.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-8xh4avo53/vXO6UEv9yaoJ83hY581geaLTygf61NPIXP5AeIZCPjJhpTaYU23IZkG+NpLN/C9FYzWp7hphIT9A=="], + + "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.11.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.11.0", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-J1sHAwGNlk+8J+FeR1eLVc6Gq0T9f9ITc3HWyPYgJraoW6U8AJuQiWVJ3SCEpltfTdpOY7fHdHT5oKAsbK/jww=="], + + "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.11.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.11.0", "@peculiar/asn1-x509": "^2.11.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-Fgvnd9REmLxEj/4cbDP54dNUNUXkzUksXLqEHqs1mHBuSRjSTCUSPM/WQIZYSvrdG7ND9CX1AQtPoIBekhgQSA=="], + + "@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="], + + "@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="], @@ -342,18 +367,28 @@ "ace-builds": ["ace-builds@1.44.0", "", {}, "sha512-PFNMSYqFdEUkul2Ntud0HvA09AgY+F1ag0UYdpMH60wNI/qOA8cB8tlTgoALMEwIdUPJK2CjrIQ7OnbiSS/ugQ=="], + "acme-client": ["acme-client@5.4.0", "", { "dependencies": { "@peculiar/x509": "^1.11.0", "asn1js": "^3.0.5", "axios": "^1.7.2", "debug": "^4.3.5", "node-forge": "^1.3.1" } }, "sha512-mORqg60S8iML6XSmVjqjGHJkINrCGLMj2QvDmFzI9vIlv1RGlyjmw3nrzaINJjkNsYXC41XhhD5pfy7CtuGcbA=="], + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "array-find-index": ["array-find-index@1.0.2", "", {}, "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw=="], + "asn1js": ["asn1js@3.1.0", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.2.0", "tslib": "^2.8.1" } }, "sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA=="], + "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="], + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], "bun": ["bun@1.3.14", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.14", "@oven/bun-darwin-x64": "1.3.14", "@oven/bun-darwin-x64-baseline": "1.3.14", "@oven/bun-freebsd-aarch64": "1.3.14", "@oven/bun-freebsd-x64": "1.3.14", "@oven/bun-linux-aarch64": "1.3.14", "@oven/bun-linux-aarch64-android": "1.3.14", "@oven/bun-linux-aarch64-musl": "1.3.14", "@oven/bun-linux-x64": "1.3.14", "@oven/bun-linux-x64-android": "1.3.14", "@oven/bun-linux-x64-baseline": "1.3.14", "@oven/bun-linux-x64-musl": "1.3.14", "@oven/bun-linux-x64-musl-baseline": "1.3.14", "@oven/bun-windows-aarch64": "1.3.14", "@oven/bun-windows-x64": "1.3.14", "@oven/bun-windows-x64-baseline": "1.3.14" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg=="], @@ -362,6 +397,8 @@ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + "camelcase": ["camelcase@2.1.1", "", {}, "sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw=="], "camelcase-keys": ["camelcase-keys@2.1.0", "", { "dependencies": { "camelcase": "^2.0.0", "map-obj": "^1.0.0" } }, "sha512-bA/Z/DERHKqoEOrp+qeGKw1QlvEQkGZSc0XaY6VnTxZr+Kv1G5zFwttpjv8qxZ/sBPT4nthwZaAcsAZTJlSKXQ=="], @@ -396,6 +433,8 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], @@ -410,6 +449,8 @@ "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -426,6 +467,8 @@ "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="], @@ -434,8 +477,14 @@ "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], @@ -468,16 +517,30 @@ "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "follow-redirects": ["follow-redirects@1.16.1", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg=="], + + "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-stdin": ["get-stdin@4.0.1", "", {}, "sha512-F5aQMywwJ2n85s4hJPTT9RPxGmubonuB10MNYo17/xph174n2MIR33HRguhzVag10O/npM7SPk73LMZNP+FaWw=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], @@ -504,6 +567,8 @@ "htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="], + "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], "indent-string": ["indent-string@2.1.0", "", { "dependencies": { "repeating": "^2.0.0" } }, "sha512-aqwDFWSgSgfRaEwao5lg5KEcVd/2a+D1rvoG7NdilmYz0NwRk6StWpWdz/Hpk34MKPpx7s8XxUqimfcQK6gGlg=="], @@ -590,6 +655,8 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], @@ -696,6 +763,10 @@ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -710,6 +781,8 @@ "next-mdx-remote": ["next-mdx-remote@6.0.0", "", { "dependencies": { "@babel/code-frame": "^7.23.5", "@mdx-js/mdx": "^3.0.1", "@mdx-js/react": "^3.0.1", "unist-util-remove": "^4.0.0", "unist-util-visit": "^5.1.0", "vfile": "^6.0.1", "vfile-matter": "^5.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-cJEpEZlgD6xGjB4jL8BnI8FaYdN9BzZM4NwadPe1YQr7pqoWjg9EBCMv3nXBkuHqMRfv2y33SzUsuyNh9LFAQQ=="], + "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="], + "normalize-package-data": ["normalize-package-data@2.5.0", "", { "dependencies": { "hosted-git-info": "^2.1.4", "resolve": "^1.10.0", "semver": "2 || 3 || 4 || 5", "validate-npm-package-license": "^3.0.1" } }, "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA=="], "object-assign": ["object-assign@4.0.1", "", {}, "sha512-c6legOHWepAbWnp3j5SRUMpxCXBKI4rD7A5Osn9IzZ8w4O/KccXdW0lqdkQKbpk0eHGjNgKihgzY6WuEq99Tfw=="], @@ -754,6 +827,12 @@ "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], + "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], + + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="], + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], "react": ["react@19.3.1", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="], @@ -780,6 +859,8 @@ "redent": ["redent@1.0.0", "", { "dependencies": { "indent-string": "^2.1.0", "strip-indent": "^1.0.1" } }, "sha512-qtW5hKzGQZqKoh6JNSD+4lfitfPKGz42e6QwiRmPM5mmKtR0N41AbJRYu0xJi7nhOJ4WDgRkKvAk6tw4WIwR4g=="], + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], + "refractor": ["refractor@5.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^9.0.0", "parse-entities": "^4.0.0" } }, "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw=="], "rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="], @@ -882,6 +963,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], @@ -974,6 +1057,8 @@ "read-pkg-up/find-up": ["find-up@1.1.2", "", { "dependencies": { "path-exists": "^2.0.0", "pinkie-promise": "^2.0.0" } }, "sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA=="], + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], diff --git a/db/schema.ts b/db/schema.ts index 096fc32..25de3f5 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -281,6 +281,151 @@ const schema: BUN_SQLITE_DatabaseSchemaType = { }, ], }, + { + tableName: "client_routes", + tableDescription: `TLS routes served to WireGuard clients`, + fields: [ + { + fieldName: "user_id", + dataType: "INTEGER", + }, + { + fieldName: "host_id", + dataType: "INTEGER", + defaultValue: 0, + }, + { + fieldName: "name", + dataType: "TEXT", + }, + { + fieldName: "hostname", + dataType: "TEXT", + }, + { + fieldName: "path_prefix", + dataType: "TEXT", + defaultValue: "/", + }, + { + fieldName: "strip_prefix", + dataType: "INTEGER", + options: [0, 1], + defaultValue: 0, + }, + { + fieldName: "upstream_url", + dataType: "TEXT", + }, + { + fieldName: "upstream_tls_verify", + dataType: "INTEGER", + options: [0, 1], + defaultValue: 1, + }, + { + fieldName: "enabled", + dataType: "INTEGER", + options: [0, 1], + defaultValue: 1, + }, + { + fieldName: "notes", + dataType: "TEXT", + }, + { + fieldName: "acme_email", + dataType: "TEXT", + }, + { + fieldName: "acme_dns_provider", + dataType: "TEXT", + }, + { + fieldName: "acme_staging", + dataType: "INTEGER", + options: [0, 1], + defaultValue: 0, + }, + { + fieldName: "acme_credentials", + dataType: "TEXT", + }, + ], + uniqueConstraints: [ + { + constraintName: "Unique path per route hostname", + alias: "unique_path_per_route_hostname", + constraintTableFields: [ + { value: "hostname" }, + { value: "path_prefix" }, + ], + }, + ], + }, + { + tableName: "tls_certificates", + tableDescription: `TLS certificates served by the Caddy ingress`, + fields: [ + { + fieldName: "user_id", + dataType: "INTEGER", + }, + { + fieldName: "hostname", + dataType: "TEXT", + unique: true, + }, + { + fieldName: "provider", + dataType: "TEXT", + defaultValue: "letsencrypt", + }, + { + fieldName: "challenge", + dataType: "TEXT", + defaultValue: "dns-01", + }, + { + fieldName: "dns_provider", + dataType: "TEXT", + }, + { + fieldName: "cert_path", + dataType: "TEXT", + }, + { + fieldName: "key_path", + dataType: "TEXT", + }, + { + fieldName: "expires_at", + dataType: "INTEGER", + }, + { + fieldName: "status", + dataType: "TEXT", + options: [ + "missing", + "issuing", + "active", + "error", + "expiring", + ], + defaultValue: "missing", + }, + { + fieldName: "last_error", + dataType: "TEXT", + }, + { + fieldName: "auto_renew", + dataType: "INTEGER", + options: [0, 1], + defaultValue: 1, + }, + ], + }, { tableName: "variables", fields: [ diff --git a/db/types/db.ts b/db/types/db.ts index c59a846..0e1570e 100644 --- a/db/types/db.ts +++ b/db/types/db.ts @@ -6,6 +6,8 @@ export const BunSQLiteTables = [ "clients", "hosts", "client_rules", + "client_routes", + "tls_certificates", "variables", ] as const @@ -162,6 +164,61 @@ export type BUN_SQLITE_WGUI_CLIENT_RULES = { protocol?: "any" | "tcp" | "udp" | ""; } +export type BUN_SQLITE_WGUI_CLIENT_ROUTES = { + /** + * The unique identifier of the record. + */ + id?: number | ""; + /** + * The time when the record was created. (Unix Timestamp) + */ + created_at?: number | ""; + /** + * The time when the record was updated. (Unix Timestamp) + */ + updated_at?: number | ""; + user_id?: number | ""; + host_id?: number | ""; + name?: string; + hostname?: string; + path_prefix?: string; + strip_prefix?: 0 | 1 | ""; + upstream_url?: string; + upstream_tls_verify?: 0 | 1 | ""; + enabled?: 0 | 1 | ""; + notes?: string; + acme_email?: string; + acme_dns_provider?: string; + acme_staging?: 0 | 1 | ""; + acme_credentials?: string; +} + +export type BUN_SQLITE_WGUI_TLS_CERTIFICATES = { + /** + * The unique identifier of the record. + */ + id?: number | ""; + /** + * The time when the record was created. (Unix Timestamp) + */ + created_at?: number | ""; + /** + * The time when the record was updated. (Unix Timestamp) + */ + updated_at?: number | ""; + user_id?: number | ""; + hostname?: string; + provider?: string; + challenge?: string; + dns_provider?: string; + cert_path?: string; + key_path?: string; + expires_at?: number | ""; + status?: "missing" | "issuing" | "active" | "error" | "expiring" | ""; + last_error?: string; + auto_renew?: 0 | 1 | ""; +} + export type BUN_SQLITE_WGUI_VARIABLES = { /** * The unique identifier of the record. @@ -179,4 +236,4 @@ export type BUN_SQLITE_WGUI_VARIABLES = { value?: string; } -export type BUN_SQLITE_WGUI_ALL_TYPEDEFS = BUN_SQLITE_WGUI_USERS & BUN_SQLITE_WGUI_USER_TYPES & BUN_SQLITE_WGUI_MEDIA & BUN_SQLITE_WGUI_MEDIA_PARADIGMS & BUN_SQLITE_WGUI_CLIENTS & BUN_SQLITE_WGUI_HOSTS & BUN_SQLITE_WGUI_CLIENT_RULES & BUN_SQLITE_WGUI_VARIABLES \ No newline at end of file +export type BUN_SQLITE_WGUI_ALL_TYPEDEFS = BUN_SQLITE_WGUI_USERS & BUN_SQLITE_WGUI_USER_TYPES & BUN_SQLITE_WGUI_MEDIA & BUN_SQLITE_WGUI_MEDIA_PARADIGMS & BUN_SQLITE_WGUI_CLIENTS & BUN_SQLITE_WGUI_HOSTS & BUN_SQLITE_WGUI_CLIENT_RULES & BUN_SQLITE_WGUI_CLIENT_ROUTES & BUN_SQLITE_WGUI_TLS_CERTIFICATES & BUN_SQLITE_WGUI_VARIABLES \ No newline at end of file diff --git a/package.json b/package.json index 7acad4b..5a17bdd 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "dependencies": { "@moduletrace/bun-sqlite": "^1.2.0", "@moduletrace/bunext": "^1.2.4", + "acme-client": "5.4.0", "html-to-react": "^1.7.0", "lodash": "^4.18.1", "lucide-react": "^1.31.0", diff --git a/src/components/general/client-form/submit-client-form.ts b/src/components/general/client-form/submit-client-form.ts index e7096ba..1dc5e4f 100644 --- a/src/components/general/client-form/submit-client-form.ts +++ b/src/components/general/client-form/submit-client-form.ts @@ -30,14 +30,23 @@ export default async function submitClientForm({ const { setLoading, setStatus, form, existing_full, app_context, clearLocalForm } = init; const is_edit = Boolean(existing_full?.id); - if (is_edit) { - const confirmed = await confirm({ - title: "Save changes?", - message: "The client's config is regenerated. Devices may need the updated config.", - confirm_label: "Save changes", - }); - if (!confirmed) return; - } + const confirmed = await confirm( + is_edit + ? { + title: "Save changes?", + message: + "The client's config is regenerated. Devices may need the updated config.", + confirm_label: "Save changes", + } + : { + title: "Create this client?", + message: + "A key pair and WireGuard config will be generated for this client.", + confirm_label: "Create client", + }, + ); + + if (!confirmed) return; setLoading(true); diff --git a/src/components/general/client-route-form/index.tsx b/src/components/general/client-route-form/index.tsx new file mode 100644 index 0000000..a2478ef --- /dev/null +++ b/src/components/general/client-route-form/index.tsx @@ -0,0 +1,226 @@ +import type { BUN_SQLITE_WGUI_CLIENT_ROUTES } from "@/db/types/db"; +import type { ClientRouteView } from "@/src/functions/backend/acme/decorate-client-route"; +import type { ClientRouteFormObject } from "./types"; +import Form from "@/src/components/twui/form/Form"; +import Input from "@/src/components/twui/form/Input"; +import Checkbox from "@/src/components/twui/form/Checkbox"; +import LoadingBlock from "@/src/components/twui/elements/LoadingBlock"; +import useFormInit from "@/src/hooks/use-form-init"; +import useConfirm from "@/src/hooks/use-confirm"; +import Panel from "../panel"; +import FormSection from "../form-section"; +import FormActions from "../form-actions"; +import InlineAlert from "../inline-alert"; +import submitClientRouteForm from "./submit-client-route-form"; + +type Props = { + host_id: number; + existing_route?: ClientRouteView; + cancel_href: string; +}; + +export default function ClientRouteForm({ + host_id, + existing_route, + cancel_href, +}: Props) { + const { confirm } = useConfirm(); + + const init = useFormInit({ + existing: existing_route, + existing_full: existing_route, + title: existing_route?.id + ? `Edit route v2 ${existing_route.id}` + : `Add client route v2 ${host_id}`, + local_storage_exclude_keys: ["acme_credentials"], + }); + + const { form, setForm, loading, status, ready } = init; + const is_edit = Boolean(existing_route?.id); + + if (!ready) { + return ; + } + + return ( + +
+ submitClientRouteForm({ + init, + host_id, + cancel_href, + confirm, + }) + } + > + {status?.error ? ( + + ) : null} + + + + setForm((prev) => ({ + ...prev, + name: e.target.value, + })) + } + showLabel + /> + + setForm((prev) => ({ + ...prev, + hostname: e.target.value.toLowerCase(), + })) + } + showLabel + required + /> + + setForm((prev) => ({ + ...prev, + path_prefix: e.target.value, + })) + } + showLabel + info="Use / for the whole hostname, or /app to match one subpath." + /> + + setForm((prev) => ({ + ...prev, + strip_prefix: value ? 1 : 0, + })) + } + /> + + + + + setForm((prev) => ({ + ...prev, + upstream_url: e.target.value, + })) + } + showLabel + required + info="Bare origin only: http(s)://host:port, with no path, query or credentials." + /> + + setForm((prev) => ({ + ...prev, + upstream_tls_verify: value ? 1 : 0, + })) + } + info="When disabled, Caddy accepts any upstream certificate. Use only for internal self-signed services." + /> + + setForm((prev) => ({ + ...prev, + enabled: value ? 1 : 0, + })) + } + /> + + + + + + setForm((prev) => ({ + ...prev, + acme_email: e.target.value, + })) + } + showLabel + /> + + setForm((prev) => ({ + ...prev, + acme_staging: value ? 1 : 0, + })) + } + /> + + + + + setForm((prev) => ({ + ...prev, + notes: e.target.value, + })) + } + showLabel + /> + + + + +
+ ); +} diff --git a/src/components/general/client-route-form/submit-client-route-form.ts b/src/components/general/client-route-form/submit-client-route-form.ts new file mode 100644 index 0000000..fb9c5f6 --- /dev/null +++ b/src/components/general/client-route-form/submit-client-route-form.ts @@ -0,0 +1,90 @@ +import type { BUN_SQLITE_WGUI_CLIENT_ROUTES } from "@/db/types/db"; +import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler"; +import type { ClientRouteFormInit } from "./types"; +import type { ConfirmContextValue } from "../confirm/confirm-context"; + +type Params = { + init: ClientRouteFormInit; + host_id: number; + cancel_href: string; + confirm: ConfirmContextValue["confirm"]; +}; + +export default async function submitClientRouteForm({ + init, + host_id, + cancel_href, + confirm, +}: Params) { + const { form, existing_full, setLoading, setStatus, clearLocalForm } = init; + const is_edit = Boolean(existing_full?.id); + + const confirmed = await confirm( + is_edit + ? { + title: "Save route changes?", + message: + "Caddy will regenerate its config and reload the TLS ingress.", + confirm_label: "Save changes", + } + : { + title: "Create this route?", + message: + "Caddy will regenerate its config and reload the TLS ingress.", + confirm_label: "Create route", + }, + ); + + if (!confirmed) return; + + setLoading(true); + + try { + const data: BUN_SQLITE_WGUI_CLIENT_ROUTES = { + host_id, + name: form.name || "", + hostname: form.hostname || "", + path_prefix: form.path_prefix || "/", + strip_prefix: form.strip_prefix == 1 ? 1 : 0, + upstream_url: form.upstream_url || "", + upstream_tls_verify: form.upstream_tls_verify == 0 ? 0 : 1, + enabled: form.enabled == 0 ? 0 : 1, + notes: form.notes || "", + acme_email: form.acme_email || "", + acme_staging: form.acme_staging == 1 ? 1 : 0, + }; + + const res = is_edit + ? await adminCrudHandler({ + action: "update", + table: "client_routes", + update_data: data, + id: existing_full?.id, + }) + : await adminCrudHandler({ + action: "insert", + table: "client_routes", + insert_data: [data], + }); + + if (!res.success) { + throw new Error(res.msg || "Could not save the route"); + } + + const route_id = + existing_full?.id || + res.postInsertReturn?.insertId || + res.singleRes?.id; + + clearLocalForm(); + window.location.href = route_id + ? `/admin/hosts/${host_id}/routes/${route_id}` + : cancel_href; + } catch (error: any) { + setStatus({ + error: true, + msg: error.message || "Could not save the route", + }); + setLoading(false); + } +} diff --git a/src/components/general/client-route-form/types.ts b/src/components/general/client-route-form/types.ts new file mode 100644 index 0000000..7243bbc --- /dev/null +++ b/src/components/general/client-route-form/types.ts @@ -0,0 +1,10 @@ +import type { ClientRouteView } from "@/src/functions/backend/acme/decorate-client-route"; +import type useFormInit from "@/src/hooks/use-form-init"; + +export type ClientRouteFormObject = Omit & { + acme_credentials?: string; +}; + +export type ClientRouteFormInit = ReturnType< + typeof useFormInit +>; diff --git a/src/components/general/client-routes-panel/index.tsx b/src/components/general/client-routes-panel/index.tsx new file mode 100644 index 0000000..9d993af --- /dev/null +++ b/src/components/general/client-routes-panel/index.tsx @@ -0,0 +1,135 @@ +import { useCallback, useMemo } from "react"; +import { Network, Plus } from "lucide-react"; +import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db"; +import type { ClientRouteView } from "@/src/functions/backend/acme/decorate-client-route"; +import Button from "@/src/components/twui/layout/Button"; +import Stack from "@/src/components/twui/layout/Stack"; +import Panel from "../panel"; +import InlineAlert from "../inline-alert"; +import EmptyState from "../empty-state"; +import ClientRoutesTable from "../client-routes-table"; +import useToast from "@/src/hooks/use-toast"; +import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get"; +import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler"; + +type Props = { + host_id?: number; + show_host?: boolean; + title?: string; + description?: string; + add_href?: string; +}; + +export default function ClientRoutesPanel({ + host_id, + show_host, + title = "Routes", + description, + add_href, +}: Props) { + const { toast } = useToast(); + + const { res, setRes } = useAdminCrudGet({ + table: "client_routes", + sql_query: { + query: host_id !== undefined + ? { host_id: { value: String(host_id) } } + : undefined, + order: { field: "hostname", strategy: "ASC" }, + limit: 200, + }, + }); + + const hosts_query = useAdminCrudGet({ + table: "hosts", + ready: Boolean(show_host), + sql_query: { + order: { field: "id", strategy: "ASC" }, + }, + }); + + const hosts_by_id = useMemo(() => { + const map = new Map(); + + for (const host of hosts_query.res || []) { + if (typeof host.id === "number") { + map.set(host.id, host); + } + } + + return map; + }, [hosts_query.res]); + + const onDeleted = useCallback( + async ({ id }: { id: number }) => { + const deleted = await adminCrudHandler({ + action: "delete", + table: "client_routes", + id, + }); + + if (deleted.success) { + setRes((prev) => prev?.filter((route) => route.id != id)); + toast({ msg: "Route deleted", color: "success" }); + } else { + toast({ + msg: deleted.msg || "Could not delete route", + color: "error", + }); + } + }, + [setRes, toast], + ); + + return ( + } + > + Add route + + ) : undefined + } + > + + + + } + title="No routes yet" + description="Expose an internal HTTP service to WireGuard clients over HTTPS." + action={ + add_href ? ( + + ) : undefined + } + /> + } + /> + + ); +} diff --git a/src/components/general/client-routes-table/client-route-mobile-card.tsx b/src/components/general/client-routes-table/client-route-mobile-card.tsx new file mode 100644 index 0000000..57b4c56 --- /dev/null +++ b/src/components/general/client-routes-table/client-route-mobile-card.tsx @@ -0,0 +1,62 @@ +import Stack from "@/src/components/twui/layout/Stack"; +import Row from "@/src/components/twui/layout/Row"; +import Card from "@/src/components/twui/elements/Card"; +import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db"; +import type { ClientRouteView } from "@/src/functions/backend/acme/decorate-client-route"; +import MutedText from "../muted-text"; +import StatusDot from "../status-dot"; +import ClientRouteNameCell from "./client-route-name-cell"; +import ClientRouteRowActions from "./client-route-row-actions"; + +type Props = { + route: ClientRouteView; + hosts_by_id?: Map; + show_host?: boolean; + onDeleted?: ({ id }: { id: number }) => void; +}; + +export default function ClientRouteMobileCard({ + route, + hosts_by_id, + show_host, + onDeleted, +}: Props) { + return ( + + + + + + {show_host ? ( + + {hosts_by_id?.get(Number(route.host_id || 0)) + ?.name || `Host #${Number(route.host_id || 0)}`} + + ) : null} + {route.upstream_url || "—"} + + + + + + {route.path_prefix || "/"} ·{" "} + {route.enabled == 0 ? "Disabled" : "Enabled"} + + + + + + ); +} diff --git a/src/components/general/client-routes-table/client-route-name-cell.tsx b/src/components/general/client-routes-table/client-route-name-cell.tsx new file mode 100644 index 0000000..84967fe --- /dev/null +++ b/src/components/general/client-routes-table/client-route-name-cell.tsx @@ -0,0 +1,22 @@ +import Link from "@/src/components/twui/layout/Link"; +import MonoText from "../mono-text"; +import type { ClientRouteView } from "@/src/functions/backend/acme/decorate-client-route"; + +type Props = { + route: ClientRouteView; +}; + +export default function ClientRouteNameCell({ route }: Props) { + const host_id = Number(route.host_id || 0); + + return ( + + + {route.hostname || route.name || "—"} + + + ); +} diff --git a/src/components/general/client-routes-table/client-route-row-actions.tsx b/src/components/general/client-routes-table/client-route-row-actions.tsx new file mode 100644 index 0000000..92dc567 --- /dev/null +++ b/src/components/general/client-routes-table/client-route-row-actions.tsx @@ -0,0 +1,53 @@ +import { Eye, Pencil, Trash2 } from "lucide-react"; +import ActionMenu from "../action-menu"; +import useConfirm from "@/src/hooks/use-confirm"; +import type { BUN_SQLITE_WGUI_CLIENT_ROUTES } from "@/db/types/db"; + +type Props = { + route: BUN_SQLITE_WGUI_CLIENT_ROUTES; + onDeleted?: ({ id }: { id: number }) => void; +}; + +export default function ClientRouteRowActions({ route, onDeleted }: Props) { + const { confirm } = useConfirm(); + const route_id = Number(route.id || 0); + const host_id = Number(route.host_id || 0); + + return ( + , + }, + { + key: "edit", + title: "Edit", + href: `/admin/hosts/${host_id}/routes/edit/${route_id}`, + icon: , + }, + { + key: "delete", + title: "Delete", + tone: "danger", + icon: , + onClick: async () => { + const confirmed = await confirm({ + title: "Delete route?", + message: `${route.hostname} will stop being served by Caddy.`, + confirm_label: "Delete", + }); + + if (confirmed) { + onDeleted?.({ id: route_id }); + } + }, + }, + ]} + /> + ); +} diff --git a/src/components/general/client-routes-table/client-routes-table-columns.tsx b/src/components/general/client-routes-table/client-routes-table-columns.tsx new file mode 100644 index 0000000..cd0a535 --- /dev/null +++ b/src/components/general/client-routes-table/client-routes-table-columns.tsx @@ -0,0 +1,96 @@ +import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db"; +import type { ClientRouteView } from "@/src/functions/backend/acme/decorate-client-route"; +import type { DataTableColumn } from "../data-table/types"; +import MonoText from "../mono-text"; +import MutedText from "../muted-text"; +import StatusDot from "../status-dot"; +import ClientRouteNameCell from "./client-route-name-cell"; +import ClientRouteRowActions from "./client-route-row-actions"; + +type Params = { + onDeleted?: ({ id }: { id: number }) => void; + hosts_by_id?: Map; + show_host?: boolean; +}; + +function certificateTone(status?: string) { + if (status == "active") return "success" as const; + if (status == "expiring" || status == "issuing") return "warning" as const; + if (status == "error") return "error" as const; + return "neutral" as const; +} + +export default function buildClientRoutesTableColumns({ + onDeleted, + hosts_by_id, + show_host, +}: Params): DataTableColumn[] { + const columns: DataTableColumn[] = [ + { + key: "name", + header: "Route", + render: ({ item }) => , + }, + ]; + + if (show_host) { + columns.push({ + key: "host", + header: "Host", + hide_below: "lg", + render: ({ item }) => ( + + {hosts_by_id?.get(Number(item.host_id || 0))?.name || + `Host #${Number(item.host_id || 0)}`} + + ), + }); + } + + columns.push( + { + key: "path", + header: "Path", + render: ({ item }) => {item.path_prefix || "/"}, + }, + { + key: "upstream", + header: "Upstream", + hide_below: "lg", + render: ({ item }) => ( + {item.upstream_url || "—"} + ), + }, + { + key: "certificate", + header: "Certificate", + render: ({ item }) => ( + + ), + }, + { + key: "state", + header: "State", + render: ({ item }) => + item.enabled == 0 ? ( + Disabled + ) : ( + Enabled + ), + }, + { + key: "actions", + header: Actions, + align: "right", + className: "w-10", + render: ({ item }) => ( + + ), + }, + ); + + return columns; +} diff --git a/src/components/general/client-routes-table/index.tsx b/src/components/general/client-routes-table/index.tsx new file mode 100644 index 0000000..0b18bd5 --- /dev/null +++ b/src/components/general/client-routes-table/index.tsx @@ -0,0 +1,59 @@ +import { useCallback, useMemo, type ReactNode } from "react"; +import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db"; +import type { ClientRouteView } from "@/src/functions/backend/acme/decorate-client-route"; +import DataTable from "../data-table"; +import buildClientRoutesTableColumns from "./client-routes-table-columns"; +import ClientRouteMobileCard from "./client-route-mobile-card"; + +type Props = { + routes?: ClientRouteView[]; + hosts_by_id?: Map; + show_host?: boolean; + loading?: boolean; + empty?: ReactNode; + onDeleted?: ({ id }: { id: number }) => void; +}; + +export default function ClientRoutesTable({ + routes, + hosts_by_id, + show_host, + loading, + empty, + onDeleted, +}: Props) { + const columns = useMemo( + () => + buildClientRoutesTableColumns({ + onDeleted, + hosts_by_id, + show_host, + }), + [onDeleted, hosts_by_id, show_host], + ); + + const MobileItem = useCallback( + ({ item }: { item: ClientRouteView }) => ( + + ), + [hosts_by_id, show_host, onDeleted], + ); + + return ( + Number(item.id)} + loading={loading} + empty={empty} + skeleton_rows={3} + MobileItem={MobileItem} + /> + ); +} diff --git a/src/components/general/user-form/index.tsx b/src/components/general/user-form/index.tsx index 9380ea7..0a3c410 100644 --- a/src/components/general/user-form/index.tsx +++ b/src/components/general/user-form/index.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import Form from "@/src/components/twui/form/Form"; import useFormInit from "@/src/hooks/use-form-init"; import useToast from "@/src/hooks/use-toast"; +import useConfirm from "@/src/hooks/use-confirm"; import type { UserFormObject } from "@/src/types"; import type { BUN_SQLITE_WGUI_USERS_JOIN } from "@/src/types/sql-joins"; import InlineAlert from "../inline-alert"; @@ -22,6 +23,7 @@ type Props = { export default function UserForm({ existing_user, existing_user_full, is_first_user }: Props) { const { toast } = useToast(); + const { confirm } = useConfirm(); const init = useFormInit({ existing: existing_user, @@ -40,7 +42,7 @@ export default function UserForm({ existing_user, existing_user_full, is_first_u className="w-full gap-5" onSubmit={() => { if (!passwords_match) return; - submitUserForm({ init, toast }); + submitUserForm({ init, toast, confirm }); }} > {status?.error ? : null} diff --git a/src/components/general/user-form/submit-user-form.ts b/src/components/general/user-form/submit-user-form.ts index 225a73c..4152e7d 100644 --- a/src/components/general/user-form/submit-user-form.ts +++ b/src/components/general/user-form/submit-user-form.ts @@ -12,15 +12,40 @@ import fetchApi from "../../twui/utils/fetch/fetchApi"; import type { APIResponseObject } from "@moduletrace/bunext/types"; import type { ToastContextValue } from "../toast/toast-context"; +import type { ConfirmContextValue } from "../confirm/confirm-context"; type Params = { init: ReturnType>; toast: ToastContextValue["toast"]; + confirm: ConfirmContextValue["confirm"]; }; -export default async function submitUserForm({ init, toast }: Params) { +export default async function submitUserForm({ init, toast, confirm }: Params) { const { setLoading, setStatus, form, existing_full, image, is_first_user } = init; + const is_update = + existing_full?.id !== undefined && existing_full?.id !== ""; + const confirmed = await confirm( + is_first_user + ? { + title: "Create the admin account?", + message: + "This account becomes the super admin and is used to sign in.", + confirm_label: "Create account", + } + : is_update + ? { + title: "Save profile changes?", + confirm_label: "Save changes", + } + : { + title: "Create this user?", + confirm_label: "Create user", + }, + ); + + if (!confirmed) return; + try { const new_user_data: BUN_SQLITE_WGUI_USERS = _.pick< UserFormObject, diff --git a/src/data/tls-data.ts b/src/data/tls-data.ts new file mode 100644 index 0000000..82f3427 --- /dev/null +++ b/src/data/tls-data.ts @@ -0,0 +1,35 @@ +function parsePort({ + value, + fallback, +}: { + value?: string; + fallback: number; +}) { + const parsed = Number(value); + + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { + return fallback; + } + + return parsed; +} + +export const TlsData = { + /** Public HTTPS port Caddy listens on. Never 80/443. */ + CaddyHttpsPort: parsePort({ + value: process.env.CADDY_HTTPS_PORT, + fallback: 8443, + }), + /** Plain HTTP port for Caddy redirects/bootstrap. Never port 80. */ + CaddyHttpPort: parsePort({ + value: process.env.CADDY_HTTP_PORT, + fallback: 8081, + }), + /** Default acme.sh DNS provider id, e.g. dns_cf. */ + DefaultDnsProvider: process.env.ACME_DNS_PROVIDER || "", + /** Use the Let's Encrypt staging endpoint. */ + AcmeStaging: process.env.ACME_STAGING == "1", + /** Keep the ingress port closed unless a route is enabled. */ + IngressEnabled: process.env.CADDY_INGRESS_ENABLED != "0", + RenewBeforeDays: 30, +} as const; diff --git a/src/functions/backend/acme/complete-manual-certificate.ts b/src/functions/backend/acme/complete-manual-certificate.ts new file mode 100644 index 0000000..8446ab9 --- /dev/null +++ b/src/functions/backend/acme/complete-manual-certificate.ts @@ -0,0 +1,141 @@ +import * as acme from "acme-client"; +import { mkdirSync, writeFileSync } from "node:fs"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import grabCertDirNames from "@/src/functions/backend/caddy/grab-cert-dir-names"; +import syncCaddyIngress from "@/src/functions/backend/caddy/sync-caddy-ingress"; +import grabCertificateByHostname from "./grab-certificate-by-hostname"; +import fixCertificatePermissions from "./fix-certificate-permissions"; +import { + grabManualDnsChallenge, + removeManualDnsChallenge, +} from "./manual-dns-challenges"; +import readCertificateExpiry from "./read-certificate-expiry"; +import recordCertificate from "./record-certificate"; + +type Params = { + route_id?: number | ""; + user_id?: number | ""; +}; + +export default async function completeManualCertificate({ + route_id, + user_id, +}: Params): Promise { + const final_route_id = Number(route_id); + + if (!Number.isSafeInteger(final_route_id) || final_route_id < 1) { + return { success: false, msg: `Invalid route` }; + } + + const challenge_state = grabManualDnsChallenge(final_route_id); + + if (!challenge_state) { + return { + success: false, + msg: `No pending certificate request — click Issue/Renew again`, + }; + } + + const { + client, + order, + challenge, + authz, + hostname, + staging, + } = challenge_state; + const { CERT_DIR, CERT_FILE, KEY_FILE } = grabCertDirNames({ hostname }); + + try { + await client.completeChallenge(challenge); + await client.waitForValidStatus(challenge); + + const [key, csr] = await acme.crypto.createCsr({ + commonName: hostname, + altNames: [hostname], + }); + + const finalized = await client.finalizeOrder(order, csr); + const certificate = await client.getCertificate(finalized); + + mkdirSync(CERT_DIR, { recursive: true, mode: 0o750 }); + writeFileSync(KEY_FILE, key, { mode: 0o600 }); + writeFileSync(CERT_FILE, certificate, { mode: 0o644 }); + + fixCertificatePermissions({ + cert_dir: CERT_DIR, + cert_file: CERT_FILE, + key_file: KEY_FILE, + }); + + const expires_at = readCertificateExpiry({ cert_path: CERT_FILE }); + + await recordCertificate({ + certificate: { + user_id: user_id, + hostname, + provider: "letsencrypt", + challenge: "dns-01", + dns_provider: "manual", + cert_path: CERT_FILE, + key_path: KEY_FILE, + expires_at: expires_at || undefined, + status: "active", + last_error: "", + auto_renew: 0, + }, + }); + + removeManualDnsChallenge(final_route_id); + + const sync = await syncCaddyIngress({ + reason: "manual certificate issued", + }); + + return { + success: true, + msg: `Certificate issued for ${hostname}`, + stringRes: hostname, + singleRes: await grabCertificateByHostname({ hostname }), + debug: { warnings: sync.warnings }, + }; + } catch (error: any) { + const msg = error.message || `Certificate validation failed`; + const existing_expiry = readCertificateExpiry({ + cert_path: CERT_FILE, + }); + + await recordCertificate({ + certificate: { + user_id: user_id, + hostname, + provider: "letsencrypt", + challenge: "dns-01", + dns_provider: "manual", + cert_path: CERT_FILE, + key_path: KEY_FILE, + expires_at: existing_expiry || undefined, + status: existing_expiry ? "active" : "error", + last_error: msg, + auto_renew: 0, + }, + }); + + removeManualDnsChallenge(final_route_id); + + // The authorization is usually invalid after a failed validation. + try { + await client.deactivateAuthorization(authz); + } catch { + // Best-effort cleanup. + } + + return { + success: false, + msg, + stringRes: hostname, + singleRes: await grabCertificateByHostname({ hostname }), + debug: { staging }, + }; + } +} diff --git a/src/functions/backend/acme/decorate-certificate.ts b/src/functions/backend/acme/decorate-certificate.ts new file mode 100644 index 0000000..6648065 --- /dev/null +++ b/src/functions/backend/acme/decorate-certificate.ts @@ -0,0 +1,46 @@ +import type { BUN_SQLITE_WGUI_TLS_CERTIFICATES } from "@/db/types/db"; +import readCertificateExpiry from "./read-certificate-expiry"; + +export type ClientCertificateView = BUN_SQLITE_WGUI_TLS_CERTIFICATES & { + expires_at?: number | null; + has_certificate?: boolean; +}; + +type Params = { + certificate?: BUN_SQLITE_WGUI_TLS_CERTIFICATES | null; + hostname?: string; +}; + +export default function decorateCertificate({ + certificate, + hostname, +}: Params): ClientCertificateView { + const expires_at = readCertificateExpiry({ + cert_path: certificate?.cert_path, + }); + const now = Date.now(); + const renew_before = 30 * 24 * 60 * 60 * 1000; + + let status = certificate?.status || "missing"; + + if (expires_at) { + status = + expires_at <= now + ? "error" + : expires_at - now <= renew_before + ? "expiring" + : certificate?.status == "issuing" + ? "issuing" + : "active"; + } else if (status == "active") { + status = "missing"; + } + + return { + ...(certificate || {}), + hostname: certificate?.hostname || hostname, + expires_at: expires_at || undefined, + status, + has_certificate: Boolean(expires_at), + }; +} diff --git a/src/functions/backend/acme/decorate-client-route.test.ts b/src/functions/backend/acme/decorate-client-route.test.ts new file mode 100644 index 0000000..10607c8 --- /dev/null +++ b/src/functions/backend/acme/decorate-client-route.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +import decorateClientRoute from "./decorate-client-route"; + +describe("decorateClientRoute", () => { + test("never exposes encrypted credentials", () => { + const route = decorateClientRoute({ + route: { + id: 1, + host_id: 0, + hostname: "app.example.com", + acme_credentials: "encrypted-value", + }, + }); + + expect("acme_credentials" in route).toBe(false); + expect(route.acme_credentials_configured).toBe(true); + expect(route.certificate_status).toBe("missing"); + }); +}); diff --git a/src/functions/backend/acme/decorate-client-route.ts b/src/functions/backend/acme/decorate-client-route.ts new file mode 100644 index 0000000..9f2f53c --- /dev/null +++ b/src/functions/backend/acme/decorate-client-route.ts @@ -0,0 +1,49 @@ +import type { + BUN_SQLITE_WGUI_CLIENT_ROUTES, + BUN_SQLITE_WGUI_TLS_CERTIFICATES, +} from "@/db/types/db"; +import readCertificateExpiry from "./read-certificate-expiry"; + +export type ClientRouteView = BUN_SQLITE_WGUI_CLIENT_ROUTES & { + acme_credentials?: never; + acme_credentials_configured?: boolean; + certificate_status?: string; + certificate_expires_at?: number | null; +}; + +type Params = { + route: BUN_SQLITE_WGUI_CLIENT_ROUTES; + certificate?: BUN_SQLITE_WGUI_TLS_CERTIFICATES | null; +}; + +export default function decorateClientRoute({ + route, + certificate, +}: Params): ClientRouteView { + const { acme_credentials, ...safe_route } = route; + const expires_at = readCertificateExpiry({ + cert_path: certificate?.cert_path, + }); + const now = Date.now(); + const renew_before = 30 * 24 * 60 * 60 * 1000; + + let certificate_status = certificate?.status || "missing"; + + if (expires_at) { + certificate_status = + expires_at <= now + ? "error" + : expires_at - now <= renew_before + ? "expiring" + : "active"; + } else if (certificate_status == "active") { + certificate_status = "missing"; + } + + return { + ...safe_route, + acme_credentials_configured: Boolean(acme_credentials), + certificate_status, + certificate_expires_at: expires_at || null, + }; +} diff --git a/src/functions/backend/acme/fix-certificate-permissions.ts b/src/functions/backend/acme/fix-certificate-permissions.ts new file mode 100644 index 0000000..e576347 --- /dev/null +++ b/src/functions/backend/acme/fix-certificate-permissions.ts @@ -0,0 +1,29 @@ +import { chmodSync } from "node:fs"; +import { execFileSync } from "node:child_process"; + +type Params = { + cert_dir: string; + cert_file: string; + key_file: string; +}; + +export default function fixCertificatePermissions({ + cert_dir, + cert_file, + key_file, +}: Params) { + try { + chmodSync(cert_dir, 0o750); + chmodSync(cert_file, 0o640); + chmodSync(key_file, 0o640); + + for (const target of [cert_dir, cert_file, key_file]) { + execFileSync("chown", ["root:wgui-caddy", target], { + stdio: "pipe", + timeout: 5000, + }); + } + } catch { + // The group may not exist in development; Caddy runs as root there. + } +} diff --git a/src/functions/backend/acme/grab-acme-account-key.ts b/src/functions/backend/acme/grab-acme-account-key.ts new file mode 100644 index 0000000..3144394 --- /dev/null +++ b/src/functions/backend/acme/grab-acme-account-key.ts @@ -0,0 +1,34 @@ +import * as acme from "acme-client"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import grabDirNames from "@/src/utils/grab-dir-names"; + +const { WGUI_LIB_ACME_DIR, WGUI_ACME_ACCOUNT_KEY_FILE } = grabDirNames(); + +let cached_key: string | null = null; + +/** + * A single ACME account key for the host. It is created on first use and + * reused for every certificate/account registration. + */ +export default async function grabAcmeAccountKey(): Promise { + if (cached_key) { + return cached_key; + } + + mkdirSync(WGUI_LIB_ACME_DIR, { recursive: true, mode: 0o700 }); + chmodSync(WGUI_LIB_ACME_DIR, 0o700); + + if (existsSync(WGUI_ACME_ACCOUNT_KEY_FILE)) { + cached_key = readFileSync(WGUI_ACME_ACCOUNT_KEY_FILE, "utf-8"); + return cached_key; + } + + const key = await acme.crypto.createPrivateKey(); + const pem = key.toString(); + + writeFileSync(WGUI_ACME_ACCOUNT_KEY_FILE, pem, { mode: 0o600 }); + chmodSync(WGUI_ACME_ACCOUNT_KEY_FILE, 0o600); + cached_key = pem; + + return pem; +} diff --git a/src/functions/backend/acme/grab-certificate-by-hostname.ts b/src/functions/backend/acme/grab-certificate-by-hostname.ts new file mode 100644 index 0000000..789ed3e --- /dev/null +++ b/src/functions/backend/acme/grab-certificate-by-hostname.ts @@ -0,0 +1,35 @@ +import type { BUN_SQLITE_WGUI_TLS_CERTIFICATES } from "@/db/types/db"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { TableType } from "@/src/types"; +import decorateCertificate, { type ClientCertificateView } from "./decorate-certificate"; + +type Params = { + hostname?: string | null; +}; + +export default async function grabCertificateByHostname({ + hostname, +}: Params): Promise { + if (!hostname) { + return null; + } + + const certificates_res = await BunSQLite.select< + BUN_SQLITE_WGUI_TLS_CERTIFICATES, + TableType + >({ + table: "tls_certificates", + query: { + query: { + hostname: { + value: hostname, + }, + }, + }, + }); + + return decorateCertificate({ + certificate: certificates_res.singleRes || null, + hostname, + }); +} diff --git a/src/functions/backend/acme/grab-tls-status.ts b/src/functions/backend/acme/grab-tls-status.ts new file mode 100644 index 0000000..9d18586 --- /dev/null +++ b/src/functions/backend/acme/grab-tls-status.ts @@ -0,0 +1,117 @@ +import type { + BUN_SQLITE_WGUI_CLIENT_ROUTES, + BUN_SQLITE_WGUI_TLS_CERTIFICATES, +} from "@/db/types/db"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { TableType } from "@/src/types"; +import { TlsData } from "@/src/data/tls-data"; +import grabCaddyStatus from "../caddy/grab-caddy-status"; +import grabCertDirNames from "../caddy/grab-cert-dir-names"; +import readAcmeEnv from "./read-acme-env"; +import readCertificateExpiry from "./read-certificate-expiry"; + +export default async function grabTlsStatus() { + const certificates_res = await BunSQLite.select< + BUN_SQLITE_WGUI_TLS_CERTIFICATES, + TableType + >({ + table: "tls_certificates", + query: { + order: { field: "hostname", strategy: "ASC" }, + }, + }); + + const routes_res = await BunSQLite.select< + BUN_SQLITE_WGUI_CLIENT_ROUTES, + TableType + >({ + table: "client_routes", + }); + + const acme_env = readAcmeEnv(); + const now = Date.now(); + const renew_before = TlsData.RenewBeforeDays * 24 * 60 * 60 * 1000; + + const certificates = (certificates_res.payload || []).map( + (certificate) => { + const expires_at = readCertificateExpiry({ + cert_path: certificate.cert_path, + }); + + let status = certificate.status || "missing"; + + if (expires_at) { + if (expires_at <= now) { + status = "error"; + } else if (expires_at - now <= renew_before) { + status = "expiring"; + } else if (status != "issuing") { + status = "active"; + } + } + + return { + ...certificate, + hostname: certificate.hostname?.trim().toLowerCase(), + expires_at: expires_at || certificate.expires_at || null, + status, + has_certificate: Boolean(expires_at), + }; + }, + ); + + const known_hostnames = new Set( + certificates + .map((certificate) => certificate.hostname) + .filter(Boolean), + ); + + for (let i = 0; i < (routes_res.payload || []).length; i++) { + const route = routes_res.payload?.[i]; + const hostname = route?.hostname?.trim().toLowerCase(); + + if (!hostname || known_hostnames.has(hostname)) { + continue; + } + + const { CERT_FILE, KEY_FILE } = grabCertDirNames({ hostname }); + + certificates.push({ + hostname, + provider: "letsencrypt", + challenge: "dns-01", + cert_path: CERT_FILE, + key_path: KEY_FILE, + status: "missing", + auto_renew: 1, + expires_at: null, + has_certificate: false, + }); + + known_hostnames.add(hostname); + } + + certificates.sort((a, b) => + (a.hostname || "").localeCompare(b.hostname || ""), + ); + + return { + caddy: grabCaddyStatus(), + certificates, + acme: { + env_path: acme_env.exists ? `/etc/wgui/acme.env` : null, + env_exists: acme_env.exists, + provider: + acme_env.values.ACME_DNS_PROVIDER || + TlsData.DefaultDnsProvider || + null, + email_configured: Boolean(acme_env.values.ACME_EMAIL), + credential_keys: acme_env.keys.filter( + (key) => + key != "ACME_EMAIL" && key != "ACME_DNS_PROVIDER", + ), + staging: TlsData.AcmeStaging, + }, + https_port: TlsData.CaddyHttpsPort, + }; +} diff --git a/src/functions/backend/acme/issue-certificate.ts b/src/functions/backend/acme/issue-certificate.ts new file mode 100644 index 0000000..250a32c --- /dev/null +++ b/src/functions/backend/acme/issue-certificate.ts @@ -0,0 +1,309 @@ +import { chmodSync, existsSync, mkdirSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import type { BUN_SQLITE_WGUI_CLIENT_ROUTES } from "@/db/types/db"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import grabDirNames from "@/src/utils/grab-dir-names"; +import type { TableType } from "@/src/types"; +import grabSystemSetupStatus from "@/src/functions/backend/setup/grab-system-setup-status"; +import grabCertDirNames from "../caddy/grab-cert-dir-names"; +import syncCaddyIngress from "../caddy/sync-caddy-ingress"; +import validateRouteHostname from "../caddy/validate-route-hostname"; +import grabCertificateByHostname from "./grab-certificate-by-hostname"; +import readCertificateExpiry from "./read-certificate-expiry"; +import readRouteAcmeConfig from "./read-route-acme-config"; +import recordCertificate from "./record-certificate"; +import redactSecrets from "./redact-secrets"; + +type Params = { + route_id?: number | ""; + route?: BUN_SQLITE_WGUI_CLIENT_ROUTES | null; + hostname?: string | null; + user_id?: number | ""; + staging?: boolean; + force?: boolean; +}; + +const { WGUI_ACME_SCRIPT, WGUI_ACME_HOME } = grabDirNames(); + +const PROVIDER_PATTERN = /^dns_[a-z0-9_]+$/; + +function formatOutput({ text, secrets }: { text?: string; secrets: string[] }) { + const redacted = redactSecrets({ text, secrets }); + const trimmed = redacted.trim(); + + return trimmed.length > 4000 ? `${trimmed.slice(0, 4000)}…` : trimmed; +} + +function fixPermissions({ cert_dir, cert_file, key_file }: { + cert_dir: string; + cert_file: string; + key_file: string; +}) { + try { + chmodSync(cert_dir, 0o750); + chmodSync(cert_file, 0o640); + chmodSync(key_file, 0o640); + + for (const target of [cert_dir, cert_file, key_file]) { + execFileSync("chown", ["root:wgui-caddy", target], { + stdio: "pipe", + timeout: 5000, + }); + } + } catch { + // The group may not exist in development; Caddy runs as root there. + } +} + +export default async function issueCertificate({ + route_id, + route: passed_route, + hostname: raw_hostname, + user_id, + staging, + force, +}: Params): Promise { + let route = passed_route || undefined; + + if (!route && route_id !== undefined && route_id !== "") { + const route_res = await BunSQLite.select< + BUN_SQLITE_WGUI_CLIENT_ROUTES, + TableType + >({ + table: "client_routes", + targetId: route_id, + }); + + route = route_res.singleRes || undefined; + } + + if (!route && raw_hostname) { + const route_res = await BunSQLite.select< + BUN_SQLITE_WGUI_CLIENT_ROUTES, + TableType + >({ + table: "client_routes", + query: { + query: { + hostname: { + value: raw_hostname.trim().toLowerCase(), + }, + }, + }, + }); + + route = route_res.singleRes || undefined; + } + + const hostname = validateRouteHostname({ + value: route?.hostname || raw_hostname, + }); + + if (!hostname.success) { + return { success: false, msg: hostname.msg }; + } + + if (!route) { + return { + success: false, + msg: `Route not found — open the route and save its TLS settings first`, + }; + } + + const acme = await readRouteAcmeConfig({ route }); + + if (!acme.success) { + return { success: false, msg: acme.msg }; + } + + const provider = acme.config.provider; + const email = acme.config.email; + const secrets = Object.values(acme.config.values); + + if (!existsSync(WGUI_ACME_SCRIPT)) { + return { + success: false, + msg: `acme.sh is not installed at ${WGUI_ACME_SCRIPT}`, + }; + } + + if (!provider || !PROVIDER_PATTERN.test(provider)) { + return { + success: false, + msg: `Set the DNS provider on this route's form (e.g. dns_cf)`, + }; + } + + if (!email) { + return { + success: false, + msg: `Set the ACME email on this route's form`, + }; + } + + const { CERT_DIR, CERT_FILE, KEY_FILE } = grabCertDirNames({ + hostname: hostname.value, + }); + + mkdirSync(CERT_DIR, { recursive: true, mode: 0o750 }); + + const setup_status = grabSystemSetupStatus(); + const reload_command = + setup_status.init_system == "openrc" + ? `rc-service wgui-caddy reload` + : `systemctl reload wgui-caddy`; + + const child_env = { + ...process.env, + ...acme.config.values, + LE_WORKING_DIR: WGUI_ACME_HOME, + }; + + const use_staging = + staging !== undefined ? staging : acme.config.staging; + + await recordCertificate({ + certificate: { + user_id, + hostname: hostname.value, + provider: "letsencrypt", + challenge: "dns-01", + dns_provider: provider, + cert_path: CERT_FILE, + key_path: KEY_FILE, + status: "issuing", + last_error: "", + auto_renew: 1, + }, + }); + + try { + const issue_args = [ + "--issue", + "--server", + "letsencrypt", + "--dns", + provider, + "--accountemail", + email, + "-d", + hostname.value, + "--keylength", + "ec-256", + ]; + + if (use_staging) { + issue_args.push("--staging"); + } + + if (force) { + issue_args.push("--force"); + } + + const issue_output = execFileSync(WGUI_ACME_SCRIPT, issue_args, { + env: child_env, + encoding: "utf-8", + stdio: "pipe", + timeout: 300000, + maxBuffer: 4 * 1024 * 1024, + }); + + const install_output = execFileSync( + WGUI_ACME_SCRIPT, + [ + "--install-cert", + "-d", + hostname.value, + "--key-file", + KEY_FILE, + "--fullchain-file", + CERT_FILE, + "--reloadcmd", + reload_command, + ], + { + env: child_env, + encoding: "utf-8", + stdio: "pipe", + timeout: 60000, + maxBuffer: 4 * 1024 * 1024, + }, + ); + + fixPermissions({ + cert_dir: CERT_DIR, + cert_file: CERT_FILE, + key_file: KEY_FILE, + }); + + const expires_at = readCertificateExpiry({ cert_path: CERT_FILE }); + + await recordCertificate({ + certificate: { + user_id, + hostname: hostname.value, + provider: "letsencrypt", + challenge: "dns-01", + dns_provider: provider, + cert_path: CERT_FILE, + key_path: KEY_FILE, + expires_at: expires_at || undefined, + status: "active", + last_error: "", + auto_renew: 1, + }, + }); + + const sync = await syncCaddyIngress({ reason: "certificate issued" }); + + return { + success: true, + msg: + formatOutput({ + text: `${issue_output}\n${install_output}`, + secrets, + }) || `Certificate issued for ${hostname.value}`, + stringRes: hostname.value, + singleRes: await grabCertificateByHostname({ + hostname: hostname.value, + }), + debug: { + warnings: sync.warnings, + }, + }; + } catch (error: any) { + const stdout = error?.stdout ? String(error.stdout) : ""; + const stderr = error?.stderr ? String(error.stderr) : ""; + const msg = formatOutput({ + text: [stdout, stderr, error.message] + .filter(Boolean) + .join("\n"), + secrets, + }); + + await recordCertificate({ + certificate: { + user_id, + hostname: hostname.value, + provider: "letsencrypt", + challenge: "dns-01", + dns_provider: provider, + cert_path: CERT_FILE, + key_path: KEY_FILE, + status: "error", + last_error: msg || `Certificate issuance failed`, + auto_renew: 1, + }, + }); + + return { + success: false, + msg: msg || `Certificate issuance failed`, + stringRes: hostname.value, + singleRes: await grabCertificateByHostname({ + hostname: hostname.value, + }), + }; + } +} diff --git a/src/functions/backend/acme/manual-dns-challenges.ts b/src/functions/backend/acme/manual-dns-challenges.ts new file mode 100644 index 0000000..6589aac --- /dev/null +++ b/src/functions/backend/acme/manual-dns-challenges.ts @@ -0,0 +1,46 @@ +import type { + Authorization, + Client, + Order, +} from "acme-client"; +import type { Challenge } from "acme-client/types/rfc8555"; + +export type ManualDnsChallenge = { + route_id: number; + hostname: string; + txt_name: string; + txt_value: string; + client: Client; + order: Order; + authz: Authorization; + challenge: Challenge; + staging: boolean; + created_at: number; +}; + +const CHALLENGE_TTL_MS = 30 * 60 * 1000; +const challenges = new Map(); + +function clearExpired() { + const cutoff = Date.now() - CHALLENGE_TTL_MS; + + for (const [route_id, challenge] of challenges) { + if (challenge.created_at < cutoff) { + challenges.delete(route_id); + } + } +} + +export function storeManualDnsChallenge(challenge: ManualDnsChallenge) { + clearExpired(); + challenges.set(challenge.route_id, challenge); +} + +export function grabManualDnsChallenge(route_id: number) { + clearExpired(); + return challenges.get(route_id) || null; +} + +export function removeManualDnsChallenge(route_id: number) { + challenges.delete(route_id); +} diff --git a/src/functions/backend/acme/parse-acme-credentials.test.ts b/src/functions/backend/acme/parse-acme-credentials.test.ts new file mode 100644 index 0000000..b72de28 --- /dev/null +++ b/src/functions/backend/acme/parse-acme-credentials.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import parseAcmeCredentials from "./parse-acme-credentials"; + +describe("parseAcmeCredentials", () => { + test("parses KEY=VALUE, export and comments", () => { + const res = parseAcmeCredentials({ + text: [ + "# provider credentials", + "CF_Token=abc123", + "export CF_Account_ID=456", + 'CF_Zone="example.com"', + ].join("\n"), + }); + + expect(res.success).toBe(true); + expect(res.success && res.values).toEqual({ + CF_Token: "abc123", + CF_Account_ID: "456", + CF_Zone: "example.com", + }); + }); + + test("rejects malformed lines", () => { + expect( + parseAcmeCredentials({ text: "CF_Token=abc\nnot a credential" }) + .success, + ).toBe(false); + }); +}); diff --git a/src/functions/backend/acme/parse-acme-credentials.ts b/src/functions/backend/acme/parse-acme-credentials.ts new file mode 100644 index 0000000..cdfd39f --- /dev/null +++ b/src/functions/backend/acme/parse-acme-credentials.ts @@ -0,0 +1,58 @@ +const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +type Params = { + text?: string | null; +}; + +/** + * Parse the route form's KEY=VALUE credential text into env values. + * Accepts `export KEY=VALUE`, quotes and `#` comments. + */ +export default function parseAcmeCredentials({ text }: Params) { + const values: Record = {}; + const lines = (text || "").split(/\r?\n/); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]?.trim(); + + if (!line || line.startsWith("#")) { + continue; + } + + const match = line.match( + /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/, + ); + + if (!match?.[1] || !KEY_PATTERN.test(match[1])) { + return { + success: false as const, + msg: `Invalid credential line ${i + 1}`, + values: {}, + }; + } + + let value = (match[2] || "").trim(); + + if (!value.startsWith('"') && !value.startsWith("'")) { + const inline_comment = value.indexOf(" #"); + + if (inline_comment > -1) { + value = value.slice(0, inline_comment).trim(); + } + } + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + values[match[1]] = value; + } + + return { + success: true as const, + values, + }; +} diff --git a/src/functions/backend/acme/read-acme-env.ts b/src/functions/backend/acme/read-acme-env.ts new file mode 100644 index 0000000..d901f8e --- /dev/null +++ b/src/functions/backend/acme/read-acme-env.ts @@ -0,0 +1,76 @@ +import { existsSync, readFileSync } from "node:fs"; +import grabDirNames from "@/src/utils/grab-dir-names"; + +const { WGUI_ACME_ENV_FILE } = grabDirNames(); + +const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +export type AcmeEnv = { + values: Record; + keys: string[]; + exists: boolean; +}; + +/** + * Parse /etc/wgui/acme.env without sourcing it through a shell. Provider + * credentials stay in this object and are only passed to acme.sh via env. + */ +export default function readAcmeEnv(): AcmeEnv { + if (!existsSync(WGUI_ACME_ENV_FILE)) { + return { values: {}, keys: [], exists: false }; + } + + const values: Record = {}; + + try { + const lines = readFileSync(WGUI_ACME_ENV_FILE, "utf-8").split(/\r?\n/); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]?.trim(); + + if (!line || line.startsWith("#")) { + continue; + } + + const match = line.match( + /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/, + ); + + if (!match?.[1]) { + continue; + } + + const key = match[1]; + let value = (match[2] || "").trim(); + + if (!KEY_PATTERN.test(key)) { + continue; + } + + if (!value.startsWith('"') && !value.startsWith("'")) { + const inline_comment = value.indexOf(" #"); + + if (inline_comment > -1) { + value = value.slice(0, inline_comment).trim(); + } + } + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + values[key] = value; + } + } catch { + return { values: {}, keys: [], exists: true }; + } + + return { + values, + keys: Object.keys(values).sort(), + exists: true, + }; +} diff --git a/src/functions/backend/acme/read-certificate-expiry.ts b/src/functions/backend/acme/read-certificate-expiry.ts new file mode 100644 index 0000000..8d8c5ba --- /dev/null +++ b/src/functions/backend/acme/read-certificate-expiry.ts @@ -0,0 +1,36 @@ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; + +type Params = { + cert_path?: string | null; +}; + +export default function readCertificateExpiry({ cert_path }: Params) { + if (!cert_path || !existsSync(cert_path)) { + return null; + } + + try { + const output = execFileSync( + "openssl", + ["x509", "-in", cert_path, "-noout", "-enddate"], + { + encoding: "utf-8", + stdio: "pipe", + timeout: 10000, + }, + ).trim(); + + const match = output.match(/notAfter=(.+)$/m); + + if (!match?.[1]) { + return null; + } + + const parsed = Date.parse(match[1]); + + return Number.isNaN(parsed) ? null : parsed; + } catch { + return null; + } +} diff --git a/src/functions/backend/acme/read-route-acme-config.ts b/src/functions/backend/acme/read-route-acme-config.ts new file mode 100644 index 0000000..0cec5eb --- /dev/null +++ b/src/functions/backend/acme/read-route-acme-config.ts @@ -0,0 +1,61 @@ +import type { BUN_SQLITE_WGUI_CLIENT_ROUTES } from "@/db/types/db"; +import { TlsData } from "@/src/data/tls-data"; +import { decrypt } from "@/src/utils/crypt"; +import readAcmeEnv from "./read-acme-env"; + +type Params = { + route?: BUN_SQLITE_WGUI_CLIENT_ROUTES | null; +}; + +export type RouteAcmeConfig = { + provider: string; + email: string; + staging: boolean; + values: Record; + source: "route" | "env"; +}; + +export default async function readRouteAcmeConfig({ + route, +}: Params): Promise< + | { success: true; config: RouteAcmeConfig } + | { success: false; msg: string } +> { + const acme_env = readAcmeEnv(); + + let route_values: Record = {}; + + if (route?.acme_credentials) { + try { + const decrypted = await decrypt(route.acme_credentials); + const parsed = JSON.parse(decrypted) as Record; + route_values = parsed || {}; + } catch { + return { + success: false, + msg: `Could not decrypt the saved ACME credentials for this route`, + }; + } + } + + const provider = + route?.acme_dns_provider?.trim() || + acme_env.values.ACME_DNS_PROVIDER || + TlsData.DefaultDnsProvider; + const email = + route?.acme_email?.trim() || acme_env.values.ACME_EMAIL || ""; + const source = route?.acme_dns_provider ? "route" : "env"; + const values = + source == "route" ? route_values : acme_env.values; + + return { + success: true, + config: { + provider, + email, + staging: route?.acme_staging == 1 || TlsData.AcmeStaging, + values, + source, + }, + }; +} diff --git a/src/functions/backend/acme/record-certificate.ts b/src/functions/backend/acme/record-certificate.ts new file mode 100644 index 0000000..e8b3c11 --- /dev/null +++ b/src/functions/backend/acme/record-certificate.ts @@ -0,0 +1,55 @@ +import type { BUN_SQLITE_WGUI_TLS_CERTIFICATES } from "@/db/types/db"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { TableType } from "@/src/types"; + +type Params = { + certificate: BUN_SQLITE_WGUI_TLS_CERTIFICATES; +}; + +/** + * Upsert by hostname. `update_on_duplicate` on a non-id unique column is not + * reliable across SQLite builds, so resolve the existing row explicitly. + */ +export default async function recordCertificate({ certificate }: Params) { + if (!certificate.hostname) { + return { + success: false as const, + msg: `Certificate hostname is required`, + }; + } + + const existing_res = await BunSQLite.select< + BUN_SQLITE_WGUI_TLS_CERTIFICATES, + TableType + >({ + table: "tls_certificates", + query: { + query: { + hostname: { + value: certificate.hostname, + }, + }, + }, + }); + + const existing = existing_res.singleRes; + + if (existing?.id) { + return await BunSQLite.update< + BUN_SQLITE_WGUI_TLS_CERTIFICATES, + TableType + >({ + table: "tls_certificates", + targetId: existing.id, + data: certificate, + }); + } + + return await BunSQLite.insert< + BUN_SQLITE_WGUI_TLS_CERTIFICATES, + TableType + >({ + table: "tls_certificates", + data: [certificate], + }); +} diff --git a/src/functions/backend/acme/redact-secrets.ts b/src/functions/backend/acme/redact-secrets.ts new file mode 100644 index 0000000..f5eef86 --- /dev/null +++ b/src/functions/backend/acme/redact-secrets.ts @@ -0,0 +1,18 @@ +type Params = { + text?: string | null; + secrets: string[]; +}; + +export default function redactSecrets({ text, secrets }: Params) { + let output = text || ""; + + for (let i = 0; i < secrets.length; i++) { + const secret = secrets[i]; + + if (secret && secret.length >= 4) { + output = output.split(secret).join(`[redacted]`); + } + } + + return output; +} diff --git a/src/functions/backend/acme/seal-route-credentials.ts b/src/functions/backend/acme/seal-route-credentials.ts new file mode 100644 index 0000000..9ee51b8 --- /dev/null +++ b/src/functions/backend/acme/seal-route-credentials.ts @@ -0,0 +1,39 @@ +import { encrypt } from "@/src/utils/crypt"; +import parseAcmeCredentials from "./parse-acme-credentials"; + +type Params = { + /** Plaintext from the route form. Empty keeps the existing value. */ + text?: string | null; + /** Existing encrypted value from the database. */ + existing?: string | null; +}; + +export default async function sealRouteCredentials({ + text, + existing, +}: Params) { + if (!text?.trim()) { + return { + success: true as const, + value: existing || "", + }; + } + + const parsed = parseAcmeCredentials({ text }); + + if (!parsed.success) { + return parsed; + } + + if (!Object.keys(parsed.values).length) { + return { + success: true as const, + value: existing || "", + }; + } + + return { + success: true as const, + value: await encrypt(JSON.stringify(parsed.values)), + }; +} diff --git a/src/functions/backend/acme/start-manual-certificate.ts b/src/functions/backend/acme/start-manual-certificate.ts new file mode 100644 index 0000000..c4bca08 --- /dev/null +++ b/src/functions/backend/acme/start-manual-certificate.ts @@ -0,0 +1,140 @@ +import * as acme from "acme-client"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import type { BUN_SQLITE_WGUI_CLIENT_ROUTES } from "@/db/types/db"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { TableType } from "@/src/types"; +import validateRouteHostname from "@/src/functions/backend/caddy/validate-route-hostname"; +import grabAcmeAccountKey from "./grab-acme-account-key"; +import { + grabManualDnsChallenge, + removeManualDnsChallenge, + storeManualDnsChallenge, +} from "./manual-dns-challenges"; + +type Params = { + route_id?: number | ""; + user_id?: number | ""; +}; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export default async function startManualCertificate({ + route_id, + user_id, +}: Params): Promise { + const final_route_id = Number(route_id); + + if (!Number.isSafeInteger(final_route_id) || final_route_id < 1) { + return { success: false, msg: `Invalid route` }; + } + + const route_res = await BunSQLite.select< + BUN_SQLITE_WGUI_CLIENT_ROUTES, + TableType + >({ + table: "client_routes", + targetId: final_route_id, + }); + + const route = route_res.singleRes; + + if (!route) { + return { success: false, msg: `Route not found` }; + } + + const hostname = validateRouteHostname({ value: route.hostname }); + + if (!hostname.success) { + return { success: false, msg: hostname.msg }; + } + + const email = (route.acme_email || "").trim(); + + if (!email || !EMAIL_PATTERN.test(email)) { + return { + success: false, + msg: `Set a valid ACME email on the route form first`, + }; + } + + const staging = route.acme_staging == 1; + const directory_url = staging + ? acme.directory.letsencrypt.staging + : acme.directory.letsencrypt.production; + + try { + const previous = grabManualDnsChallenge(final_route_id); + + if (previous) { + try { + await previous.client.deactivateAuthorization(previous.authz); + } catch { + // The previous authorization may already be invalid/expired. + } + + removeManualDnsChallenge(final_route_id); + } + + const account_key = await grabAcmeAccountKey(); + const client = new acme.Client({ + directoryUrl: directory_url, + accountKey: account_key, + }); + + await client.createAccount({ + termsOfServiceAgreed: true, + contact: [`mailto:${email}`], + }); + + const order = await client.createOrder({ + identifiers: [{ type: "dns", value: hostname.value }], + }); + + const authorizations = await client.getAuthorizations(order); + const authz = authorizations[0]; + + if (!authz) { + throw new Error(`The CA did not return an authorization`); + } + + const challenge = authz.challenges.find( + (item) => item.type == "dns-01", + ); + + if (!challenge) { + throw new Error(`The CA did not offer a dns-01 challenge`); + } + + const txt_value = await client.getChallengeKeyAuthorization(challenge); + const txt_name = `_acme-challenge.${hostname.value}`; + + storeManualDnsChallenge({ + route_id: final_route_id, + hostname: hostname.value, + txt_name, + txt_value, + client, + order, + authz, + challenge, + staging, + created_at: Date.now(), + }); + + return { + success: true, + msg: `Add the TXT record at your DNS registrar, then click Validate.`, + singleRes: { + txt_name, + txt_value, + hostname: hostname.value, + staging, + }, + }; + } catch (error: any) { + return { + success: false, + msg: error.message || `Could not start certificate issuance`, + }; + } +} diff --git a/src/functions/backend/caddy/apply-caddy-ingress-firewall-rule.ts b/src/functions/backend/caddy/apply-caddy-ingress-firewall-rule.ts new file mode 100644 index 0000000..6c1adf2 --- /dev/null +++ b/src/functions/backend/caddy/apply-caddy-ingress-firewall-rule.ts @@ -0,0 +1,94 @@ +import { execFileSync } from "node:child_process"; +import { TlsData } from "@/src/data/tls-data"; +import deriveWireguardInterfaceName from "@/src/utils/derive-wireguard-interface-name"; +import deriveIptablesChainNames from "../setup/derive-iptables-chain-names"; + +type Params = { + host_id: number; + port?: number; +}; + +function iptables({ args }: { args: string[] }) { + try { + execFileSync("iptables", args, { + encoding: "utf-8", + stdio: "pipe", + timeout: 10000, + }); + + return true; + } catch { + return false; + } +} + +/** + * Allow clients of a host to reach the Caddy ingress over the tunnel. + * + * The host's generated INPUT chain ends in DROP, so the port must be accepted + * explicitly. This applies the rule immediately for already-running tunnels; + * `buildHostIptablesScripts` also writes it into the next post_up. + */ +export default function applyCaddyIngressFirewallRule({ + host_id, + port, +}: Params) { + const final_port = port || TlsData.CaddyHttpsPort; + + if ( + !Number.isSafeInteger(host_id) || + host_id < 0 || + !Number.isInteger(final_port) || + final_port < 1 || + final_port > 65535 + ) { + return { + success: false as const, + msg: `Invalid host or ingress port`, + }; + } + + const interface_name = deriveWireguardInterfaceName({ host_id }); + const { input } = deriveIptablesChainNames({ host_id }); + + // The chain only exists while the tunnel is up. The next host setup will + // regenerate the script and include the ingress rule. + if (!iptables({ args: ["-L", input, "-n"] })) { + return { + success: true as const, + msg: ``, + }; + } + + const rule_args = [ + "-i", + interface_name, + "-p", + "tcp", + "--dport", + String(final_port), + "-j", + "ACCEPT", + ]; + + if (iptables({ args: ["-C", input, ...rule_args] })) { + return { + success: true as const, + msg: ``, + }; + } + + const added = iptables({ + args: ["-I", input, "1", ...rule_args], + }); + + return added + ? { + success: true as const, + msg: ``, + } + : { + success: false as const, + msg: `Could not allow port ${final_port}/tcp on ${interface_name}`, + }; +} diff --git a/src/functions/backend/caddy/build-caddyfile.test.ts b/src/functions/backend/caddy/build-caddyfile.test.ts new file mode 100644 index 0000000..0727d53 --- /dev/null +++ b/src/functions/backend/caddy/build-caddyfile.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; +import type { + BUN_SQLITE_WGUI_CLIENT_ROUTES, + BUN_SQLITE_WGUI_TLS_CERTIFICATES, +} from "@/db/types/db"; +import buildCaddyfile from "./build-caddyfile"; + +const base = { + https_port: 8443, + http_port: 8081, + admin_socket: `/run/wgui-caddy/admin.sock`, + storage_dir: `/var/lib/wgui/caddy/data`, +}; + +const certificate = { + hostname: "media.example.com", + cert_path: "/var/lib/wgui/tls/media.example.com/fullchain.pem", + key_path: "/var/lib/wgui/tls/media.example.com/privkey.pem", + status: "active", +} as BUN_SQLITE_WGUI_TLS_CERTIFICATES; + +function route( + values: Partial, +): BUN_SQLITE_WGUI_CLIENT_ROUTES { + return { + hostname: "media.example.com", + path_prefix: "/", + upstream_url: "http://10.5.0.20:8080", + enabled: 1, + ...values, + }; +} + +describe("buildCaddyfile", () => { + test("uses a high port and never 80/443", () => { + const res = buildCaddyfile({ ...base, routes: [], certificates: [] }); + + expect(res.config).toContain("https_port 8443"); + expect(res.config).toContain("http_port 8081"); + expect(res.config).toContain("auto_https disable_redirects"); + expect(res.config).not.toContain("https_port 443"); + expect(res.config).not.toContain("http_port 80\n"); + }); + + test("orders longer prefixes before the catch-all", () => { + const res = buildCaddyfile({ + ...base, + certificates: [certificate], + routes: [ + route({ name: "root", path_prefix: "/" }), + route({ + name: "app", + path_prefix: "/app", + strip_prefix: 1, + upstream_url: "http://10.5.0.30:9000", + }), + ], + }); + + const app_index = res.config?.indexOf("@route_1 {") || 0; + const root_index = res.config?.indexOf("handle {") || 0; + + expect(app_index).toBeGreaterThan(0); + expect(root_index).toBeGreaterThan(app_index); + expect(res.config).toContain("path /app /app/*"); + expect(res.config).toContain("uri strip_prefix /app"); + }); + + test("skips routes without an active certificate", () => { + const res = buildCaddyfile({ + ...base, + certificates: [], + routes: [route({ name: "media" })], + }); + + expect(res.config).not.toContain("media.example.com {"); + expect(res.warnings[0]).toContain("certificate"); + }); + + test("scopes a route to its host's WireGuard subnet", () => { + const res = buildCaddyfile({ + ...base, + certificates: [certificate], + hosts: [{ id: 0, wg_ip_address: "10.5.0.1" }], + routes: [route({ host_id: 0, path_prefix: "/app" })], + }); + + expect(res.config).toContain("remote_ip 10.5.0.0/24"); + }); + + test("maps insecure upstream TLS to Caddy", () => { + const res = buildCaddyfile({ + ...base, + certificates: [certificate], + routes: [ + route({ + upstream_url: "https://10.5.0.20:8443", + upstream_tls_verify: 0, + }), + ], + }); + + expect(res.config).toContain("tls_insecure_skip_verify"); + }); +}); diff --git a/src/functions/backend/caddy/build-caddyfile.ts b/src/functions/backend/caddy/build-caddyfile.ts new file mode 100644 index 0000000..8c70141 --- /dev/null +++ b/src/functions/backend/caddy/build-caddyfile.ts @@ -0,0 +1,277 @@ +import type { + BUN_SQLITE_WGUI_CLIENT_ROUTES, + BUN_SQLITE_WGUI_HOSTS, + BUN_SQLITE_WGUI_TLS_CERTIFICATES, +} from "@/db/types/db"; +import validateRouteHostname from "./validate-route-hostname"; +import validateRoutePath from "./validate-route-path"; +import validateUpstreamUrl from "./validate-upstream-url"; + +export type CaddyBuildResult = { + success: boolean; + msg?: string; + config?: string; + warnings: string[]; +}; + +type Params = { + routes?: BUN_SQLITE_WGUI_CLIENT_ROUTES[]; + certificates?: BUN_SQLITE_WGUI_TLS_CERTIFICATES[]; + hosts?: BUN_SQLITE_WGUI_HOSTS[]; + https_port: number; + http_port: number; + admin_socket: string; + storage_dir: string; +}; + +function reverseProxyBlock({ + route, + indent, +}: { + route: BUN_SQLITE_WGUI_CLIENT_ROUTES; + indent: string; +}) { + const upstream = route.upstream_url || ""; + const lines: string[] = []; + + if (upstream.startsWith("https:") && route.upstream_tls_verify == 0) { + lines.push(`${indent}reverse_proxy ${upstream} {`); + lines.push(`${indent} transport http {`); + lines.push(`${indent} tls_insecure_skip_verify`); + lines.push(`${indent} }`); + lines.push(`${indent}}`); + } else { + lines.push(`${indent}reverse_proxy ${upstream}`); + } + + return lines; +} + +function routeBlock({ + route, + index, + subnet, +}: { + route: BUN_SQLITE_WGUI_CLIENT_ROUTES; + index: number; + subnet?: string; +}) { + const path_prefix = route.path_prefix || "/"; + const matcher = `route_${index}`; + const lines: string[] = []; + + if (path_prefix == "/" && !subnet) { + lines.push(` handle {`); + lines.push(...reverseProxyBlock({ route, indent: " " })); + lines.push(` }`); + return lines; + } + + if (path_prefix == "/") { + lines.push( + ` @${matcher} remote_ip ${subnet} 127.0.0.1/32 ::1/128`, + ); + } else { + lines.push(` @${matcher} {`); + lines.push(` path ${path_prefix} ${path_prefix}/*`); + + if (subnet) { + lines.push( + ` remote_ip ${subnet} 127.0.0.1/32 ::1/128`, + ); + } + + lines.push(` }`); + } + + lines.push(` handle @${matcher} {`); + + if (path_prefix != "/" && route.strip_prefix == 1) { + lines.push(` uri strip_prefix ${path_prefix}`); + } + + lines.push(...reverseProxyBlock({ route, indent: " " })); + lines.push(` }`); + + return lines; +} + +/** + * Build a Caddyfile for the enabled routes. Routes without an active + * certificate are skipped and reported as warnings instead of being served + * with an untrusted fallback certificate. + */ +export default function buildCaddyfile({ + routes, + certificates, + hosts, + https_port, + http_port, + admin_socket, + storage_dir, +}: Params): CaddyBuildResult { + const warnings: string[] = []; + + const subnet_by_host_id = new Map(); + + for (let i = 0; i < (hosts || []).length; i++) { + const host = hosts?.[i]; + const parts = (host?.wg_ip_address || "").trim().split("."); + + if ( + host?.id !== undefined && + parts.length == 4 && + parts.every((part) => /^\d{1,3}$/.test(part)) + ) { + subnet_by_host_id.set( + Number(host.id), + `${parts.slice(0, 3).join(".")}.0/24`, + ); + } + } + const certificates_by_hostname = new Map< + string, + BUN_SQLITE_WGUI_TLS_CERTIFICATES + >(); + + for (let i = 0; i < (certificates || []).length; i++) { + const certificate = certificates?.[i]; + const hostname = certificate?.hostname?.trim().toLowerCase(); + + if ( + hostname && + certificate?.cert_path && + certificate.key_path && + (certificate.status == "active" || + certificate.status == "expiring") + ) { + certificates_by_hostname.set(hostname, { + ...certificate, + hostname, + }); + } + } + + const routes_by_hostname = new Map< + string, + { + route: BUN_SQLITE_WGUI_CLIENT_ROUTES; + subnet?: string; + }[] + >(); + + for (let i = 0; i < (routes || []).length; i++) { + const route = routes?.[i]; + + if (!route?.hostname || !route.upstream_url || route.enabled == 0) { + continue; + } + + const hostname = validateRouteHostname({ value: route.hostname }); + const path_prefix = validateRoutePath({ + value: route.path_prefix, + }); + const upstream = validateUpstreamUrl({ + value: route.upstream_url, + }); + + if (!hostname.success || !path_prefix.success || !upstream.success) { + warnings.push( + `Route ${route.name || route.hostname} has invalid settings and was skipped`, + ); + continue; + } + + if (!certificates_by_hostname.has(hostname.value)) { + warnings.push( + `Route ${route.name || hostname.value} is skipped until its certificate is issued`, + ); + continue; + } + + const safe_hostname = hostname.value || ""; + const safe_route: BUN_SQLITE_WGUI_CLIENT_ROUTES = { + ...route, + hostname: safe_hostname, + path_prefix: path_prefix.value || "/", + upstream_url: upstream.value || "", + }; + + const subnet = subnet_by_host_id.get(Number(route.host_id || 0)); + + if (hosts?.length && !subnet) { + warnings.push( + `Route ${route.name || safe_hostname} is skipped because its host has no WireGuard address`, + ); + continue; + } + + const existing = routes_by_hostname.get(safe_hostname) || []; + existing.push({ route: safe_route, subnet }); + routes_by_hostname.set(safe_hostname, existing); + } + + const lines: string[] = [ + `{`, + ` https_port ${https_port}`, + ` http_port ${http_port}`, + ` auto_https disable_redirects`, + ` admin unix/${admin_socket}`, + ` storage file_system ${storage_dir}`, + `}`, + ``, + ]; + + const hostnames = [...routes_by_hostname.keys()].sort(); + + for (let h = 0; h < hostnames.length; h++) { + const hostname = hostnames[h]; + + if (!hostname) continue; + + const certificate = certificates_by_hostname.get(hostname); + const host_routes = routes_by_hostname.get(hostname) || []; + + if (!certificate) { + continue; + } + + host_routes.sort((a, b) => { + const a_path = a.route.path_prefix || "/"; + const b_path = b.route.path_prefix || "/"; + + if (a_path == "/") return 1; + if (b_path == "/") return -1; + + return b_path.length - a_path.length; + }); + + lines.push(`${hostname} {`); + lines.push( + ` tls "${certificate.cert_path}" "${certificate.key_path}"`, + ); + + for (let r = 0; r < host_routes.length; r++) { + const entry = host_routes[r]; + + if (!entry) continue; + + lines.push( + ...routeBlock({ + route: entry.route, + subnet: entry.subnet, + index: r + 1, + }), + ); + } + + lines.push(`}`); + lines.push(``); + } + + return { + success: true, + config: lines.join("\n"), + warnings, + }; +} diff --git a/src/functions/backend/caddy/grab-caddy-status.ts b/src/functions/backend/caddy/grab-caddy-status.ts new file mode 100644 index 0000000..9f5a91c --- /dev/null +++ b/src/functions/backend/caddy/grab-caddy-status.ts @@ -0,0 +1,94 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import grabDirNames from "@/src/utils/grab-dir-names"; +import { TlsData } from "@/src/data/tls-data"; +import grabSystemSetupStatus from "@/src/functions/backend/setup/grab-system-setup-status"; + +const { + WGUI_CADDY_BINARY, + WGUI_LIB_CADDY_CONFIG_FILE, + WGUI_LIB_CADDY_STATE_FILE, + WGUI_ACME_SCRIPT, + WGUI_ACME_ENV_FILE, +} = grabDirNames(); + +function readState() { + try { + if (!existsSync(WGUI_LIB_CADDY_STATE_FILE)) { + return null; + } + + return JSON.parse( + readFileSync(WGUI_LIB_CADDY_STATE_FILE, "utf-8"), + ) as { + last_reload_at?: number; + last_error?: string | null; + warnings?: string[]; + }; + } catch { + return null; + } +} + +function version() { + try { + return execFileSync(WGUI_CADDY_BINARY, ["version"], { + encoding: "utf-8", + stdio: "pipe", + timeout: 5000, + }).trim(); + } catch { + return null; + } +} + +function serviceActive() { + const setup_status = grabSystemSetupStatus(); + + try { + if (setup_status.init_system == "systemd") { + return ( + execFileSync( + "systemctl", + ["is-active", "wgui-caddy"], + { + encoding: "utf-8", + stdio: "pipe", + timeout: 5000, + }, + ).trim() == "active" + ); + } + + if (setup_status.init_system == "openrc") { + execFileSync("rc-service", ["wgui-caddy", "status"], { + encoding: "utf-8", + stdio: "pipe", + timeout: 5000, + }); + + return true; + } + } catch { + return false; + } + + return null; +} + +export default function grabCaddyStatus() { + const state = readState(); + + return { + installed: existsSync(WGUI_CADDY_BINARY), + service_active: serviceActive(), + acme_installed: existsSync(WGUI_ACME_SCRIPT), + acme_env_exists: existsSync(WGUI_ACME_ENV_FILE), + version: version(), + https_port: TlsData.CaddyHttpsPort, + http_port: TlsData.CaddyHttpPort, + enabled: TlsData.IngressEnabled, + config_path: WGUI_LIB_CADDY_CONFIG_FILE, + state, + }; +} diff --git a/src/functions/backend/caddy/grab-cert-dir-names.ts b/src/functions/backend/caddy/grab-cert-dir-names.ts new file mode 100644 index 0000000..d212c20 --- /dev/null +++ b/src/functions/backend/caddy/grab-cert-dir-names.ts @@ -0,0 +1,17 @@ +import path from "node:path"; +import grabDirNames from "@/src/utils/grab-dir-names"; + +type Params = { + hostname: string; +}; + +export default function grabCertDirNames({ hostname }: Params) { + const { WGUI_LIB_TLS_DIR } = grabDirNames(); + const CERT_DIR = path.join(WGUI_LIB_TLS_DIR, hostname); + + return { + CERT_DIR, + CERT_FILE: path.join(CERT_DIR, "fullchain.pem"), + KEY_FILE: path.join(CERT_DIR, "privkey.pem"), + }; +} diff --git a/src/functions/backend/caddy/reload-caddy-service.ts b/src/functions/backend/caddy/reload-caddy-service.ts new file mode 100644 index 0000000..56d46fa --- /dev/null +++ b/src/functions/backend/caddy/reload-caddy-service.ts @@ -0,0 +1,86 @@ +import { execFileSync } from "node:child_process"; +import grabSystemSetupStatus from "@/src/functions/backend/setup/grab-system-setup-status"; + +const CADDY_SERVICE_NAME = "wgui-caddy"; + +function run({ command, args }: { command: string; args: string[] }) { + try { + const output = execFileSync(command, args, { + encoding: "utf-8", + stdio: "pipe", + timeout: 30000, + }).trim(); + + return { + success: true as const, + msg: output, + }; + } catch (error: any) { + const stdout = error?.stdout ? String(error.stdout).trim() : ""; + const stderr = error?.stderr ? String(error.stderr).trim() : ""; + + return { + success: false as const, + msg: [stdout, stderr, error.message] + .filter(Boolean) + .join("\n") + .trim(), + }; + } +} + +function caddyServiceIsActive({ + init_system, +}: { + init_system: ReturnType["init_system"]; +}) { + if (init_system == "systemd") { + const status = run({ + command: "systemctl", + args: ["is-active", CADDY_SERVICE_NAME], + }); + + return status.success && status.msg == "active"; + } + + if (init_system == "openrc") { + return run({ + command: "rc-service", + args: [CADDY_SERVICE_NAME, "status"], + }).success; + } + + return false; +} + +export default function reloadCaddyService() { + const setup_status = grabSystemSetupStatus(); + const service_active = caddyServiceIsActive({ + init_system: setup_status.init_system, + }); + + if (setup_status.is_dev && !service_active) { + return { + success: true as const, + msg: `Development mode — Caddy service is not running, reload skipped`, + }; + } + + switch (setup_status.init_system) { + case "systemd": + return run({ + command: "systemctl", + args: ["reload", CADDY_SERVICE_NAME], + }); + case "openrc": + return run({ + command: "rc-service", + args: [CADDY_SERVICE_NAME, "reload"], + }); + default: + return { + success: false as const, + msg: `No supported init system found for wgui-caddy`, + }; + } +} diff --git a/src/functions/backend/caddy/sanitize-client-route.ts b/src/functions/backend/caddy/sanitize-client-route.ts new file mode 100644 index 0000000..94cf58a --- /dev/null +++ b/src/functions/backend/caddy/sanitize-client-route.ts @@ -0,0 +1,98 @@ +import type { BUN_SQLITE_WGUI_CLIENT_ROUTES } from "@/db/types/db"; +import validateRouteHostname from "./validate-route-hostname"; +import validateRoutePath from "./validate-route-path"; +import validateUpstreamUrl from "./validate-upstream-url"; + +const PROVIDER_PATTERN = /^dns_[a-z0-9_]+$/; +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +type Params = { + route?: BUN_SQLITE_WGUI_CLIENT_ROUTES | null; + user_id?: number | ""; + host_id?: number | ""; +}; + +export default function sanitizeClientRoute({ + route, + user_id, + host_id, +}: Params) { + if (!route) { + return { + success: false as const, + msg: `No route provided`, + }; + } + + const hostname = validateRouteHostname({ value: route.hostname }); + + if (!hostname.success) { + return hostname; + } + + const path_prefix = validateRoutePath({ value: route.path_prefix }); + + if (!path_prefix.success) { + return path_prefix; + } + + const upstream = validateUpstreamUrl({ value: route.upstream_url }); + + if (!upstream.success) { + return upstream; + } + + const final_host_id = Number(route.host_id ?? host_id ?? 0); + + if (!Number.isSafeInteger(final_host_id) || final_host_id < 0) { + return { + success: false as const, + msg: `Invalid host`, + }; + } + + const acme_provider = + typeof route.acme_dns_provider == "string" + ? route.acme_dns_provider.trim() + : ""; + const acme_email = + typeof route.acme_email == "string" ? route.acme_email.trim() : ""; + + if (acme_provider && !PROVIDER_PATTERN.test(acme_provider)) { + return { + success: false as const, + msg: `Invalid DNS provider "${acme_provider}"`, + }; + } + + if (acme_email && !EMAIL_PATTERN.test(acme_email)) { + return { + success: false as const, + msg: `Invalid ACME email "${acme_email}"`, + }; + } + + return { + success: true as const, + route: { + ...route, + user_id: route.user_id || user_id, + host_id: final_host_id, + name: (route.name || hostname.value).trim().slice(0, 120), + hostname: hostname.value, + path_prefix: path_prefix.value, + strip_prefix: route.strip_prefix == 1 ? 1 : 0, + upstream_url: upstream.value, + upstream_tls_verify: route.upstream_tls_verify == 0 ? 0 : 1, + enabled: route.enabled == 0 ? 0 : 1, + notes: (route.notes || "").trim(), + acme_email, + acme_dns_provider: acme_provider, + acme_staging: route.acme_staging == 1 ? 1 : 0, + acme_credentials: + typeof route.acme_credentials == "string" + ? route.acme_credentials + : "", + } satisfies BUN_SQLITE_WGUI_CLIENT_ROUTES, + }; +} diff --git a/src/functions/backend/caddy/sync-caddy-ingress.ts b/src/functions/backend/caddy/sync-caddy-ingress.ts new file mode 100644 index 0000000..7d2f39a --- /dev/null +++ b/src/functions/backend/caddy/sync-caddy-ingress.ts @@ -0,0 +1,255 @@ +import type { + BUN_SQLITE_WGUI_CLIENT_ROUTES, + BUN_SQLITE_WGUI_HOSTS, + BUN_SQLITE_WGUI_TLS_CERTIFICATES, +} from "@/db/types/db"; +import { + copyFileSync, + existsSync, + mkdirSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { TableType } from "@/src/types"; +import grabDirNames from "@/src/utils/grab-dir-names"; +import { TlsData } from "@/src/data/tls-data"; +import buildCaddyfile from "./build-caddyfile"; +import reloadCaddyService from "./reload-caddy-service"; +import applyCaddyIngressFirewallRule from "./apply-caddy-ingress-firewall-rule"; + +export type CaddySyncResult = { + success: boolean; + msg?: string; + warnings: string[]; +}; + +type Params = { + reason?: string; +}; + +const { + WGUI_CADDY_BINARY, + WGUI_LIB_CADDY_DIR, + WGUI_LIB_CADDY_CONFIG_FILE, + WGUI_LIB_CADDY_STATE_FILE, + WGUI_LIB_CADDY_DATA_DIR, + WGUI_LIB_CADDY_ADMIN_SOCKET, +} = grabDirNames(); + +let sync_chain: Promise = Promise.resolve({ + success: true, + warnings: [], +}); + +function writeState({ + warnings, + last_error, +}: { + warnings: string[]; + last_error?: string | null; +}) { + try { + writeFileSync( + WGUI_LIB_CADDY_STATE_FILE, + JSON.stringify( + { + last_reload_at: Date.now(), + last_error: last_error || null, + warnings, + }, + null, + 4, + ), + { mode: 0o644 }, + ); + } catch { + // The config is still valid; state is only for the setup page. + } +} + +async function doSync({ reason }: Params): Promise { + if (!TlsData.IngressEnabled) { + return { + success: true, + warnings: [], + msg: `Caddy ingress is disabled`, + }; + } + + const routes_res = await BunSQLite.select< + BUN_SQLITE_WGUI_CLIENT_ROUTES, + TableType + >({ + table: "client_routes", + }); + + const certificates_res = await BunSQLite.select< + BUN_SQLITE_WGUI_TLS_CERTIFICATES, + TableType + >({ + table: "tls_certificates", + }); + + const hosts_res = await BunSQLite.select({ + table: "hosts", + }); + + const routes = routes_res.payload || []; + const hosts = hosts_res.payload || []; + const certificates = (certificates_res.payload || []) + .filter( + (certificate) => + Boolean( + certificate.cert_path && + certificate.key_path && + existsSync(certificate.cert_path) && + existsSync(certificate.key_path), + ), + ) + .map((certificate) => ({ + ...certificate, + // A valid file on disk is enough to serve; the setup page + // separately reports expiry and errors. + status: + certificate.status == "issuing" + ? ("issuing" as const) + : ("active" as const), + })); + + const built = buildCaddyfile({ + routes, + certificates, + hosts, + https_port: TlsData.CaddyHttpsPort, + http_port: TlsData.CaddyHttpPort, + admin_socket: WGUI_LIB_CADDY_ADMIN_SOCKET, + storage_dir: WGUI_LIB_CADDY_DATA_DIR, + }); + + if (!built.success || !built.config) { + writeState({ + warnings: built.warnings, + last_error: built.msg || `Could not build the Caddyfile`, + }); + + return { + success: false, + msg: built.msg || `Could not build the Caddyfile`, + warnings: built.warnings, + }; + } + + mkdirSync(WGUI_LIB_CADDY_DIR, { recursive: true }); + mkdirSync(WGUI_LIB_CADDY_DATA_DIR, { recursive: true }); + + const tmp_path = path.join(WGUI_LIB_CADDY_DIR, `Caddyfile.tmp`); + const backup_path = path.join(WGUI_LIB_CADDY_DIR, `Caddyfile.last-good`); + + writeFileSync(tmp_path, built.config, { mode: 0o644 }); + + if (existsSync(WGUI_CADDY_BINARY)) { + try { + execFileSync( + WGUI_CADDY_BINARY, + [ + "validate", + "--config", + tmp_path, + "--adapter", + "caddyfile", + ], + { + encoding: "utf-8", + stdio: "pipe", + timeout: 30000, + }, + ); + } catch (error: any) { + rmSync(tmp_path, { force: true }); + const msg = `Caddy rejected the generated config: ${ + error?.stderr + ? String(error.stderr).trim() + : error.message + }`; + + writeState({ warnings: built.warnings, last_error: msg }); + + return { + success: false, + msg, + warnings: built.warnings, + }; + } + } + + if (existsSync(WGUI_LIB_CADDY_CONFIG_FILE)) { + copyFileSync(WGUI_LIB_CADDY_CONFIG_FILE, backup_path); + } + + renameSync(tmp_path, WGUI_LIB_CADDY_CONFIG_FILE); + + const reload = reloadCaddyService(); + const warnings = [...built.warnings]; + + if (!reload.success) { + warnings.push(`Caddy reload failed: ${reload.msg}`); + } + + writeState({ + warnings, + last_error: reload.success ? null : reload.msg, + }); + + const host_ids = [ + ...new Set( + routes + .filter((route) => route.enabled != 0) + .map((route) => Number(route.host_id || 0)) + .filter((host_id) => host_id >= 0), + ), + ]; + + for (let i = 0; i < host_ids.length; i++) { + const host_id = host_ids[i]; + + if (typeof host_id != "number") continue; + + const firewall = applyCaddyIngressFirewallRule({ host_id }); + + if (!firewall.success && firewall.msg) { + warnings.push(firewall.msg); + } + } + + return { + success: reload.success, + msg: reload.success + ? `Caddy ingress synced (${reason || "manual"})` + : reload.msg, + warnings, + }; +} + +/** + * Serialize config writes. Route/cert mutations can arrive concurrently while + * Caddy is reloading; the last committed config must always be a complete one. + */ +export default function syncCaddyIngress( + params?: Params, +): Promise { + const run = sync_chain.then( + () => doSync(params || {}), + () => doSync(params || {}), + ); + + sync_chain = run.then( + () => ({ success: true, warnings: [] }), + () => ({ success: true, warnings: [] }), + ); + + return run; +} diff --git a/src/functions/backend/caddy/validate-route-hostname.test.ts b/src/functions/backend/caddy/validate-route-hostname.test.ts new file mode 100644 index 0000000..98aa501 --- /dev/null +++ b/src/functions/backend/caddy/validate-route-hostname.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import validateRouteHostname from "./validate-route-hostname"; + +describe("validateRouteHostname", () => { + test("accepts and lowercases a DNS name", () => { + const res = validateRouteHostname({ value: "Media.Example.COM" }); + + expect(res.success).toBe(true); + expect(res.success && res.value).toBe("media.example.com"); + }); + + test("rejects schemes, ports, wildcards and injection", () => { + for (const value of [ + "https://media.example.com", + "media.example.com:8443", + "*.example.com", + "media.example.com\nhandle", + "media example.com", + "", + ]) { + expect(validateRouteHostname({ value }).success).toBe(false); + } + }); +}); diff --git a/src/functions/backend/caddy/validate-route-hostname.ts b/src/functions/backend/caddy/validate-route-hostname.ts new file mode 100644 index 0000000..32210fc --- /dev/null +++ b/src/functions/backend/caddy/validate-route-hostname.ts @@ -0,0 +1,34 @@ +const HOSTNAME_PATTERN = + /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])$/; + +type Params = { + value?: string | null; +}; + +/** + * Route hostnames are used as Caddy site addresses and certificate names. + * Keep them to plain lowercase DNS labels: no wildcards, ports, schemes, + * spaces or Caddyfile control characters. + */ +export default function validateRouteHostname({ value }: Params) { + const hostname = (value || "").trim().toLowerCase(); + + if (!hostname) { + return { + success: false as const, + msg: `Hostname is required`, + }; + } + + if (!HOSTNAME_PATTERN.test(hostname)) { + return { + success: false as const, + msg: `Invalid hostname "${hostname}"`, + }; + } + + return { + success: true as const, + value: hostname, + }; +} diff --git a/src/functions/backend/caddy/validate-route-path.test.ts b/src/functions/backend/caddy/validate-route-path.test.ts new file mode 100644 index 0000000..a8d2c82 --- /dev/null +++ b/src/functions/backend/caddy/validate-route-path.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; +import validateRoutePath from "./validate-route-path"; + +describe("validateRoutePath", () => { + test("normalizes prefixes", () => { + expect(validateRoutePath({ value: "" })).toEqual({ + success: true, + value: "/", + }); + expect(validateRoutePath({ value: "app/" })).toEqual({ + success: true, + value: "/app", + }); + expect(validateRoutePath({ value: "//app//media/" })).toEqual({ + success: true, + value: "/app/media", + }); + }); + + test("rejects traversal, whitespace and Caddyfile characters", () => { + for (const value of ["/../etc", "/app/{x}", "/app x", "/app\nhandle"]) { + expect(validateRoutePath({ value }).success).toBe(false); + } + }); +}); diff --git a/src/functions/backend/caddy/validate-route-path.ts b/src/functions/backend/caddy/validate-route-path.ts new file mode 100644 index 0000000..494d7e8 --- /dev/null +++ b/src/functions/backend/caddy/validate-route-path.ts @@ -0,0 +1,43 @@ +type Params = { + value?: string | null; +}; + +/** + * Normalize a route path prefix to `/`, `/app` or `/app/nested`. + * Rejects traversal, whitespace and Caddyfile control characters. + */ +export default function validateRoutePath({ value }: Params) { + let path_prefix = (value || "/").trim(); + + if (!path_prefix.startsWith("/")) { + path_prefix = `/${path_prefix}`; + } + + path_prefix = path_prefix.replace(/\/{2,}/g, "/").replace(/\/$/, "") || "/"; + + if (path_prefix.length > 512) { + return { + success: false as const, + msg: `Path prefix is too long`, + }; + } + + if (/[\s?#{}\\'"`]/.test(path_prefix)) { + return { + success: false as const, + msg: `Path prefix contains invalid characters`, + }; + } + + if (path_prefix.split("/").some((part) => part == ".." || part == ".")) { + return { + success: false as const, + msg: `Path prefix cannot contain "." or ".."`, + }; + } + + return { + success: true as const, + value: path_prefix, + }; +} diff --git a/src/functions/backend/caddy/validate-upstream-url.test.ts b/src/functions/backend/caddy/validate-upstream-url.test.ts new file mode 100644 index 0000000..d3df998 --- /dev/null +++ b/src/functions/backend/caddy/validate-upstream-url.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import validateUpstreamUrl from "./validate-upstream-url"; + +describe("validateUpstreamUrl", () => { + test("accepts bare http and https origins", () => { + expect(validateUpstreamUrl({ value: "http://10.5.0.20:8080" })).toEqual({ + success: true, + value: "http://10.5.0.20:8080", + secure: false, + }); + + expect(validateUpstreamUrl({ value: "https://app.internal" })).toEqual({ + success: true, + value: "https://app.internal", + secure: true, + }); + }); + + test("rejects paths, credentials, queries and non-http schemes", () => { + for (const value of [ + "http://10.5.0.20:8080/app", + "http://user:pass@10.5.0.20:8080", + "http://10.5.0.20:8080?x=1", + "ftp://10.5.0.20:8080", + "http://10.5.0.20:8080\nhandle", + "", + ]) { + expect(validateUpstreamUrl({ value }).success).toBe(false); + } + }); +}); diff --git a/src/functions/backend/caddy/validate-upstream-url.ts b/src/functions/backend/caddy/validate-upstream-url.ts new file mode 100644 index 0000000..06b9bf4 --- /dev/null +++ b/src/functions/backend/caddy/validate-upstream-url.ts @@ -0,0 +1,83 @@ +type Params = { + value?: string | null; +}; + +const CONTROL_CHARS = /[\u0000-\u001f\u007f]/; + +/** + * Upstreams must be a bare http(s) origin. Paths, credentials, queries and + * fragments are rejected so joining a route path can never escape the origin. + */ +export default function validateUpstreamUrl({ value }: Params) { + const raw = (value || "").trim(); + + if (!raw) { + return { + success: false as const, + msg: `Upstream URL is required`, + }; + } + + if (raw.length > 2048 || CONTROL_CHARS.test(raw)) { + return { + success: false as const, + msg: `Invalid upstream URL`, + }; + } + + let url: URL; + + try { + url = new URL(raw); + } catch { + return { + success: false as const, + msg: `Invalid upstream URL`, + }; + } + + if (url.protocol != "http:" && url.protocol != "https:") { + return { + success: false as const, + msg: `Upstream URL must use http or https`, + }; + } + + if (url.username || url.password) { + return { + success: false as const, + msg: `Upstream URL cannot contain credentials`, + }; + } + + if ( + url.pathname != "/" || + url.search || + url.hash || + !url.hostname + ) { + return { + success: false as const, + msg: `Upstream URL must be a bare origin, e.g. http://10.0.0.5:8080`, + }; + } + + const port = url.port + ? Number(url.port) + : url.protocol == "https:" + ? 443 + : 80; + + if (!Number.isInteger(port) || port < 1 || port > 65535) { + return { + success: false as const, + msg: `Invalid upstream port`, + }; + } + + return { + success: true as const, + value: `${url.protocol}//${url.host}`, + secure: url.protocol == "https:", + }; +} diff --git a/src/functions/backend/db/clients/grab-clients.ts b/src/functions/backend/db/clients/grab-clients.ts index 13d0b00..b1e86bf 100644 --- a/src/functions/backend/db/clients/grab-clients.ts +++ b/src/functions/backend/db/clients/grab-clients.ts @@ -52,11 +52,18 @@ export default async function grabClients( sql += `LEFT JOIN hosts h ON c.host_id = h.id\n`; sql += `WHERE 1=1\n`; - const target_host_id = params?.host_id || query?.host_id; + const target_host_id = + params?.host_id !== undefined + ? params.host_id + : query?.host_id; const target_user_id = params?.user_id || query?.user_id; const target_client_id = params?.client_id || query?.client_id || query?.id; - if (target_host_id) { + if ( + target_host_id !== undefined && + target_host_id !== null && + target_host_id !== "" + ) { sql += ` AND c.host_id=?\n`; values.push(target_host_id); } diff --git a/src/functions/backend/setup/build-host-iptables-scripts.test.ts b/src/functions/backend/setup/build-host-iptables-scripts.test.ts index f9b4cb2..c546a2f 100644 --- a/src/functions/backend/setup/build-host-iptables-scripts.test.ts +++ b/src/functions/backend/setup/build-host-iptables-scripts.test.ts @@ -455,6 +455,30 @@ describe("buildHostIptablesScripts", () => { ); }); + test("allows the TLS ingress port from the wireguard interface", () => { + const res = buildHostIptablesScripts({ + ...base, + clients: [], + ingress_ports: [8443], + }); + + expect(res.success).toBe(true); + expect(res.post_up).toContain( + "iptables -A WGUI0IN -i wgui0 -p tcp --dport 8443 -j ACCEPT", + ); + }); + + test("rejects an invalid ingress port", () => { + const res = buildHostIptablesScripts({ + ...base, + clients: [], + ingress_ports: [70000], + }); + + expect(res.success).toBe(false); + expect(res.msg).toBe("Invalid ingress port"); + }); + test("rejects oversized host id", () => { const res = buildHostIptablesScripts({ ...base, diff --git a/src/functions/backend/setup/build-host-iptables-scripts.ts b/src/functions/backend/setup/build-host-iptables-scripts.ts index f3ca6e3..c331114 100644 --- a/src/functions/backend/setup/build-host-iptables-scripts.ts +++ b/src/functions/backend/setup/build-host-iptables-scripts.ts @@ -19,6 +19,8 @@ type Params = { host_subnet?: string; sibling_subnets?: string[]; clients?: HostIptablesClient[]; + /** Host-level TCP ports clients may reach (e.g. the Caddy ingress). */ + ingress_ports?: number[]; }; const SAFE_IFACE = /^[A-Za-z][A-Za-z0-9._-]*$/; @@ -148,6 +150,7 @@ export default function buildHostIptablesScripts({ host_subnet, sibling_subnets, clients, + ingress_ports, }: Params) { if (!Number.isSafeInteger(host_id) || host_id < 0 || host_id > 999999999) { return { @@ -184,6 +187,21 @@ export default function buildHostIptablesScripts({ const accept_lines: string[] = []; const host_clients = clients || []; + for (let i = 0; i < (ingress_ports || []).length; i++) { + const port = ingress_ports?.[i]; + + if (!Number.isInteger(port) || Number(port) < 1 || Number(port) > 65535) { + return { + success: false, + msg: `Invalid ingress port`, + }; + } + + accept_lines.push( + `iptables -A ${input} -i ${interface_name} -p tcp --dport ${port} -j ACCEPT`, + ); + } + for (let i = 0; i < host_clients.length; i++) { const client = host_clients[i]; diff --git a/src/functions/backend/setup/grab-system-setup-status.ts b/src/functions/backend/setup/grab-system-setup-status.ts index 7e5967f..96141e3 100644 --- a/src/functions/backend/setup/grab-system-setup-status.ts +++ b/src/functions/backend/setup/grab-system-setup-status.ts @@ -84,6 +84,11 @@ const TOOL_CHECKS: { command: "bun", version_command: "bun --version", }, + { + name: "caddy", + command: "/opt/caddy/caddy", + version_command: "/opt/caddy/caddy version", + }, ]; const tryShell = ({ diff --git a/src/functions/backend/setup/read-wireguard-client-config.ts b/src/functions/backend/setup/read-wireguard-client-config.ts index b5aaa3a..ce768d9 100644 --- a/src/functions/backend/setup/read-wireguard-client-config.ts +++ b/src/functions/backend/setup/read-wireguard-client-config.ts @@ -21,7 +21,10 @@ export default async function readWireguardClientConfig({ } const { HOST_CLIENTS_DIR } = grabHostDirnames({ - host: host_id ? { id: Number(host_id) } : undefined, + host: + host_id !== undefined && host_id !== "" + ? { id: Number(host_id) } + : undefined, }); const config_path = path.join( diff --git a/src/functions/backend/setup/setup-wireguard-host.ts b/src/functions/backend/setup/setup-wireguard-host.ts index fa0abde..3da303f 100644 --- a/src/functions/backend/setup/setup-wireguard-host.ts +++ b/src/functions/backend/setup/setup-wireguard-host.ts @@ -17,6 +17,7 @@ import grabClientDirnames from "./grab-client-dir-names"; import buildHostIptablesScripts from "./build-host-iptables-scripts"; import allowSubnetFirewallRules from "../firewall/allow-subnet-firewall-rules"; import { AppData } from "@/src/data/app-data"; +import { TlsData } from "@/src/data/tls-data"; const { WIREGUARD_PRIVATE_KEY_FILE_NAME, WIREGUARD_PUBLIC_KEY_FILE_NAME } = grabDirNames(); @@ -215,6 +216,9 @@ export default async function setupWireguardHost({ interface_name: INTERFACE_NAME, host_subnet: HOST_SUBNET, sibling_subnets, + ingress_ports: TlsData.IngressEnabled + ? [TlsData.CaddyHttpsPort] + : [], clients: clients.map((client) => ({ id: client.id, wg_ip_address: client.wg_ip_address, diff --git a/src/layouts/admin/(data)/admin-nav-dict.ts b/src/layouts/admin/(data)/admin-nav-dict.ts index 4b440c9..8c603e0 100644 --- a/src/layouts/admin/(data)/admin-nav-dict.ts +++ b/src/layouts/admin/(data)/admin-nav-dict.ts @@ -1,6 +1,7 @@ import { LayoutDashboard, LogOut, + Network, Server, Settings, Users, @@ -34,6 +35,7 @@ export const AdminNav: AdminNavGroup[] = [ items: [ { title: "Hosts", url: "/admin/hosts", Icon: Server }, { title: "Clients", url: "/admin/clients", Icon: Users }, + { title: "Routes", url: "/admin/routes", Icon: Network }, ], }, { diff --git a/src/pages/admin/hosts/(partials)/host-row-actions.tsx b/src/pages/admin/hosts/(partials)/host-row-actions.tsx index 9c9f687..9898e16 100644 --- a/src/pages/admin/hosts/(partials)/host-row-actions.tsx +++ b/src/pages/admin/hosts/(partials)/host-row-actions.tsx @@ -1,4 +1,4 @@ -import { Eye, Pencil, Plus, Trash2, Users } from "lucide-react"; +import { Eye, Network, Pencil, Plus, Trash2, Users } from "lucide-react"; import ActionMenu from "@/src/components/general/action-menu"; import useDeleteHost from "@/src/components/general/use-delete-host"; import deriveHostDisplayName from "@/src/utils/derive-host-display-name"; @@ -20,6 +20,7 @@ export default function HostRowActions({ host, onDeleted }: Props) { items={[ { key: "view", title: "View", href: base, icon: }, { key: "clients", title: "Clients", href: `${base}/clients`, icon: }, + { key: "routes", title: "Client routes", href: `${base}/routes`, icon: }, { key: "add-client", title: "Add client", diff --git a/src/pages/admin/hosts/[host_id]/(hooks)/use-host-header-actions.tsx b/src/pages/admin/hosts/[host_id]/(hooks)/use-host-header-actions.tsx index 82befd1..e65add2 100644 --- a/src/pages/admin/hosts/[host_id]/(hooks)/use-host-header-actions.tsx +++ b/src/pages/admin/hosts/[host_id]/(hooks)/use-host-header-actions.tsx @@ -1,4 +1,4 @@ -import { Pencil, Plus, Trash2, Users } from "lucide-react"; +import { Network, Pencil, Plus, Trash2, Users } from "lucide-react"; import useDeleteHost from "@/src/components/general/use-delete-host"; import type { ActionMenuItem } from "@/src/components/general/action-menu/types"; import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db"; @@ -24,6 +24,7 @@ export default function useHostHeaderActions({ host, host_id }: Params) { const actions: ActionMenuItem[] = [ { key: "clients", title: "Clients", href: `/admin/hosts/${host_id}/clients`, icon: }, + { key: "routes", title: "Client routes", href: `/admin/hosts/${host_id}/routes`, icon: }, { key: "edit", title: "Edit", href: `/admin/hosts/${host_id}/edit`, icon: }, ...(host ? [ diff --git a/src/pages/admin/hosts/[host_id]/index.tsx b/src/pages/admin/hosts/[host_id]/index.tsx index f1a8b7c..354bfbb 100644 --- a/src/pages/admin/hosts/[host_id]/index.tsx +++ b/src/pages/admin/hosts/[host_id]/index.tsx @@ -10,6 +10,7 @@ import { AppContext } from "@/src/pages/__root"; import deriveHostDisplayName from "@/src/utils/derive-host-display-name"; import HostStatsSection from "./(sections)/host-stats-section"; import HostInterfaceSection from "./(sections)/host-interface-section"; +import ClientRoutesPanel from "@/src/components/general/client-routes-panel"; import HostHeaderMeta from "./(partials)/host-header-meta"; import useHostHeaderActions from "./(hooks)/use-host-header-actions"; @@ -45,6 +46,11 @@ export default function HostDetailPage() { + diff --git a/src/pages/admin/hosts/[host_id]/routes/[route_id]/(hooks)/use-route-header-actions.tsx b/src/pages/admin/hosts/[host_id]/routes/[route_id]/(hooks)/use-route-header-actions.tsx new file mode 100644 index 0000000..67dbd63 --- /dev/null +++ b/src/pages/admin/hosts/[host_id]/routes/[route_id]/(hooks)/use-route-header-actions.tsx @@ -0,0 +1,63 @@ +import { Pencil, Trash2 } from "lucide-react"; +import type { ActionMenuItem } from "@/src/components/general/action-menu/types"; +import type { ClientRouteView } from "@/src/functions/backend/acme/decorate-client-route"; +import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler"; +import useConfirm from "@/src/hooks/use-confirm"; +import useToast from "@/src/hooks/use-toast"; + +type Params = { + route?: ClientRouteView | null; + host_id: number; +}; + +export default function useRouteHeaderActions({ route, host_id }: Params) { + const { confirm } = useConfirm(); + const { toast } = useToast(); + const route_id = Number(route?.id || 0); + + const primary_action: ActionMenuItem = { + key: "edit", + title: "Edit route", + href: `/admin/hosts/${host_id}/routes/edit/${route_id}`, + icon: , + }; + + const actions: ActionMenuItem[] = route + ? [ + { + key: "delete", + title: "Delete route", + tone: "danger", + icon: , + onClick: async () => { + const confirmed = await confirm({ + title: "Delete this route?", + message: `${route.hostname} will stop being served by Caddy.`, + confirm_label: "Delete route", + tone: "danger", + }); + + if (!confirmed) return; + + const res = await adminCrudHandler({ + action: "delete", + table: "client_routes", + id: route_id, + }); + + if (res.success) { + toast({ msg: "Route deleted", color: "success" }); + window.location.href = `/admin/hosts/${host_id}/routes`; + } else { + toast({ + msg: res.msg || "Could not delete route", + color: "error", + }); + } + }, + }, + ] + : []; + + return { primary_action, actions }; +} diff --git a/src/pages/admin/hosts/[host_id]/routes/[route_id]/(sections)/route-certificate-section.tsx b/src/pages/admin/hosts/[host_id]/routes/[route_id]/(sections)/route-certificate-section.tsx new file mode 100644 index 0000000..e4d4cd0 --- /dev/null +++ b/src/pages/admin/hosts/[host_id]/routes/[route_id]/(sections)/route-certificate-section.tsx @@ -0,0 +1,269 @@ +import { useState } from "react"; +import { RefreshCw, ShieldCheck } from "lucide-react"; +import type { ClientRouteView } from "@/src/functions/backend/acme/decorate-client-route"; +import type { ClientCertificateView } from "@/src/functions/backend/acme/decorate-certificate"; +import fetchApi from "@/src/components/twui/utils/fetch/fetchApi"; +import Button from "@/src/components/twui/layout/Button"; +import Row from "@/src/components/twui/layout/Row"; +import Stack from "@/src/components/twui/layout/Stack"; +import Panel from "@/src/components/general/panel"; +import StatusDot from "@/src/components/general/status-dot"; +import InlineAlert from "@/src/components/general/inline-alert"; +import KeyValueList from "@/src/components/general/key-value-list"; +import MutedText from "@/src/components/general/muted-text"; +import MonoText from "@/src/components/general/mono-text"; +import useConfirm from "@/src/hooks/use-confirm"; +import useToast from "@/src/hooks/use-toast"; +import type { ApiReqParams } from "@/src/types"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; + +type Props = { + route: ClientRouteView; + certificate?: ClientCertificateView | null; + pending_challenge?: ManualChallenge | null; +}; + +type ManualChallenge = { + txt_name: string; + txt_value: string; + hostname?: string; + staging?: boolean; +}; + +function expiryLabel({ expires_at }: { expires_at?: number | null }) { + if (!expires_at) return "No certificate"; + const days = Math.ceil((expires_at - Date.now()) / (24 * 60 * 60 * 1000)); + if (days < 0) return `Expired ${Math.abs(days)} day(s) ago`; + return `Expires in ${days} day(s)`; +} + +function certificateTone(status?: string) { + if (status == "active") return "success" as const; + if (status == "expiring" || status == "issuing") return "warning" as const; + if (status == "error") return "error" as const; + return "neutral" as const; +} + +export default function RouteCertificateSection({ + route, + certificate, + pending_challenge, +}: Props) { + const { confirm } = useConfirm(); + const { toast } = useToast(); + const [current, setCurrent] = useState( + certificate || null, + ); + const [challenge, setChallenge] = useState( + pending_challenge || null, + ); + const [busy, setBusy] = useState(false); + + const route_id = Number(route.id || 0); + const configured = Boolean(route.acme_email); + const is_active = + current?.status == "active" || current?.status == "expiring"; + + async function start({ force }: { force?: boolean }) { + const confirmed = await confirm({ + title: force + ? "Start certificate renewal?" + : "Start certificate issuance?", + message: `${route.hostname} needs a _acme-challenge TXT record at your DNS registrar. The dashboard will show the exact value.`, + confirm_label: force ? "Start renewal" : "Start issuance", + }); + + if (!confirmed) return; + + setBusy(true); + + try { + const res = await fetchApi( + `/api/admin/tls/manual/start`, + { + method: "POST", + body: { route_id } as ApiReqParams, + }, + ); + + if (res.success && res.singleRes) { + setChallenge(res.singleRes as ManualChallenge); + } + + toast({ + msg: + res.msg || + (res.success + ? "TXT record ready" + : "Could not start certificate issuance"), + color: res.success ? "success" : "error", + }); + } finally { + setBusy(false); + } + } + + async function complete() { + setBusy(true); + + try { + const res = await fetchApi( + `/api/admin/tls/manual/complete`, + { + method: "POST", + body: { route_id } as ApiReqParams, + }, + ); + + if (res.singleRes) { + setCurrent(res.singleRes as ClientCertificateView); + } + + setChallenge(null); + + toast({ + msg: + res.msg || + (res.success + ? "Certificate issued" + : "Certificate validation failed"), + color: res.success ? "success" : "error", + }); + } finally { + setBusy(false); + } + } + + async function cancel() { + await fetchApi( + `/api/admin/tls/manual/cancel`, + { + method: "POST", + body: { route_id } as ApiReqParams, + }, + ); + + setChallenge(null); + } + + return ( + + + {!configured ? ( + + ) : null} + + {current?.last_error ? ( + + ) : null} + + {challenge ? ( + + + + + + + + + ) : ( + + + + + + {expiryLabel({ + expires_at: current?.expires_at, + })} + + + + {route.hostname || "—"} + + {route.acme_staging == 1 + ? "Let's Encrypt staging" + : "Let's Encrypt production"} + + + + + {is_active ? ( + + ) : ( + + )} + + )} + + + ); +} diff --git a/src/pages/admin/hosts/[host_id]/routes/[route_id]/(sections)/route-overview-section.tsx b/src/pages/admin/hosts/[host_id]/routes/[route_id]/(sections)/route-overview-section.tsx new file mode 100644 index 0000000..d577fe4 --- /dev/null +++ b/src/pages/admin/hosts/[host_id]/routes/[route_id]/(sections)/route-overview-section.tsx @@ -0,0 +1,96 @@ +import { Pencil } from "lucide-react"; +import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db"; +import type { ClientRouteView } from "@/src/functions/backend/acme/decorate-client-route"; +import Button from "@/src/components/twui/layout/Button"; +import Panel from "@/src/components/general/panel"; +import KeyValueList from "@/src/components/general/key-value-list"; + +type Props = { + route: ClientRouteView; + host?: BUN_SQLITE_WGUI_HOSTS | null; +}; + +export default function RouteOverviewSection({ route, host }: Props) { + const host_id = Number(route.host_id || 0); + const route_id = Number(route.id || 0); + + return ( + } + > + Edit + + } + > + + + ); +} diff --git a/src/pages/admin/hosts/[host_id]/routes/[route_id]/index.server.ts b/src/pages/admin/hosts/[host_id]/routes/[route_id]/index.server.ts new file mode 100644 index 0000000..51406ef --- /dev/null +++ b/src/pages/admin/hosts/[host_id]/routes/[route_id]/index.server.ts @@ -0,0 +1,60 @@ +import type { + BUN_SQLITE_WGUI_CLIENT_ROUTES, + BUN_SQLITE_WGUI_HOSTS, +} from "@/db/types/db"; +import decorateClientRoute from "@/src/functions/backend/acme/decorate-client-route"; +import grabCertificateByHostname from "@/src/functions/backend/acme/grab-certificate-by-hostname"; +import { grabManualDnsChallenge } from "@/src/functions/backend/acme/manual-dns-challenges"; +import type { PagePropsType, PageQueryObject, TableType } from "@/src/types"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { BunextPageServerFn } from "@moduletrace/bunext/types"; + +type RoutePageQuery = PageQueryObject & { + route_id?: string; +}; + +const server: BunextPageServerFn = async ({ query }) => { + const page_query = query as RoutePageQuery; + const route_id = Number(page_query.route_id || 0); + + const host_res = await BunSQLite.select({ + table: "hosts", + targetId: page_query.host_id, + }); + + const route_res = await BunSQLite.select< + BUN_SQLITE_WGUI_CLIENT_ROUTES, + TableType + >({ + table: "client_routes", + targetId: route_id, + }); + + const route = route_res.singleRes; + const certificate = route + ? await grabCertificateByHostname({ hostname: route.hostname }) + : null; + const pending = route + ? grabManualDnsChallenge(Number(route.id || 0)) + : null; + + return { + props: { + host: host_res.singleRes || null, + client_route: route + ? decorateClientRoute({ route, certificate }) + : null, + client_certificate: certificate, + client_challenge: pending + ? { + txt_name: pending.txt_name, + txt_value: pending.txt_value, + hostname: pending.hostname, + staging: pending.staging, + } + : null, + }, + }; +}; + +export default server; diff --git a/src/pages/admin/hosts/[host_id]/routes/[route_id]/index.tsx b/src/pages/admin/hosts/[host_id]/routes/[route_id]/index.tsx new file mode 100644 index 0000000..3f4713a --- /dev/null +++ b/src/pages/admin/hosts/[host_id]/routes/[route_id]/index.tsx @@ -0,0 +1,75 @@ +import type { BunextPageModuleMeta } from "@moduletrace/bunext/types"; +import { useContext } from "react"; +import { Network } from "lucide-react"; +import PageHeader from "@/src/components/general/page-header"; +import PageBody from "@/src/components/general/page-body"; +import EmptyState from "@/src/components/general/empty-state"; +import { SiteData } from "@/src/data/site-data"; +import { AppContext } from "@/src/pages/__root"; +import RouteOverviewSection from "./(sections)/route-overview-section"; +import RouteCertificateSection from "./(sections)/route-certificate-section"; +import useRouteHeaderActions from "./(hooks)/use-route-header-actions"; + +export default function RouteDetailPage() { + const { pageProps, query } = useContext(AppContext); + const route = pageProps?.client_route; + const host = pageProps?.host; + const certificate = pageProps?.client_certificate; + const challenge = pageProps?.client_challenge; + const host_id = Number( + query?.host_id ?? host?.id ?? route?.host_id ?? 0, + ); + const { primary_action, actions } = useRouteHeaderActions({ + route, + host_id, + }); + + if (!route) { + return ( + <> + + + } + title="Route not found" + description="It may have been deleted." + /> + + + ); + } + + return ( + <> + + + + + + + ); +} + +export const meta: BunextPageModuleMeta = { + title: `Route | ${SiteData["SiteName"]}`, + description: `TLS route details`, +}; diff --git a/src/pages/admin/hosts/[host_id]/routes/add/index.server.ts b/src/pages/admin/hosts/[host_id]/routes/add/index.server.ts new file mode 100644 index 0000000..4e359e0 --- /dev/null +++ b/src/pages/admin/hosts/[host_id]/routes/add/index.server.ts @@ -0,0 +1,21 @@ +import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db"; +import type { PagePropsType, PageQueryObject, TableType } from "@/src/types"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { BunextPageServerFn } from "@moduletrace/bunext/types"; + +const server: BunextPageServerFn = async ({ query }) => { + const page_query = query as PageQueryObject; + + const host_res = await BunSQLite.select({ + table: "hosts", + targetId: page_query.host_id, + }); + + return { + props: { + host: host_res.singleRes || null, + }, + }; +}; + +export default server; diff --git a/src/pages/admin/hosts/[host_id]/routes/add/index.tsx b/src/pages/admin/hosts/[host_id]/routes/add/index.tsx new file mode 100644 index 0000000..0210635 --- /dev/null +++ b/src/pages/admin/hosts/[host_id]/routes/add/index.tsx @@ -0,0 +1,37 @@ +import type { BunextPageModuleMeta } from "@moduletrace/bunext/types"; +import { useContext } from "react"; +import PageHeader from "@/src/components/general/page-header"; +import PageBody from "@/src/components/general/page-body"; +import ClientRouteForm from "@/src/components/general/client-route-form"; +import { SiteData } from "@/src/data/site-data"; +import { AppContext } from "@/src/pages/__root"; + +export default function AddClientRoutePage() { + const { pageProps, query } = useContext(AppContext); + const host = pageProps?.host; + const host_id = Number(query?.host_id ?? host?.id ?? 0); + + return ( + <> + + + + + + ); +} + +export const meta: BunextPageModuleMeta = { + title: `Add route | ${SiteData["SiteName"]}`, + description: `Add a TLS route for a WireGuard host`, +}; diff --git a/src/pages/admin/hosts/[host_id]/routes/edit/[route_id]/index.server.ts b/src/pages/admin/hosts/[host_id]/routes/edit/[route_id]/index.server.ts new file mode 100644 index 0000000..e226685 --- /dev/null +++ b/src/pages/admin/hosts/[host_id]/routes/edit/[route_id]/index.server.ts @@ -0,0 +1,49 @@ +import type { + BUN_SQLITE_WGUI_CLIENT_ROUTES, + BUN_SQLITE_WGUI_HOSTS, +} from "@/db/types/db"; +import decorateClientRoute from "@/src/functions/backend/acme/decorate-client-route"; +import grabCertificateByHostname from "@/src/functions/backend/acme/grab-certificate-by-hostname"; +import type { PagePropsType, PageQueryObject, TableType } from "@/src/types"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { BunextPageServerFn } from "@moduletrace/bunext/types"; + +type RouteEditPageQuery = PageQueryObject & { + route_id?: string; +}; + +const server: BunextPageServerFn = async ({ query }) => { + const page_query = query as RouteEditPageQuery; + const route_id = Number(page_query.route_id || 0); + + const host_res = await BunSQLite.select({ + table: "hosts", + targetId: page_query.host_id, + }); + + const route_res = await BunSQLite.select< + BUN_SQLITE_WGUI_CLIENT_ROUTES, + TableType + >({ + table: "client_routes", + targetId: route_id, + }); + + const route = route_res.singleRes; + + return { + props: { + host: host_res.singleRes || null, + client_route: route + ? decorateClientRoute({ + route, + certificate: await grabCertificateByHostname({ + hostname: route.hostname, + }), + }) + : null, + }, + }; +}; + +export default server; diff --git a/src/pages/admin/hosts/[host_id]/routes/edit/[route_id]/index.tsx b/src/pages/admin/hosts/[host_id]/routes/edit/[route_id]/index.tsx new file mode 100644 index 0000000..bdd10a2 --- /dev/null +++ b/src/pages/admin/hosts/[host_id]/routes/edit/[route_id]/index.tsx @@ -0,0 +1,47 @@ +import type { BunextPageModuleMeta } from "@moduletrace/bunext/types"; +import { useContext } from "react"; +import PageHeader from "@/src/components/general/page-header"; +import PageBody from "@/src/components/general/page-body"; +import EmptyState from "@/src/components/general/empty-state"; +import ClientRouteForm from "@/src/components/general/client-route-form"; +import { SiteData } from "@/src/data/site-data"; +import { AppContext } from "@/src/pages/__root"; + +export default function EditClientRoutePage() { + const { pageProps, query } = useContext(AppContext); + const host = pageProps?.host; + const route = pageProps?.client_route; + const host_id = Number(query?.host_id ?? host?.id ?? 0); + + return ( + <> + + + {route ? ( + + ) : ( + + )} + + + ); +} + +export const meta: BunextPageModuleMeta = { + title: `Edit route | ${SiteData["SiteName"]}`, + description: `Edit a TLS route for a WireGuard host`, +}; diff --git a/src/pages/admin/hosts/[host_id]/routes/index.server.ts b/src/pages/admin/hosts/[host_id]/routes/index.server.ts new file mode 100644 index 0000000..4e359e0 --- /dev/null +++ b/src/pages/admin/hosts/[host_id]/routes/index.server.ts @@ -0,0 +1,21 @@ +import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db"; +import type { PagePropsType, PageQueryObject, TableType } from "@/src/types"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { BunextPageServerFn } from "@moduletrace/bunext/types"; + +const server: BunextPageServerFn = async ({ query }) => { + const page_query = query as PageQueryObject; + + const host_res = await BunSQLite.select({ + table: "hosts", + targetId: page_query.host_id, + }); + + return { + props: { + host: host_res.singleRes || null, + }, + }; +}; + +export default server; diff --git a/src/pages/admin/hosts/[host_id]/routes/index.tsx b/src/pages/admin/hosts/[host_id]/routes/index.tsx new file mode 100644 index 0000000..376a2ed --- /dev/null +++ b/src/pages/admin/hosts/[host_id]/routes/index.tsx @@ -0,0 +1,35 @@ +import type { BunextPageModuleMeta } from "@moduletrace/bunext/types"; +import { useContext } from "react"; +import PageHeader from "@/src/components/general/page-header"; +import PageBody from "@/src/components/general/page-body"; +import { SiteData } from "@/src/data/site-data"; +import { AppContext } from "@/src/pages/__root"; +import ClientRoutesPanel from "@/src/components/general/client-routes-panel"; + +export default function HostRoutesPage() { + const { pageProps, query } = useContext(AppContext); + const host = pageProps?.host; + const host_id = Number(query?.host_id ?? host?.id ?? 0); + + return ( + <> + + + + + + ); +} + +export const meta: BunextPageModuleMeta = { + title: `Routes | ${SiteData["SiteName"]}`, + description: `TLS routes for a WireGuard host`, +}; diff --git a/src/pages/admin/routes/index.tsx b/src/pages/admin/routes/index.tsx new file mode 100644 index 0000000..ab2a671 --- /dev/null +++ b/src/pages/admin/routes/index.tsx @@ -0,0 +1,28 @@ +import type { BunextPageModuleMeta } from "@moduletrace/bunext/types"; +import PageHeader from "@/src/components/general/page-header"; +import PageBody from "@/src/components/general/page-body"; +import ClientRoutesPanel from "@/src/components/general/client-routes-panel"; +import { SiteData } from "@/src/data/site-data"; + +export default function RoutesPage() { + return ( + <> + + + + + + ); +} + +export const meta: BunextPageModuleMeta = { + title: `Routes | ${SiteData["SiteName"]}`, + description: `TLS routes exposed to WireGuard clients`, +}; diff --git a/src/pages/admin/setup/(data)/setup-nav-dict.ts b/src/pages/admin/setup/(data)/setup-nav-dict.ts index 92fadaa..5f02130 100644 --- a/src/pages/admin/setup/(data)/setup-nav-dict.ts +++ b/src/pages/admin/setup/(data)/setup-nav-dict.ts @@ -1,8 +1,9 @@ -import { Activity, Shield, Wrench } from "lucide-react"; +import { Activity, LockKeyhole, Shield, Wrench } from "lucide-react"; import type { SubNavItemType } from "@/src/components/general/sub-nav/sub-nav-item"; export const SetupNav: SubNavItemType[] = [ { title: "System status", href: "/admin/setup/status", Icon: Activity }, { title: "Maintenance", href: "/admin/setup/maintenance", Icon: Wrench }, { title: "Firewall", href: "/admin/setup/firewall", Icon: Shield }, + { title: "TLS ingress", href: "/admin/setup/tls", Icon: LockKeyhole }, ]; diff --git a/src/pages/admin/setup/(hooks)/use-tls-status.ts b/src/pages/admin/setup/(hooks)/use-tls-status.ts new file mode 100644 index 0000000..1416039 --- /dev/null +++ b/src/pages/admin/setup/(hooks)/use-tls-status.ts @@ -0,0 +1,63 @@ +import fetchApi from "@/src/components/twui/utils/fetch/fetchApi"; +import type grabTlsStatus from "@/src/functions/backend/acme/grab-tls-status"; +import type { ApiReqParams } from "@/src/types"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import { useEffect, useState } from "react"; + +export type TlsStatus = Awaited>; + +type TlsStatusResponse = APIResponseObject & { + singleRes?: TlsStatus | null; +}; + +export default function useTlsStatus() { + const [tls_status, setTlsStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const refresh = () => + fetchApi(`/api/admin/tls/status`, { + method: "GET", + }).then((res) => { + if (res.success && res.singleRes) { + setTlsStatus(res.singleRes); + setError(null); + } else { + setError(res.msg || `Could not fetch TLS status`); + } + + return res; + }); + + const refreshWithLoading = () => { + setLoading(true); + + refresh() + .catch((request_error: any) => { + setError( + request_error?.message || `Could not fetch TLS status`, + ); + }) + .finally(() => setLoading(false)); + }; + + const sync = () => + fetchApi(`/api/admin/tls/sync`, { + method: "POST", + }).then(async (res) => { + await refresh(); + return res; + }); + + useEffect(() => { + refreshWithLoading(); + }, []); + + return { + tls_status, + refresh: refreshWithLoading, + sync, + loading, + error, + }; +} diff --git a/src/pages/admin/setup/(partials)/setup-caddy-action.tsx b/src/pages/admin/setup/(partials)/setup-caddy-action.tsx new file mode 100644 index 0000000..8a9d22e --- /dev/null +++ b/src/pages/admin/setup/(partials)/setup-caddy-action.tsx @@ -0,0 +1,36 @@ +import { ServerCog } from "lucide-react"; +import fetchApi from "@/src/components/twui/utils/fetch/fetchApi"; +import type { ApiReqParams } from "@/src/types"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import SetupActionRow from "./setup-action-row"; + +type Props = { + installed?: boolean | null; + on_done?: () => void; +}; + +export default function SetupCaddyAction({ installed, on_done }: Props) { + return ( + } + button_title={installed ? "Repair Caddy" : "Install Caddy"} + confirm_params={{ + title: installed ? "Repair the Caddy ingress?" : "Install Caddy?", + message: + "This installs Caddy and the wgui-caddy service. Existing routes and certificates are preserved.", + confirm_label: installed ? "Repair Caddy" : "Install Caddy", + }} + on_run={async () => { + const res = await fetchApi( + `/api/admin/install-caddy`, + { method: "POST" }, + ); + + on_done?.(); + return res; + }} + /> + ); +} diff --git a/src/pages/admin/setup/(sections)/setup-actions-section.tsx b/src/pages/admin/setup/(sections)/setup-actions-section.tsx index 96f66ac..c8e4425 100644 --- a/src/pages/admin/setup/(sections)/setup-actions-section.tsx +++ b/src/pages/admin/setup/(sections)/setup-actions-section.tsx @@ -5,6 +5,7 @@ import type { ApiReqParams } from "@/src/types"; import type { APIResponseObject } from "@moduletrace/bunext/types"; import type useSystemSetupStatus from "../(hooks)/use-system-setup-status"; import SetupActionRow from "../(partials)/setup-action-row"; +import SetupCaddyAction from "../(partials)/setup-caddy-action"; import SetupUpdateWgUiAction from "../(partials)/setup-update-wg-ui-action"; type Props = { @@ -24,6 +25,11 @@ export default function SetupActionsSection({ setup }: Props) { .filter((tool) => WG_TOOL_COMMANDS.includes(tool.command)) .every((tool) => tool.installed); + const have_caddy = Boolean( + setup_status.tools.find((tool) => tool.command == "/opt/caddy/caddy") + ?.installed, + ); + const show_restart = !setup_status.is_dev && setup_status.init_system !== "unknown"; return ( @@ -43,6 +49,11 @@ export default function SetupActionsSection({ setup }: Props) { }} /> + setup.refresh()} + /> + {show_restart ? ( diff --git a/src/pages/admin/setup/(sections)/setup-page-frame.tsx b/src/pages/admin/setup/(sections)/setup-page-frame.tsx index b1bcf82..ddfe26c 100644 --- a/src/pages/admin/setup/(sections)/setup-page-frame.tsx +++ b/src/pages/admin/setup/(sections)/setup-page-frame.tsx @@ -12,7 +12,7 @@ type Props = PropsWithChildren & { is_root?: boolean; }; -/** Header, sub-navigation and body shared by the three setup subpages. */ +/** Header, sub-navigation and body shared by the setup subpages. */ export default function SetupPageFrame({ title, description, is_root, children }: Props) { return ( <> diff --git a/src/pages/admin/setup/tls/(sections)/tls-install-section.tsx b/src/pages/admin/setup/tls/(sections)/tls-install-section.tsx new file mode 100644 index 0000000..d52ed1f --- /dev/null +++ b/src/pages/admin/setup/tls/(sections)/tls-install-section.tsx @@ -0,0 +1,18 @@ +import Panel from "@/src/components/general/panel"; +import type useTlsStatus from "../../(hooks)/use-tls-status"; +import SetupCaddyAction from "../../(partials)/setup-caddy-action"; + +type Props = { + tls: ReturnType; +}; + +export default function TlsInstallSection({ tls }: Props) { + return ( + + + + ); +} diff --git a/src/pages/admin/setup/tls/(sections)/tls-setup-section.tsx b/src/pages/admin/setup/tls/(sections)/tls-setup-section.tsx new file mode 100644 index 0000000..fc298e5 --- /dev/null +++ b/src/pages/admin/setup/tls/(sections)/tls-setup-section.tsx @@ -0,0 +1,59 @@ +import { ArrowRight } from "lucide-react"; +import Button from "@/src/components/twui/layout/Button"; +import Stack from "@/src/components/twui/layout/Stack"; +import P from "@/src/components/twui/layout/P"; +import Panel from "@/src/components/general/panel"; +import InlineAlert from "@/src/components/general/inline-alert"; +import MonoText from "@/src/components/general/mono-text"; +import type useTlsStatus from "../../(hooks)/use-tls-status"; + +type Props = { + tls: ReturnType; +}; + +export default function TlsSetupSection({ tls }: Props) { + const caddy = tls.tls_status?.caddy; + + return ( + } + > + Open routes + + } + > + + {!caddy ? ( + + ) : !caddy.installed ? ( + + ) : null} +

+ Open a route to issue or renew its certificate. The dashboard + displays the exact _acme-challenge TXT record; + add it at Cloudflare, wait for propagation, then click + Validate. +

+

+ Certificates are renewed manually from the route page because + DNS validation needs your registrar. The route list shows + expiry warnings. +

+

+ The Caddy ingress listens on port {caddy?.https_port || 8443}. + It never binds ports 80 or 443. +

+
+
+ ); +} diff --git a/src/pages/admin/setup/tls/(sections)/tls-status-section.tsx b/src/pages/admin/setup/tls/(sections)/tls-status-section.tsx new file mode 100644 index 0000000..f26a1ad --- /dev/null +++ b/src/pages/admin/setup/tls/(sections)/tls-status-section.tsx @@ -0,0 +1,117 @@ +import { RefreshCw } from "lucide-react"; +import Button from "@/src/components/twui/layout/Button"; +import Stack from "@/src/components/twui/layout/Stack"; +import Panel from "@/src/components/general/panel"; +import StatusDot from "@/src/components/general/status-dot"; +import InlineAlert from "@/src/components/general/inline-alert"; +import KeyValueList from "@/src/components/general/key-value-list"; +import type useTlsStatus from "../../(hooks)/use-tls-status"; + +type Props = { + tls: ReturnType; +}; + +export default function TlsStatusSection({ tls }: Props) { + const { tls_status, refresh, sync, loading, error } = tls; + const caddy = tls_status?.caddy; + + return ( + + + + + + } + > + + {error ? : null} + {!caddy?.installed ? ( + + ) : null} + {caddy?.state?.last_error ? ( + + ) : null} + {caddy?.state?.warnings?.length ? ( + + ) : null} + + + + ); +} diff --git a/src/pages/admin/setup/tls/index.server.ts b/src/pages/admin/setup/tls/index.server.ts new file mode 100644 index 0000000..36300fb --- /dev/null +++ b/src/pages/admin/setup/tls/index.server.ts @@ -0,0 +1,15 @@ +import type { PagePropsType } from "@/src/types"; +import type { BunextPageServerFn } from "@moduletrace/bunext/types"; +import guardSetupPage from "../(functions)/guard-setup-page"; + +const server: BunextPageServerFn = async ({ req }) => { + const redirect = await guardSetupPage({ req }); + + if (redirect) { + return { redirect }; + } + + return { props: {} }; +}; + +export default server; diff --git a/src/pages/admin/setup/tls/index.tsx b/src/pages/admin/setup/tls/index.tsx new file mode 100644 index 0000000..2986e24 --- /dev/null +++ b/src/pages/admin/setup/tls/index.tsx @@ -0,0 +1,27 @@ +import type { BunextPageModuleMeta } from "@moduletrace/bunext/types"; +import { SiteData } from "@/src/data/site-data"; +import useTlsStatus from "../(hooks)/use-tls-status"; +import SetupPageFrame from "../(sections)/setup-page-frame"; +import TlsStatusSection from "./(sections)/tls-status-section"; +import TlsInstallSection from "./(sections)/tls-install-section"; +import TlsSetupSection from "./(sections)/tls-setup-section"; + +export default function SetupTlsPage() { + const tls = useTlsStatus(); + + return ( + + + + + + ); +} + +export const meta: BunextPageModuleMeta = { + title: `TLS ingress | ${SiteData["SiteName"]}`, + description: `Caddy ingress and client-route certificates`, +}; diff --git a/src/pages/api/admin/crud/(tables)/client_routes/(functions)/ensure-certificate-record.ts b/src/pages/api/admin/crud/(tables)/client_routes/(functions)/ensure-certificate-record.ts new file mode 100644 index 0000000..e464411 --- /dev/null +++ b/src/pages/api/admin/crud/(tables)/client_routes/(functions)/ensure-certificate-record.ts @@ -0,0 +1,54 @@ +import type { BUN_SQLITE_WGUI_TLS_CERTIFICATES } from "@/db/types/db"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { TableType } from "@/src/types"; +import grabCertDirNames from "@/src/functions/backend/caddy/grab-cert-dir-names"; + +type Params = { + hostname: string; + user_id?: number | ""; +}; + +/** + * A route may exist before its certificate. Create the metadata row so the + * setup page can list it and offer an "Issue" action. + */ +export default async function ensureCertificateRecord({ + hostname, + user_id, +}: Params) { + const existing = await BunSQLite.select< + BUN_SQLITE_WGUI_TLS_CERTIFICATES, + TableType + >({ + table: "tls_certificates", + query: { + query: { + hostname: { + value: hostname, + }, + }, + }, + }); + + if (existing.singleRes?.id) { + return; + } + + const { CERT_FILE, KEY_FILE } = grabCertDirNames({ hostname }); + + await BunSQLite.insert({ + table: "tls_certificates", + data: [ + { + user_id, + hostname, + provider: "letsencrypt", + challenge: "dns-01", + cert_path: CERT_FILE, + key_path: KEY_FILE, + status: "missing", + auto_renew: 1, + }, + ], + }); +} diff --git a/src/pages/api/admin/crud/(tables)/client_routes/del.ts b/src/pages/api/admin/crud/(tables)/client_routes/del.ts new file mode 100644 index 0000000..c0b08fd --- /dev/null +++ b/src/pages/api/admin/crud/(tables)/client_routes/del.ts @@ -0,0 +1,63 @@ +import type { BUN_SQLITE_WGUI_CLIENT_ROUTES } from "@/db/types/db"; +import checkUserAccess from "@/src/functions/backend/auth/check-user-access"; +import syncCaddyIngress from "@/src/functions/backend/caddy/sync-caddy-ingress"; +import type { AdminCrudAPIParams, TableType } from "@/src/types"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; + +export default async function ( + params: AdminCrudAPIParams, +): Promise { + const { id, user_types, user } = params; + + const is_super_admin = checkUserAccess({ user_types }); + + if (!is_super_admin.success) { + const is_admin = checkUserAccess({ + user_types, + includes: ["admin"], + }); + + if (!is_admin.success) { + return { success: false, msg: `Unauthorized` }; + } + } + + const existing_res = await BunSQLite.select< + BUN_SQLITE_WGUI_CLIENT_ROUTES, + TableType + >({ + table: "client_routes", + targetId: id, + }); + + const existing = existing_res.singleRes; + + if (typeof existing?.id !== "number") { + return { success: false, msg: `Route not found` }; + } + + if (!is_super_admin.success && existing.user_id != user.id) { + return { success: false, msg: `Unauthorized` }; + } + + const deleted = await BunSQLite.delete< + BUN_SQLITE_WGUI_CLIENT_ROUTES, + TableType + >({ + table: "client_routes", + targetId: existing.id, + }); + + if (!deleted.success) { + return deleted; + } + + const sync = await syncCaddyIngress({ reason: "route deleted" }); + + return { + ...deleted, + msg: sync.msg, + debug: { warnings: sync.warnings }, + }; +} diff --git a/src/pages/api/admin/crud/(tables)/client_routes/get.ts b/src/pages/api/admin/crud/(tables)/client_routes/get.ts new file mode 100644 index 0000000..dfdef4f --- /dev/null +++ b/src/pages/api/admin/crud/(tables)/client_routes/get.ts @@ -0,0 +1,79 @@ +import type { + BUN_SQLITE_WGUI_CLIENT_ROUTES, + BUN_SQLITE_WGUI_TLS_CERTIFICATES, +} from "@/db/types/db"; +import checkUserAccess from "@/src/functions/backend/auth/check-user-access"; +import decorateClientRoute from "@/src/functions/backend/acme/decorate-client-route"; +import { grabManualDnsChallenge } from "@/src/functions/backend/acme/manual-dns-challenges"; +import type { AdminCrudAPIParams, TableType } from "@/src/types"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import _ from "lodash"; + +export default async function ( + params: AdminCrudAPIParams, +): Promise { + const { table, user_types, body, id, query } = params; + + const allowed = checkUserAccess({ + user_types, + includes: ["admin"], + }); + + if (!allowed.success) { + return { success: false, msg: `Unauthorized` }; + } + + const final_sql_query = _.merge(query?.sql_query, body?.sql_query) || {}; + + const GET = await BunSQLite.select< + BUN_SQLITE_WGUI_CLIENT_ROUTES, + TableType + >({ + table, + query: final_sql_query, + targetId: id, + }); + + const certificates_res = await BunSQLite.select< + BUN_SQLITE_WGUI_TLS_CERTIFICATES, + TableType + >({ + table: "tls_certificates", + }); + + const certificates_by_hostname = new Map( + (certificates_res.payload || []) + .filter((certificate) => certificate.hostname) + .map((certificate) => [ + certificate.hostname!.trim().toLowerCase(), + certificate, + ]), + ); + + const decorate = (route: BUN_SQLITE_WGUI_CLIENT_ROUTES) => { + const view = decorateClientRoute({ + route, + certificate: certificates_by_hostname.get(route.hostname || ""), + }); + + if ( + typeof route.id === "number" && + grabManualDnsChallenge(route.id) + ) { + view.certificate_status = "issuing"; + } + + return view; + }; + + return { + ...GET, + payload: GET.payload?.map((route) => + decorate(route as BUN_SQLITE_WGUI_CLIENT_ROUTES), + ), + singleRes: GET.singleRes + ? decorate(GET.singleRes as BUN_SQLITE_WGUI_CLIENT_ROUTES) + : GET.singleRes, + }; +} diff --git a/src/pages/api/admin/crud/(tables)/client_routes/index.ts b/src/pages/api/admin/crud/(tables)/client_routes/index.ts new file mode 100644 index 0000000..3381ab6 --- /dev/null +++ b/src/pages/api/admin/crud/(tables)/client_routes/index.ts @@ -0,0 +1,24 @@ +import type { AdminCrudAPIParams } from "@/src/types"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import get from "./get"; +import post from "./post"; +import put from "./put"; +import del from "./del"; + +export default async function ( + params: AdminCrudAPIParams, +): Promise { + switch (params.req.method) { + case "GET": + return await get(params); + case "POST": + return await post(params); + case "PUT": + return await put(params); + case "DELETE": + return await del(params); + + default: + return { success: false }; + } +} diff --git a/src/pages/api/admin/crud/(tables)/client_routes/post.ts b/src/pages/api/admin/crud/(tables)/client_routes/post.ts new file mode 100644 index 0000000..5c05270 --- /dev/null +++ b/src/pages/api/admin/crud/(tables)/client_routes/post.ts @@ -0,0 +1,123 @@ +import type { + BUN_SQLITE_WGUI_CLIENT_ROUTES, + BUN_SQLITE_WGUI_HOSTS, +} from "@/db/types/db"; +import checkUserAccess from "@/src/functions/backend/auth/check-user-access"; +import sanitizeClientRoute from "@/src/functions/backend/caddy/sanitize-client-route"; +import syncCaddyIngress from "@/src/functions/backend/caddy/sync-caddy-ingress"; +import type { AdminCrudAPIParams, TableType } from "@/src/types"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import ensureCertificateRecord from "./(functions)/ensure-certificate-record"; +import sealRouteCredentials from "@/src/functions/backend/acme/seal-route-credentials"; + +export default async function ( + params: AdminCrudAPIParams, +): Promise { + const { body, user_types, user } = params; + + const is_super_admin = checkUserAccess({ user_types }); + + if (!is_super_admin.success) { + const is_admin = checkUserAccess({ + user_types, + includes: ["admin"], + }); + + if (!is_admin.success) { + return { success: false, msg: `Unauthorized` }; + } + } + + const prepared: BUN_SQLITE_WGUI_CLIENT_ROUTES[] = []; + + for (let i = 0; i < (body?.insert_data || []).length; i++) { + const sanitized = sanitizeClientRoute({ + route: body?.insert_data?.[i], + user_id: user.id, + }); + + if (!sanitized.success) { + return { + success: false, + msg: sanitized.msg, + }; + } + + const sealed = await sealRouteCredentials({ + text: sanitized.route.acme_credentials, + }); + + if (!sealed.success) { + return { success: false, msg: sealed.msg }; + } + + const { id: _ignored_id, ...route_data } = sanitized.route; + + prepared.push({ + ...route_data, + acme_credentials: sealed.value, + user_id: is_super_admin.success + ? route_data.user_id + : user.id, + }); + } + + if (!prepared[0]) { + return { success: false, msg: `No routes to insert` }; + } + + const host_ids = [...new Set(prepared.map((route) => Number(route.host_id)))]; + + for (let i = 0; i < host_ids.length; i++) { + const host_res = await BunSQLite.select< + BUN_SQLITE_WGUI_HOSTS, + TableType + >({ + table: "hosts", + targetId: host_ids[i], + }); + + const host = host_res.singleRes; + + if (typeof host?.id !== "number") { + return { success: false, msg: `Host not found` }; + } + + if (!is_super_admin.success && host.user_id != user.id) { + return { success: false, msg: `Unauthorized` }; + } + } + + const insert = await BunSQLite.insert< + BUN_SQLITE_WGUI_CLIENT_ROUTES, + TableType + >({ + table: "client_routes", + data: prepared, + update_on_duplicate: false, + }); + + if (!insert.success) { + return insert; + } + + for (let i = 0; i < prepared.length; i++) { + const route = prepared[i]; + + if (route?.hostname) { + await ensureCertificateRecord({ + hostname: route.hostname, + user_id: user.id, + }); + } + } + + const sync = await syncCaddyIngress({ reason: "route created" }); + + return { + ...insert, + msg: sync.msg, + debug: { warnings: sync.warnings }, + }; +} diff --git a/src/pages/api/admin/crud/(tables)/client_routes/put.ts b/src/pages/api/admin/crud/(tables)/client_routes/put.ts new file mode 100644 index 0000000..fbd4d54 --- /dev/null +++ b/src/pages/api/admin/crud/(tables)/client_routes/put.ts @@ -0,0 +1,133 @@ +import type { + BUN_SQLITE_WGUI_CLIENT_ROUTES, + BUN_SQLITE_WGUI_HOSTS, +} from "@/db/types/db"; +import checkUserAccess from "@/src/functions/backend/auth/check-user-access"; +import sanitizeClientRoute from "@/src/functions/backend/caddy/sanitize-client-route"; +import syncCaddyIngress from "@/src/functions/backend/caddy/sync-caddy-ingress"; +import type { AdminCrudAPIParams, TableType } from "@/src/types"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import ensureCertificateRecord from "./(functions)/ensure-certificate-record"; +import sealRouteCredentials from "@/src/functions/backend/acme/seal-route-credentials"; + +export default async function ( + params: AdminCrudAPIParams, +): Promise { + const { body, id, user_types, user } = params; + + if (!body?.update_data) { + return { success: false, msg: `No update data` }; + } + + const is_super_admin = checkUserAccess({ user_types }); + + if (!is_super_admin.success) { + const is_admin = checkUserAccess({ + user_types, + includes: ["admin"], + }); + + if (!is_admin.success) { + return { success: false, msg: `Unauthorized` }; + } + } + + const existing_res = await BunSQLite.select< + BUN_SQLITE_WGUI_CLIENT_ROUTES, + TableType + >({ + table: "client_routes", + targetId: id, + }); + + const existing = existing_res.singleRes; + + if (typeof existing?.id !== "number") { + return { success: false, msg: `Route not found` }; + } + + if (!is_super_admin.success && existing.user_id != user.id) { + return { success: false, msg: `Unauthorized` }; + } + + const update_data = { + ...(body.update_data as BUN_SQLITE_WGUI_CLIENT_ROUTES), + }; + const new_credentials_text = + typeof update_data.acme_credentials == "string" + ? update_data.acme_credentials + : ""; + + // Blank means "keep the existing encrypted credentials". + delete update_data.acme_credentials; + + const sanitized = sanitizeClientRoute({ + route: { ...existing, ...update_data }, + user_id: existing.user_id, + host_id: existing.host_id, + }); + + if (!sanitized.success) { + return { success: false, msg: sanitized.msg }; + } + + if (new_credentials_text?.trim()) { + const sealed = await sealRouteCredentials({ + text: new_credentials_text, + existing: existing.acme_credentials, + }); + + if (!sealed.success) { + return { success: false, msg: sealed.msg }; + } + + sanitized.route.acme_credentials = sealed.value; + } + + const host_res = await BunSQLite.select({ + table: "hosts", + targetId: sanitized.route.host_id, + }); + + if (typeof host_res.singleRes?.id !== "number") { + return { success: false, msg: `Host not found` }; + } + + if ( + !is_super_admin.success && + host_res.singleRes.user_id != user.id + ) { + return { success: false, msg: `Unauthorized` }; + } + + sanitized.route.user_id = is_super_admin.success + ? existing.user_id + : user.id; + + const update = await BunSQLite.update< + BUN_SQLITE_WGUI_CLIENT_ROUTES, + TableType + >({ + table: "client_routes", + targetId: existing.id, + data: sanitized.route, + }); + + if (!update.success) { + return update; + } + + await ensureCertificateRecord({ + hostname: sanitized.route.hostname || "", + user_id: sanitized.route.user_id, + }); + + const sync = await syncCaddyIngress({ reason: "route updated" }); + + return { + ...update, + msg: sync.msg, + debug: { warnings: sync.warnings }, + }; +} diff --git a/src/pages/api/admin/crud/(tables)/clients/(functions)/run-client-setup.ts b/src/pages/api/admin/crud/(tables)/clients/(functions)/run-client-setup.ts index 539167b..ba82b84 100644 --- a/src/pages/api/admin/crud/(tables)/clients/(functions)/run-client-setup.ts +++ b/src/pages/api/admin/crud/(tables)/clients/(functions)/run-client-setup.ts @@ -42,7 +42,7 @@ export default async function runClientSetup({ let host: BUN_SQLITE_WGUI_HOSTS | undefined; - if (client?.host_id) { + if (client?.host_id !== undefined && client.host_id !== "") { const host_res = await BunSQLite.select< BUN_SQLITE_WGUI_HOSTS, TableType diff --git a/src/pages/api/admin/crud/(tables)/clients/del.ts b/src/pages/api/admin/crud/(tables)/clients/del.ts index b96dc54..a87c4fd 100644 --- a/src/pages/api/admin/crud/(tables)/clients/del.ts +++ b/src/pages/api/admin/crud/(tables)/clients/del.ts @@ -100,7 +100,7 @@ export default async function ( try { let host: BUN_SQLITE_WGUI_HOSTS | undefined; - if (host_id) { + if (Number.isSafeInteger(host_id) && host_id >= 0) { const host_res = await BunSQLite.select< BUN_SQLITE_WGUI_HOSTS, TableType diff --git a/src/pages/api/admin/crud/[[...paths]].ts b/src/pages/api/admin/crud/[[...paths]].ts index fbc07c7..5416de5 100644 --- a/src/pages/api/admin/crud/[[...paths]].ts +++ b/src/pages/api/admin/crud/[[...paths]].ts @@ -15,6 +15,7 @@ import userAuth from "@/src/functions/backend/auth/user-auth"; import checkUserAccess from "@/src/functions/backend/auth/check-user-access"; import clients from "./(tables)/clients"; import clientRules from "./(tables)/client_rules"; +import clientRoutes from "./(tables)/client_routes"; export const handler: BunextAPIRouteHandler = async ( params, @@ -64,6 +65,13 @@ export const handler: BunextAPIRouteHandler = async ( return await clients(crud_params); case "client_rules": return await clientRules(crud_params); + case "client_routes": + return await clientRoutes(crud_params); + case "tls_certificates": + return { + success: false, + msg: `TLS certificates are managed by the TLS ingress`, + }; default: break; diff --git a/src/pages/api/admin/grab-next-available-client-ip.ts b/src/pages/api/admin/grab-next-available-client-ip.ts index c2cff1d..d6c8291 100644 --- a/src/pages/api/admin/grab-next-available-client-ip.ts +++ b/src/pages/api/admin/grab-next-available-client-ip.ts @@ -33,7 +33,11 @@ export const handler: BunextAPIRouteHandler = async ( try { let host: BUN_SQLITE_WGUI_HOSTS | undefined; - if (body?.host_id) { + if ( + body?.host_id !== undefined && + body.host_id !== null && + body.host_id !== "" + ) { const host_res = await BunSQLite.select< BUN_SQLITE_WGUI_HOSTS, TableType diff --git a/src/pages/api/admin/install-caddy.ts b/src/pages/api/admin/install-caddy.ts new file mode 100644 index 0000000..0ed6763 --- /dev/null +++ b/src/pages/api/admin/install-caddy.ts @@ -0,0 +1,62 @@ +import adminApiGuard from "@/src/functions/backend/auth/admin-api-guard"; +import syncCaddyIngress from "@/src/functions/backend/caddy/sync-caddy-ingress"; +import runSetupScript from "@/src/functions/backend/setup/run-setup-script"; +import { TlsData } from "@/src/data/tls-data"; +import grabDirNames from "@/src/utils/grab-dir-names"; +import type { + APIResponseObject, + BunextAPIRouteHandler, +} from "@moduletrace/bunext/types"; + +const INSTALL_CADDY_SCRIPT = `install-caddy.sh`; +const INSTALL_CADDY_TIMEOUT_MS = 20 * 60 * 1000; + +const { + WGUI_LIB_DIR, + WGUI_CADDY_BIN_DIR, + WGUI_ACME_HOME, + WGUI_ACME_ENV_FILE, +} = grabDirNames(); + +export const handler: BunextAPIRouteHandler = async ({ + req, +}) => { + if (req.method !== "POST") { + return { success: false }; + } + + const unauthorized = await adminApiGuard({ req }); + + if (unauthorized) { + return unauthorized; + } + + const install = runSetupScript({ + script: INSTALL_CADDY_SCRIPT, + timeout_ms: INSTALL_CADDY_TIMEOUT_MS, + env: { + WGUI_LIB_DIR, + CADDY_VERSION: process.env.CADDY_VERSION, + CADDY_BIN_DIR: WGUI_CADDY_BIN_DIR, + CADDY_HTTPS_PORT: String(TlsData.CaddyHttpsPort), + CADDY_HTTP_PORT: String(TlsData.CaddyHttpPort), + ACME_HOME: WGUI_ACME_HOME, + ACME_ENV_FILE: WGUI_ACME_ENV_FILE, + }, + }); + + if (!install.success) { + return { + success: false, + msg: install.output, + }; + } + + const sync = await syncCaddyIngress({ reason: "caddy installed" }); + + return { + success: sync.success, + msg: [install.output, sync.msg].filter(Boolean).join("\n\n"), + debug: { warnings: sync.warnings }, + }; +}; diff --git a/src/pages/api/admin/tls/(functions)/guard-route-access.ts b/src/pages/api/admin/tls/(functions)/guard-route-access.ts new file mode 100644 index 0000000..fcbc426 --- /dev/null +++ b/src/pages/api/admin/tls/(functions)/guard-route-access.ts @@ -0,0 +1,75 @@ +import type { + BUN_SQLITE_WGUI_CLIENT_ROUTES, + BUN_SQLITE_WGUI_USER_TYPES, +} from "@/db/types/db"; +import type { User } from "@/src/types"; +import checkUserAccess from "@/src/functions/backend/auth/check-user-access"; +import userAuth from "@/src/functions/backend/auth/user-auth"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { TableType } from "@/src/types"; + +type Params = { + req: Request; + route_id?: number | ""; +}; + +type Result = + | { + success: true; + user: User; + user_types: BUN_SQLITE_WGUI_USER_TYPES[]; + route: BUN_SQLITE_WGUI_CLIENT_ROUTES; + } + | { success: false; msg: string; logoutUser?: boolean }; + +export default async function guardRouteAccess({ + req, + route_id, +}: Params): Promise { + const { user, user_types } = await userAuth({ req }); + + if (!user?.logged_in_status) { + return { + success: false, + msg: `Unauthorized`, + logoutUser: true, + }; + } + + const is_admin = checkUserAccess({ + user_types, + includes: ["admin"], + }); + + if (!is_admin.success) { + return { success: false, msg: `Unauthorized` }; + } + + const final_route_id = Number(route_id); + + if (!Number.isSafeInteger(final_route_id) || final_route_id < 1) { + return { success: false, msg: `Invalid route` }; + } + + const route_res = await BunSQLite.select< + BUN_SQLITE_WGUI_CLIENT_ROUTES, + TableType + >({ + table: "client_routes", + targetId: final_route_id, + }); + + const route = route_res.singleRes; + + if (!route) { + return { success: false, msg: `Route not found` }; + } + + const is_super_admin = checkUserAccess({ user_types }).success; + + if (!is_super_admin && route.user_id != user.id) { + return { success: false, msg: `Unauthorized` }; + } + + return { success: true, user, user_types: user_types || [], route }; +} diff --git a/src/pages/api/admin/tls/issue.ts b/src/pages/api/admin/tls/issue.ts new file mode 100644 index 0000000..6039f12 --- /dev/null +++ b/src/pages/api/admin/tls/issue.ts @@ -0,0 +1,96 @@ +import checkUserAccess from "@/src/functions/backend/auth/check-user-access"; +import userAuth from "@/src/functions/backend/auth/user-auth"; +import issueCertificate from "@/src/functions/backend/acme/issue-certificate"; +import type { BUN_SQLITE_WGUI_CLIENT_ROUTES } from "@/db/types/db"; +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { ApiReqParams, TableType } from "@/src/types"; +import type { + APIResponseObject, + BunextAPIRouteHandler, +} from "@moduletrace/bunext/types"; + +export const handler: BunextAPIRouteHandler = async ({ + req, + body, +}) => { + if (req.method !== "POST") { + return { success: false }; + } + + const { user, user_types } = await userAuth({ req }); + + if (!user?.logged_in_status) { + return { + success: false, + msg: `Unauthorized`, + logoutUser: true, + }; + } + + const is_admin = checkUserAccess({ + user_types, + includes: ["admin"], + }); + + if (!is_admin.success) { + return { success: false, msg: `Unauthorized` }; + } + + const payload = body as + | (ApiReqParams & { + route_id?: number | ""; + hostname?: string; + staging?: boolean; + force?: boolean; + }) + | undefined; + + let route: BUN_SQLITE_WGUI_CLIENT_ROUTES | undefined; + + if (payload?.route_id !== undefined && payload.route_id !== "") { + const route_res = await BunSQLite.select< + BUN_SQLITE_WGUI_CLIENT_ROUTES, + TableType + >({ + table: "client_routes", + targetId: payload.route_id, + }); + + route = route_res.singleRes || undefined; + } else if (payload?.hostname) { + const route_res = await BunSQLite.select< + BUN_SQLITE_WGUI_CLIENT_ROUTES, + TableType + >({ + table: "client_routes", + query: { + query: { + hostname: { + value: payload.hostname.trim().toLowerCase(), + }, + }, + }, + }); + + route = route_res.singleRes || undefined; + } + + if (!route || typeof route.id !== "number") { + return { success: false, msg: `Route not found` }; + } + + const is_super_admin = checkUserAccess({ user_types }).success; + + if (!is_super_admin && route.user_id != user.id) { + return { success: false, msg: `Unauthorized` }; + } + + return await issueCertificate({ + route, + route_id: route.id, + hostname: route.hostname, + user_id: user.id, + staging: payload?.staging, + force: Boolean(payload?.force), + }); +}; diff --git a/src/pages/api/admin/tls/manual/cancel.ts b/src/pages/api/admin/tls/manual/cancel.ts new file mode 100644 index 0000000..96e2653 --- /dev/null +++ b/src/pages/api/admin/tls/manual/cancel.ts @@ -0,0 +1,37 @@ +import { removeManualDnsChallenge } from "@/src/functions/backend/acme/manual-dns-challenges"; +import type { ApiReqParams } from "@/src/types"; +import type { + APIResponseObject, + BunextAPIRouteHandler, +} from "@moduletrace/bunext/types"; +import guardRouteAccess from "../(functions)/guard-route-access"; + +export const handler: BunextAPIRouteHandler = async ({ + req, + body, +}) => { + if (req.method !== "POST") { + return { success: false }; + } + + const payload = body as (ApiReqParams & { route_id?: number | "" }) | undefined; + const guard = await guardRouteAccess({ + req, + route_id: payload?.route_id, + }); + + if (!guard.success) { + return { + success: false, + msg: guard.msg, + logoutUser: guard.logoutUser, + }; + } + + removeManualDnsChallenge(Number(guard.route.id)); + + return { + success: true, + msg: `Pending certificate request cancelled`, + }; +}; diff --git a/src/pages/api/admin/tls/manual/complete.ts b/src/pages/api/admin/tls/manual/complete.ts new file mode 100644 index 0000000..74a1aec --- /dev/null +++ b/src/pages/api/admin/tls/manual/complete.ts @@ -0,0 +1,35 @@ +import completeManualCertificate from "@/src/functions/backend/acme/complete-manual-certificate"; +import type { ApiReqParams } from "@/src/types"; +import type { + APIResponseObject, + BunextAPIRouteHandler, +} from "@moduletrace/bunext/types"; +import guardRouteAccess from "../(functions)/guard-route-access"; + +export const handler: BunextAPIRouteHandler = async ({ + req, + body, +}) => { + if (req.method !== "POST") { + return { success: false }; + } + + const payload = body as (ApiReqParams & { route_id?: number | "" }) | undefined; + const guard = await guardRouteAccess({ + req, + route_id: payload?.route_id, + }); + + if (!guard.success) { + return { + success: false, + msg: guard.msg, + logoutUser: guard.logoutUser, + }; + } + + return await completeManualCertificate({ + route_id: guard.route.id, + user_id: guard.user.id, + }); +}; diff --git a/src/pages/api/admin/tls/manual/start.ts b/src/pages/api/admin/tls/manual/start.ts new file mode 100644 index 0000000..d99648f --- /dev/null +++ b/src/pages/api/admin/tls/manual/start.ts @@ -0,0 +1,35 @@ +import startManualCertificate from "@/src/functions/backend/acme/start-manual-certificate"; +import type { ApiReqParams } from "@/src/types"; +import type { + APIResponseObject, + BunextAPIRouteHandler, +} from "@moduletrace/bunext/types"; +import guardRouteAccess from "../(functions)/guard-route-access"; + +export const handler: BunextAPIRouteHandler = async ({ + req, + body, +}) => { + if (req.method !== "POST") { + return { success: false }; + } + + const payload = body as (ApiReqParams & { route_id?: number | "" }) | undefined; + const guard = await guardRouteAccess({ + req, + route_id: payload?.route_id, + }); + + if (!guard.success) { + return { + success: false, + msg: guard.msg, + logoutUser: guard.logoutUser, + }; + } + + return await startManualCertificate({ + route_id: guard.route.id, + user_id: guard.user.id, + }); +}; diff --git a/src/pages/api/admin/tls/status.ts b/src/pages/api/admin/tls/status.ts new file mode 100644 index 0000000..a9787b5 --- /dev/null +++ b/src/pages/api/admin/tls/status.ts @@ -0,0 +1,35 @@ +import adminApiGuard from "@/src/functions/backend/auth/admin-api-guard"; +import grabTlsStatus from "@/src/functions/backend/acme/grab-tls-status"; +import type { + APIResponseObject, + BunextAPIRouteHandler, +} from "@moduletrace/bunext/types"; + +export const handler: BunextAPIRouteHandler = async ({ + req, +}) => { + if (req.method !== "GET") { + return { success: false }; + } + + const unauthorized = await adminApiGuard({ req }); + + if (unauthorized) { + return unauthorized; + } + + try { + const status = await grabTlsStatus(); + + return { + success: true, + payload: [status], + singleRes: status, + } as APIResponseObject; + } catch (error: any) { + return { + success: false, + msg: error.message, + }; + } +}; diff --git a/src/pages/api/admin/tls/sync.ts b/src/pages/api/admin/tls/sync.ts new file mode 100644 index 0000000..ecfbaf9 --- /dev/null +++ b/src/pages/api/admin/tls/sync.ts @@ -0,0 +1,28 @@ +import adminApiGuard from "@/src/functions/backend/auth/admin-api-guard"; +import syncCaddyIngress from "@/src/functions/backend/caddy/sync-caddy-ingress"; +import type { + APIResponseObject, + BunextAPIRouteHandler, +} from "@moduletrace/bunext/types"; + +export const handler: BunextAPIRouteHandler = async ({ + req, +}) => { + if (req.method !== "POST") { + return { success: false }; + } + + const unauthorized = await adminApiGuard({ req }); + + if (unauthorized) { + return unauthorized; + } + + const sync = await syncCaddyIngress({ reason: "manual sync" }); + + return { + success: sync.success, + msg: sync.msg, + debug: { warnings: sync.warnings }, + }; +}; diff --git a/src/pages/api/create-first-user.ts b/src/pages/api/create-first-user.ts index 6bfb241..894a4d5 100644 --- a/src/pages/api/create-first-user.ts +++ b/src/pages/api/create-first-user.ts @@ -82,8 +82,6 @@ export const handler: BunextAPIRouteHandler< ], }); - console.log("add_super_admin_user_types", add_super_admin_user_types); - return { ...POST, singleRes: new_users_res.singleRes, diff --git a/src/pages/auth/login/(partials)/login-form/index.tsx b/src/pages/auth/login/(partials)/login-form/index.tsx index e1247d3..bea4b77 100644 --- a/src/pages/auth/login/(partials)/login-form/index.tsx +++ b/src/pages/auth/login/(partials)/login-form/index.tsx @@ -1,6 +1,7 @@ import Form from "@/src/components/twui/form/Form"; import InlineAlert from "@/src/components/general/inline-alert"; import useFormInit from "@/src/hooks/use-form-init"; +import useConfirm from "@/src/hooks/use-confirm"; import { type LoginFormObject } from "@/src/types"; import LoginFormEmailUsername from "./login-form-email-username"; import LoginFormPassword from "./login-form-password"; @@ -10,12 +11,21 @@ import LoginFormAction from "./login-form-action"; export default function LoginForm() { const init = useFormInit(); const { status } = init; + const { confirm } = useConfirm(); return (
{ - submitLoginForm(init); + onSubmit={async () => { + const confirmed = await confirm({ + title: "Sign in?", + message: "Use these credentials to sign in to the dashboard.", + confirm_label: "Sign in", + }); + + if (confirmed) { + submitLoginForm(init); + } }} > {status?.error ? : null} diff --git a/src/scripts/install-caddy.sh b/src/scripts/install-caddy.sh new file mode 100755 index 0000000..00e0d1a --- /dev/null +++ b/src/scripts/install-caddy.sh @@ -0,0 +1,367 @@ +#!/bin/bash + +# Install or repair the Caddy TLS ingress used by client routes. +# Safe to re-run: it preserves the existing Caddyfile and certificate data. + +set -euo pipefail + +log() { echo "==> $*"; } +fail() { echo "error: $*" >&2; exit 1; } + +WGUI_LIB_DIR="${WGUI_LIB_DIR:-/var/lib/wgui}" +CADDY_VERSION="${CADDY_VERSION:-2.11.4}" +CADDY_BIN_DIR="${CADDY_BIN_DIR:-/opt/caddy}" +CADDY_BIN="$CADDY_BIN_DIR/caddy" +CADDY_USER="${CADDY_USER:-wgui-caddy}" +CADDY_HTTPS_PORT="${CADDY_HTTPS_PORT:-8443}" +CADDY_HTTP_PORT="${CADDY_HTTP_PORT:-8081}" +ACME_HOME="${ACME_HOME:-/opt/acme.sh}" +ACME_ENV_FILE="${ACME_ENV_FILE:-/etc/wgui/acme.env}" +SERVICE_NAME="wgui-caddy" + +require_root() { + if [ "$(id -u)" -ne 0 ]; then + fail "this script must be run as root" + fi +} + +detect_distro() { + if [ -f /etc/os-release ]; then + # shellcheck disable=SC1091 + . /etc/os-release + echo "$ID" + else + echo "unknown" + fi +} + +detect_init_system() { + if command -v systemctl >/dev/null 2>&1; then + echo "systemd" + elif [ -x /sbin/openrc-run ] || [ -f /etc/alpine-release ]; then + echo "openrc" + else + echo "unknown" + fi +} + +caddy_arch() { + case "$(uname -m)" in + x86_64 | amd64) echo "amd64" ;; + aarch64 | arm64 | armv8l | armv8) echo "arm64" ;; + armv7l | armv7) echo "armv7" ;; + armv6l | armv6) echo "armv6" ;; + *) fail "unsupported architecture for Caddy: $(uname -m)" ;; + esac +} + +install_deps() { + local missing=false + local tool + + for tool in curl tar git openssl; do + if ! command -v "$tool" >/dev/null 2>&1; then + missing=true + break + fi + done + + if [ "$missing" = false ]; then + log "Caddy dependencies already installed" + return + fi + + local distro + distro="$(detect_distro)" + + log "installing Caddy dependencies for $distro ..." + + case "$distro" in + debian | ubuntu | linuxmint | raspbian) + export DEBIAN_FRONTEND=noninteractive + apt-get update -y + apt-get install -y curl ca-certificates openssl git tar + ;; + fedora | rhel | centos | rocky | almalinux | ol) + dnf install -y curl ca-certificates openssl git tar + ;; + arch | manjaro | endeavouros) + pacman -Syu --noconfirm --needed curl ca-certificates openssl git tar + ;; + alpine) + apk add --no-cache bash curl ca-certificates-bundle openssl git tar + ;; + *) + fail "unsupported distro: $distro" + ;; + esac +} + +install_caddy() { + if [ -x "$CADDY_BIN" ]; then + if [ ! -e /usr/local/bin/caddy ]; then + ln -sf "$CADDY_BIN" /usr/local/bin/caddy + fi + log "caddy: $($CADDY_BIN version)" + return + fi + + local arch + arch="$(caddy_arch)" + local archive="caddy_${CADDY_VERSION}_linux_${arch}.tar.gz" + local base_url="https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}" + local tmp_dir + tmp_dir="$(mktemp -d)" + + log "installing Caddy ${CADDY_VERSION} (${arch}) to ${CADDY_BIN_DIR} ..." + mkdir -p "$CADDY_BIN_DIR" + + curl -fsSL -o "$tmp_dir/$archive" "$base_url/$archive" + curl -fsSL -o "$tmp_dir/checksums.txt" "$base_url/caddy_${CADDY_VERSION}_checksums.txt" + + if command -v sha512sum >/dev/null 2>&1; then + (cd "$tmp_dir" && grep " $archive\$" checksums.txt | sha512sum -c -) + elif command -v openssl >/dev/null 2>&1; then + local expected actual + expected="$(grep " $archive\$" "$tmp_dir/checksums.txt" | awk '{print $1}')" + actual="$(openssl dgst -sha512 "$tmp_dir/$archive" | awk '{print $NF}')" + + if [ -z "$expected" ] || [ "$expected" != "$actual" ]; then + fail "Caddy checksum verification failed for $archive" + fi + else + fail "no sha512 verification tool available (install coreutils or openssl)" + fi + + tar -xzf "$tmp_dir/$archive" -C "$CADDY_BIN_DIR" caddy + chmod 755 "$CADDY_BIN" + if [ ! -e /usr/local/bin/caddy ]; then + ln -sf "$CADDY_BIN" /usr/local/bin/caddy + fi + rm -rf "$tmp_dir" + + log "caddy: $($CADDY_BIN version)" +} + +install_acme_sh() { + if [ -x "$ACME_HOME/acme.sh" ]; then + log "acme.sh already present at $ACME_HOME" + return + fi + + log "installing acme.sh to $ACME_HOME ..." + rm -rf "$ACME_HOME" + git clone --depth 1 https://github.com/acmesh-official/acme.sh.git "$ACME_HOME" + chmod 700 "$ACME_HOME" + chmod 700 "$ACME_HOME/acme.sh" +} + +setup_caddy() { + if ! id -u "$CADDY_USER" >/dev/null 2>&1; then + log "creating service user $CADDY_USER ..." + if command -v useradd >/dev/null 2>&1; then + useradd --system --user-group --home-dir "$WGUI_LIB_DIR/caddy" --shell /usr/sbin/nologin "$CADDY_USER" + else + adduser -S -H -h "$WGUI_LIB_DIR/caddy" -s /sbin/nologin "$CADDY_USER" + fi + fi + + if ! id -g "$CADDY_USER" >/dev/null 2>&1 && command -v groupadd >/dev/null 2>&1; then + groupadd --system "$CADDY_USER" + fi + + mkdir -p "$WGUI_LIB_DIR/caddy/data" "$WGUI_LIB_DIR/tls" + chmod 755 "$WGUI_LIB_DIR" + chown root:"$CADDY_USER" "$WGUI_LIB_DIR/tls" + chmod 750 "$WGUI_LIB_DIR/tls" + chown -R "$CADDY_USER:$CADDY_USER" "$WGUI_LIB_DIR/caddy" + chmod 750 "$WGUI_LIB_DIR/caddy" + chmod 750 "$WGUI_LIB_DIR/caddy/data" + + mkdir -p /etc/wgui + chmod 700 /etc/wgui + + if [ ! -f "$ACME_ENV_FILE" ]; then + log "writing $ACME_ENV_FILE template ..." + cat > "$ACME_ENV_FILE" < "$WGUI_LIB_DIR/caddy/Caddyfile" < "$unit" < "$init_script" < "$service" < "$timer" < "$script" < "$INSTALL_DIR/.env" - chmod 600 "$INSTALL_DIR/.env" - fi -} - -setup_wireguard() { - local setup_script="$INSTALL_DIR/src/scripts/setup-wireguard.sh" - if [ -x "$setup_script" ] && [ "${SKIP_WIREGUARD_SETUP:-0}" != "1" ]; then - log "running $(basename "$setup_script") to install wireguard on this host ..." - "$setup_script" - fi -} - -install_wg_quick_helper() { - local helper_src="$INSTALL_DIR/src/scripts/wg-quick-manage.sh" - if [ ! -f "$helper_src" ]; then - fail "missing $helper_src — cannot install the wg-quick helper" - fi - log "installing wg-quick helper to $WG_QUICK_HELPER ..." - install -m 755 -o root -g root "$helper_src" "$WG_QUICK_HELPER" -} - -install_systemd_unit() { - local unit="/etc/systemd/system/$SERVICE_NAME.service" - log "writing systemd unit $unit ..." - cat > "$unit" < "$init_script" </dev/null | awk '{print $1}' || echo localhost):$PORT in your browser" \ No newline at end of file +log "open http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo localhost):$PORT in your browser" +if [ "$DEV_MODE" = false ]; then + log "tls ingress: wgui-caddy on https://:$CADDY_HTTPS_PORT (never binds 80/443)" + log "open a route page to issue certificates with manual DNS-01" +fi \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index 27fe7a5..10dd9b1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,7 @@ import bunext from "@moduletrace/bunext"; import syncWireguardHosts from "./functions/backend/setup/sync-wireguard-hosts"; import handleMediaServer from "./functions/server/media/handle-media-server"; +import syncCaddyIngress from "./functions/backend/caddy/sync-caddy-ingress"; import { SiteData } from "./data/site-data"; const development = process.env.NODE_ENV == "development"; @@ -33,6 +34,19 @@ bunext.bunextLog.build(`Syncing WireGuard hosts ...`); await syncWireguardHosts(); bunext.bunextLog.success(`Wireguard host sync complete!`); +try { + const caddy_sync = await syncCaddyIngress({ reason: "startup" }); + bunext.bunextLog.info( + `Caddy ingress: ${caddy_sync.success ? "ready" : "not ready"} ${ + caddy_sync.msg || "" + }`, + ); +} catch (error: any) { + bunext.bunextLog.info( + `Caddy ingress skipped: ${error.message}`, + ); +} + bunext.bunextLog.info( `Server running on http://${server.hostname}:${server.port} ...`, ); diff --git a/src/types/index.ts b/src/types/index.ts index 644796b..96dd0e7 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,7 @@ import type { BUN_SQLITE_WGUI_ALL_TYPEDEFS, BUN_SQLITE_WGUI_CLIENTS, + BUN_SQLITE_WGUI_CLIENT_ROUTES, BUN_SQLITE_WGUI_CLIENT_RULES, BUN_SQLITE_WGUI_HOSTS, BUN_SQLITE_WGUI_MEDIA, @@ -26,6 +27,8 @@ import type { ImageInputToBase64FunctionReturn } from "../components/twui/utils/ import type { ServerQueryParam } from "@moduletrace/bun-sqlite/types"; import type grabHostDirnames from "../functions/backend/setup/grab-host-dir-names"; import type grabSystemSetupStatus from "../functions/backend/setup/grab-system-setup-status"; +import type { ClientRouteView } from "../functions/backend/acme/decorate-client-route"; +import type { ClientCertificateView } from "../functions/backend/acme/decorate-certificate"; export type User = { id: number; @@ -57,6 +60,15 @@ export type PagePropsType = { client?: BUN_SQLITE_WGUI_CLIENTS_JOIN | null; clients?: BUN_SQLITE_WGUI_CLIENTS_JOIN[] | null; client_rules?: BUN_SQLITE_WGUI_CLIENT_RULES[] | null; + client_route?: ClientRouteView | null; + client_certificate?: ClientCertificateView | null; + client_challenge?: { + txt_name: string; + txt_value: string; + hostname?: string; + staging?: boolean; + } | null; + route_certificates?: ClientCertificateView[] | null; client_config?: string | null; client_config_path?: string | null; client_qr_data_uri?: string | null; diff --git a/src/utils/grab-dir-names.ts b/src/utils/grab-dir-names.ts index 695d0d0..5081105 100644 --- a/src/utils/grab-dir-names.ts +++ b/src/utils/grab-dir-names.ts @@ -34,6 +34,28 @@ export default function grabDirNames(params?: Params) { const WGUI_LIB_KEYS_DIR = path.join(WGUI_LIB_DIR, `keys`); const WGUI_LIB_HOSTS_CONFIGS_DIR = path.join(WGUI_LIB_DIR, `hosts`); const WGUI_LIB_SCRIPTS_DIR = path.join(WGUI_LIB_DIR, `scripts`); + const WGUI_LIB_TLS_DIR = path.join(WGUI_LIB_DIR, `tls`); + const WGUI_LIB_CADDY_DIR = path.join(WGUI_LIB_DIR, `caddy`); + const WGUI_LIB_CADDY_CONFIG_FILE = path.join( + WGUI_LIB_CADDY_DIR, + `Caddyfile`, + ); + const WGUI_LIB_CADDY_STATE_FILE = path.join( + WGUI_LIB_CADDY_DIR, + `state.json`, + ); + const WGUI_LIB_CADDY_DATA_DIR = path.join(WGUI_LIB_CADDY_DIR, `data`); + const WGUI_LIB_CADDY_ADMIN_SOCKET = `/run/wgui-caddy/admin.sock`; + const WGUI_CADDY_BIN_DIR = `/opt/caddy`; + const WGUI_CADDY_BINARY = path.join(WGUI_CADDY_BIN_DIR, `caddy`); + const WGUI_ACME_HOME = `/opt/acme.sh`; + const WGUI_ACME_SCRIPT = path.join(WGUI_ACME_HOME, `acme.sh`); + const WGUI_ACME_ENV_FILE = `/etc/wgui/acme.env`; + const WGUI_LIB_ACME_DIR = path.join(WGUI_LIB_DIR, `acme`); + const WGUI_ACME_ACCOUNT_KEY_FILE = path.join( + WGUI_LIB_ACME_DIR, + `account.key`, + ); const WGUI_WG_QUICK_MANAGE_SCRIPT = path.join( WGUI_LIB_SCRIPTS_DIR, `wg-quick-manage.sh`, @@ -59,6 +81,19 @@ export default function grabDirNames(params?: Params) { WGUI_LIB_KEYS_DIR, WGUI_LIB_HOSTS_CONFIGS_DIR, WGUI_LIB_SCRIPTS_DIR, + WGUI_LIB_TLS_DIR, + WGUI_LIB_CADDY_DIR, + WGUI_LIB_CADDY_CONFIG_FILE, + WGUI_LIB_CADDY_STATE_FILE, + WGUI_LIB_CADDY_DATA_DIR, + WGUI_LIB_CADDY_ADMIN_SOCKET, + WGUI_CADDY_BIN_DIR, + WGUI_CADDY_BINARY, + WGUI_ACME_HOME, + WGUI_ACME_SCRIPT, + WGUI_ACME_ENV_FILE, + WGUI_LIB_ACME_DIR, + WGUI_ACME_ACCOUNT_KEY_FILE, WGUI_WG_QUICK_MANAGE_SCRIPT, WIREGUARD_PRIVATE_KEY_FILE_NAME,