Add TLS feature with Caddy

This commit is contained in:
2026-10-21 09:13:59 +01:00
parent 1462067e72
commit ed4a6f3ec8
109 changed files with 6374 additions and 156 deletions
+4 -2
View File
@@ -76,13 +76,13 @@ Server functions use `index.server.ts` alongside `index.tsx` (e.g. `single-docum
## Shared Code Organization ## 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/frontend/`** — Client-safe: CRUD/fetch handlers, media upload/delete, client rule label formatting.
- **`src/functions/server/`** — Request-level server handlers (media server). - **`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/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/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/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: - **`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). - 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`. - 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 ## 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. - 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) => <ItemComponent {...item} />)` — never inline the item's JSX inside the map callback. - **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) => <ItemComponent {...item} />)` — never inline the item's JSX inside the map callback.
- Always use object parameters (`{ foo, bar }`) instead of comma-separated positional parameters. - Always use object parameters (`{ foo, bar }`) instead of comma-separated positional parameters.
+49 -2
View File
@@ -49,8 +49,9 @@ The install script will:
5. Install app dependencies (`bun install`) 5. Install app dependencies (`bun install`)
6. Generate `.env` with encryption secrets 6. Generate `.env` with encryption secrets
7. Run WireGuard setup (if available) 7. Run WireGuard setup (if available)
8. Install the `wg-quick` helper script 8. Install Caddy (`/opt/caddy`)
9. Set up a systemd or OpenRC service (`wgui`) 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 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 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 Rules that no longer match any host (`temporary`/`orphaned`) are flagged
with copyable removal commands on the setup page. 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.<hostname>` TXT record. Add it at Cloudflare,
wait for propagation, then click **Validate**.
5. Clients use `https://<hostname>: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) ### Manual (Development)
```bash ```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_KEY` | Encryption key for sensitive data | Auto-generated |
| `ENCRYPTION_SALT` | Encryption salt for sensitive data | Auto-generated | | `ENCRYPTION_SALT` | Encryption salt for sensitive data | Auto-generated |
| `DATA_DIR` | Application data directory | `$INSTALL_DIR/.data` | | `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 ### Database Configuration
@@ -192,6 +233,8 @@ The database consists of the following tables:
| `clients` | WireGuard clients per host | | `clients` | WireGuard clients per host |
| `hosts` | WireGuard host configurations | | `hosts` | WireGuard host configurations |
| `client_rules` | IP table rules for each client | | `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 | | `variables` | Application-wide key-value variables |
Generate types from the schema: Generate types from the schema:
@@ -206,9 +249,13 @@ bun run db:schema
| ----------------------------------------------- | ------------ | ------------------------------------------------------------ | | ----------------------------------------------- | ------------ | ------------------------------------------------------------ |
| `/admin` | Admin | Dashboard overview | | `/admin` | Admin | Dashboard overview |
| `/admin/hosts` | Admin | Host management (add, edit, per-host detail) | | `/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/clients` | Admin | Client management (add, edit, client rules per host) |
| `/admin/settings` | Admin | Account settings | | `/admin/settings` | Admin | Account settings |
| `/admin/setup` | Super admin | System setup (hosts, WireGuard tools, update, restart) | | `/admin/setup` | Super admin | System setup (hosts, WireGuard tools, update, restart) |
| `/admin/setup/tls` | Super admin | Caddy ingress installation and status |
## Development ## Development
+85
View File
@@ -7,6 +7,7 @@
"dependencies": { "dependencies": {
"@moduletrace/bun-sqlite": "^1.2.0", "@moduletrace/bun-sqlite": "^1.2.0",
"@moduletrace/bunext": "^1.2.4", "@moduletrace/bunext": "^1.2.4",
"acme-client": "5.4.0",
"html-to-react": "^1.7.0", "html-to-react": "^1.7.0",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"lucide-react": "^1.31.0", "lucide-react": "^1.31.0",
@@ -236,6 +237,30 @@
"@oven/bun-windows-x64-baseline": ["@oven/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw=="], "@oven/bun-windows-x64-baseline": ["@oven/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw=="],
"@peculiar/asn1-cms": ["@peculiar/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-8xh4avo53/vXO6UEv9yaoJ83hY581geaLTygf61NPIXP5AeIZCPjJhpTaYU23IZkG+NpLN/C9FYzWp7hphIT9A=="],
"@peculiar/asn1-x509": ["@peculiar/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="],
"@peculiar/x509": ["@peculiar/[email protected]", "", { "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/[email protected]", "", { "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/node": ["@tailwindcss/[email protected]", "", { "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/[email protected]", "", { "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=="], "@tailwindcss/oxide": ["@tailwindcss/[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-PFNMSYqFdEUkul2Ntud0HvA09AgY+F1ag0UYdpMH60wNI/qOA8cB8tlTgoALMEwIdUPJK2CjrIQ7OnbiSS/ugQ=="], "ace-builds": ["[email protected]", "", {}, "sha512-PFNMSYqFdEUkul2Ntud0HvA09AgY+F1ag0UYdpMH60wNI/qOA8cB8tlTgoALMEwIdUPJK2CjrIQ7OnbiSS/ugQ=="],
"acme-client": ["[email protected]", "", { "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": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], "acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"acorn-jsx": ["[email protected]", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], "acorn-jsx": ["[email protected]", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"agent-base": ["[email protected]", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"ansi-regex": ["[email protected]", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "ansi-regex": ["[email protected]", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["[email protected]", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "ansi-styles": ["[email protected]", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"array-find-index": ["[email protected]", "", {}, "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw=="], "array-find-index": ["[email protected]", "", {}, "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw=="],
"asn1js": ["[email protected]", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.2.0", "tslib": "^2.8.1" } }, "sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA=="],
"astring": ["[email protected]", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], "astring": ["[email protected]", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="],
"asynckit": ["[email protected]", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"axios": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], "bail": ["[email protected]", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"bun": ["[email protected]", "", { "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=="], "bun": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"call-bind-apply-helpers": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"camelcase": ["[email protected]", "", {}, "sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw=="], "camelcase": ["[email protected]", "", {}, "sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw=="],
"camelcase-keys": ["[email protected]", "", { "dependencies": { "camelcase": "^2.0.0", "map-obj": "^1.0.0" } }, "sha512-bA/Z/DERHKqoEOrp+qeGKw1QlvEQkGZSc0XaY6VnTxZr+Kv1G5zFwttpjv8qxZ/sBPT4nthwZaAcsAZTJlSKXQ=="], "camelcase-keys": ["[email protected]", "", { "dependencies": { "camelcase": "^2.0.0", "map-obj": "^1.0.0" } }, "sha512-bA/Z/DERHKqoEOrp+qeGKw1QlvEQkGZSc0XaY6VnTxZr+Kv1G5zFwttpjv8qxZ/sBPT4nthwZaAcsAZTJlSKXQ=="],
@@ -396,6 +433,8 @@
"color-name": ["[email protected]", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "color-name": ["[email protected]", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"combined-stream": ["[email protected]", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"comma-separated-tokens": ["[email protected]", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], "comma-separated-tokens": ["[email protected]", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["[email protected]", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "commander": ["[email protected]", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
@@ -410,6 +449,8 @@
"decode-named-character-reference": ["[email protected]", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], "decode-named-character-reference": ["[email protected]", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"delayed-stream": ["[email protected]", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"dequal": ["[email protected]", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "dequal": ["[email protected]", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["[email protected]", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["[email protected]", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -426,6 +467,8 @@
"domutils": ["[email protected]", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], "domutils": ["[email protected]", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"dunder-proto": ["[email protected]", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"emoji-regex": ["[email protected]", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "emoji-regex": ["[email protected]", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"enhanced-resolve": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="], "enhanced-resolve": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
@@ -434,8 +477,14 @@
"error-ex": ["[email protected]", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], "error-ex": ["[email protected]", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
"es-define-property": ["[email protected]", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["[email protected]", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], "es-errors": ["[email protected]", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
"es-set-tostringtag": ["[email protected]", "", { "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": ["[email protected]", "", { "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-estree": ["[email protected]", "", { "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": ["[email protected]", "", { "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=="], "esast-util-from-js": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], "find-up": ["[email protected]", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"follow-redirects": ["[email protected]", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg=="],
"form-data": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-caller-file": ["[email protected]", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-caller-file": ["[email protected]", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["[email protected]", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], "get-east-asian-width": ["[email protected]", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
"get-intrinsic": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-stdin": ["[email protected]", "", {}, "sha512-F5aQMywwJ2n85s4hJPTT9RPxGmubonuB10MNYo17/xph174n2MIR33HRguhzVag10O/npM7SPk73LMZNP+FaWw=="], "get-stdin": ["[email protected]", "", {}, "sha512-F5aQMywwJ2n85s4hJPTT9RPxGmubonuB10MNYo17/xph174n2MIR33HRguhzVag10O/npM7SPk73LMZNP+FaWw=="],
"gopd": ["[email protected]", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["[email protected]", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["[email protected]", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-symbols": ["[email protected]", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["[email protected]", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hast-util-from-html": ["[email protected]", "", { "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=="], "hast-util-from-html": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="], "htmlparser2": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
"https-proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"iconv-lite": ["[email protected]", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], "iconv-lite": ["[email protected]", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="],
"indent-string": ["[email protected]", "", { "dependencies": { "repeating": "^2.0.0" } }, "sha512-aqwDFWSgSgfRaEwao5lg5KEcVd/2a+D1rvoG7NdilmYz0NwRk6StWpWdz/Hpk34MKPpx7s8XxUqimfcQK6gGlg=="], "indent-string": ["[email protected]", "", { "dependencies": { "repeating": "^2.0.0" } }, "sha512-aqwDFWSgSgfRaEwao5lg5KEcVd/2a+D1rvoG7NdilmYz0NwRk6StWpWdz/Hpk34MKPpx7s8XxUqimfcQK6gGlg=="],
@@ -590,6 +655,8 @@
"markdown-table": ["[email protected]", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], "markdown-table": ["[email protected]", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"math-intrinsics": ["[email protected]", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdast-util-find-and-replace": ["[email protected]", "", { "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-find-and-replace": ["[email protected]", "", { "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": ["[email protected]", "", { "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=="], "mdast-util-from-markdown": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], "micromark-util-types": ["[email protected]", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"mime-db": ["[email protected]", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["[email protected]", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"mimic-function": ["[email protected]", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], "mimic-function": ["[email protected]", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"minimist": ["[email protected]", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "minimist": ["[email protected]", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -710,6 +781,8 @@
"next-mdx-remote": ["[email protected]", "", { "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=="], "next-mdx-remote": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="],
"normalize-package-data": ["[email protected]", "", { "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=="], "normalize-package-data": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-c6legOHWepAbWnp3j5SRUMpxCXBKI4rD7A5Osn9IzZ8w4O/KccXdW0lqdkQKbpk0eHGjNgKihgzY6WuEq99Tfw=="], "object-assign": ["[email protected]", "", {}, "sha512-c6legOHWepAbWnp3j5SRUMpxCXBKI4rD7A5Osn9IzZ8w4O/KccXdW0lqdkQKbpk0eHGjNgKihgzY6WuEq99Tfw=="],
@@ -754,6 +827,12 @@
"property-information": ["[email protected]", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], "property-information": ["[email protected]", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
"proxy-from-env": ["[email protected]", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
"pvtsutils": ["[email protected]", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
"pvutils": ["[email protected]", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="],
"qrcode": ["[email protected]", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], "qrcode": ["[email protected]", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
"react": ["[email protected]", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="], "react": ["[email protected]", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="],
@@ -780,6 +859,8 @@
"redent": ["[email protected]", "", { "dependencies": { "indent-string": "^2.1.0", "strip-indent": "^1.0.1" } }, "sha512-qtW5hKzGQZqKoh6JNSD+4lfitfPKGz42e6QwiRmPM5mmKtR0N41AbJRYu0xJi7nhOJ4WDgRkKvAk6tw4WIwR4g=="], "redent": ["[email protected]", "", { "dependencies": { "indent-string": "^2.1.0", "strip-indent": "^1.0.1" } }, "sha512-qtW5hKzGQZqKoh6JNSD+4lfitfPKGz42e6QwiRmPM5mmKtR0N41AbJRYu0xJi7nhOJ4WDgRkKvAk6tw4WIwR4g=="],
"reflect-metadata": ["[email protected]", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
"refractor": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^9.0.0", "parse-entities": "^4.0.0" } }, "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw=="], "refractor": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="], "rehype-parse": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsyringe": ["[email protected]", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="],
"typescript": ["[email protected]", "", { "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=="], "typescript": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "undici-types": ["[email protected]", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
@@ -974,6 +1057,8 @@
"read-pkg-up/find-up": ["[email protected]", "", { "dependencies": { "path-exists": "^2.0.0", "pinkie-promise": "^2.0.0" } }, "sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA=="], "read-pkg-up/find-up": ["[email protected]", "", { "dependencies": { "path-exists": "^2.0.0", "pinkie-promise": "^2.0.0" } }, "sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA=="],
"tsyringe/tslib": ["[email protected]", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
"wrap-ansi/string-width": ["[email protected]", "", { "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/string-width": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "wrap-ansi/strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+145
View File
@@ -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", tableName: "variables",
fields: [ fields: [
+58 -1
View File
@@ -6,6 +6,8 @@ export const BunSQLiteTables = [
"clients", "clients",
"hosts", "hosts",
"client_rules", "client_rules",
"client_routes",
"tls_certificates",
"variables", "variables",
] as const ] as const
@@ -162,6 +164,61 @@ export type BUN_SQLITE_WGUI_CLIENT_RULES = {
protocol?: "any" | "tcp" | "udp" | ""; 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 = { export type BUN_SQLITE_WGUI_VARIABLES = {
/** /**
* The unique identifier of the record. * The unique identifier of the record.
@@ -179,4 +236,4 @@ export type BUN_SQLITE_WGUI_VARIABLES = {
value?: string; 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 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
+1
View File
@@ -31,6 +31,7 @@
"dependencies": { "dependencies": {
"@moduletrace/bun-sqlite": "^1.2.0", "@moduletrace/bun-sqlite": "^1.2.0",
"@moduletrace/bunext": "^1.2.4", "@moduletrace/bunext": "^1.2.4",
"acme-client": "5.4.0",
"html-to-react": "^1.7.0", "html-to-react": "^1.7.0",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"lucide-react": "^1.31.0", "lucide-react": "^1.31.0",
@@ -30,14 +30,23 @@ export default async function submitClientForm({
const { setLoading, setStatus, form, existing_full, app_context, clearLocalForm } = init; const { setLoading, setStatus, form, existing_full, app_context, clearLocalForm } = init;
const is_edit = Boolean(existing_full?.id); const is_edit = Boolean(existing_full?.id);
if (is_edit) { const confirmed = await confirm(
const confirmed = await confirm({ is_edit
? {
title: "Save changes?", title: "Save changes?",
message: "The client's config is regenerated. Devices may need the updated config.", message:
"The client's config is regenerated. Devices may need the updated config.",
confirm_label: "Save changes", confirm_label: "Save changes",
});
if (!confirmed) return;
} }
: {
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); setLoading(true);
@@ -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<ClientRouteFormObject>({
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 <LoadingBlock className="h-[420px]" />;
}
return (
<Panel>
<Form
className="w-full gap-0"
onSubmit={() =>
submitClientRouteForm({
init,
host_id,
cancel_href,
confirm,
})
}
>
{status?.error ? (
<InlineAlert tone="error" msg={status.msg} className="mb-6" />
) : null}
<FormSection
title="Route"
description="The public hostname and path Caddy matches."
>
<Input
name="name"
title="Name"
placeholder="e.g. Jellyfin"
defaultValue={form.name}
onChange={(e) =>
setForm((prev) => ({
...prev,
name: e.target.value,
}))
}
showLabel
/>
<Input
name="hostname"
title="Hostname"
placeholder="media.example.com"
defaultValue={form.hostname}
onChange={(e) =>
setForm((prev) => ({
...prev,
hostname: e.target.value.toLowerCase(),
}))
}
showLabel
required
/>
<Input
name="path_prefix"
title="Path prefix"
placeholder="/"
defaultValue={form.path_prefix || "/"}
onChange={(e) =>
setForm((prev) => ({
...prev,
path_prefix: e.target.value,
}))
}
showLabel
info="Use / for the whole hostname, or /app to match one subpath."
/>
<Checkbox
title="Strip the path prefix before forwarding"
label="Strip path prefix"
checked={form.strip_prefix == 1}
changeHandler={(value) =>
setForm((prev) => ({
...prev,
strip_prefix: value ? 1 : 0,
}))
}
/>
</FormSection>
<FormSection
title="Upstream"
description="The internal service Caddy forwards to. It must be reachable from this host."
>
<Input
name="upstream_url"
title="Upstream URL"
placeholder="http://10.5.0.20:8080"
defaultValue={form.upstream_url}
onChange={(e) =>
setForm((prev) => ({
...prev,
upstream_url: e.target.value,
}))
}
showLabel
required
info="Bare origin only: http(s)://host:port, with no path, query or credentials."
/>
<Checkbox
title="Verify the upstream TLS certificate"
label="Verify upstream TLS"
checked={form.upstream_tls_verify != 0}
changeHandler={(value) =>
setForm((prev) => ({
...prev,
upstream_tls_verify: value ? 1 : 0,
}))
}
info="When disabled, Caddy accepts any upstream certificate. Use only for internal self-signed services."
/>
<Checkbox
title="Serve this route"
label="Enabled"
checked={form.enabled != 0}
changeHandler={(value) =>
setForm((prev) => ({
...prev,
enabled: value ? 1 : 0,
}))
}
/>
</FormSection>
<FormSection
title="TLS certificate"
description="Manual DNS-01. After saving, the route page shows the TXT record to add at your registrar."
>
<InlineAlert
tone="info"
msg="No DNS provider token is needed. Issue the certificate from the route page, add the displayed _acme-challenge TXT record at Cloudflare, then click Validate."
/>
<Input
name="acme_email"
title="ACME email"
placeholder="[email protected]"
type="email"
defaultValue={form.acme_email}
onChange={(e) =>
setForm((prev) => ({
...prev,
acme_email: e.target.value,
}))
}
showLabel
/>
<Checkbox
title="Use the Let's Encrypt staging endpoint"
label="Use staging CA"
checked={form.acme_staging == 1}
changeHandler={(value) =>
setForm((prev) => ({
...prev,
acme_staging: value ? 1 : 0,
}))
}
/>
</FormSection>
<FormSection
title="Notes"
description="Optional context for the next admin."
>
<Input
name="notes"
title="Notes"
istextarea
defaultValue={form.notes}
onChange={(e) =>
setForm((prev) => ({
...prev,
notes: e.target.value,
}))
}
showLabel
/>
</FormSection>
<FormActions
submit_title={is_edit ? "Save changes" : "Create route"}
loading={loading}
cancel_href={cancel_href}
/>
</Form>
</Panel>
);
}
@@ -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);
}
}
@@ -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<ClientRouteView, "acme_credentials"> & {
acme_credentials?: string;
};
export type ClientRouteFormInit = ReturnType<
typeof useFormInit<ClientRouteFormObject>
>;
@@ -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<ClientRouteView>({
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<BUN_SQLITE_WGUI_HOSTS>({
table: "hosts",
ready: Boolean(show_host),
sql_query: {
order: { field: "id", strategy: "ASC" },
},
});
const hosts_by_id = useMemo(() => {
const map = new Map<number, BUN_SQLITE_WGUI_HOSTS>();
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 (
<Panel
flush
title={title}
description={description || (res ? `${res.length} configured` : undefined)}
actions={
add_href ? (
<Button
title="Add route"
href={add_href}
size="small"
beforeIcon={<Plus size={14} />}
>
Add route
</Button>
) : undefined
}
>
<Stack className="px-4 pt-4 items-stretch">
<InlineAlert
tone="info"
msg="Certificates are configured and issued on each route page."
/>
</Stack>
<ClientRoutesTable
routes={res}
hosts_by_id={hosts_by_id}
show_host={show_host}
loading={!res}
onDeleted={onDeleted}
empty={
<EmptyState
icon={<Network size={18} />}
title="No routes yet"
description="Expose an internal HTTP service to WireGuard clients over HTTPS."
action={
add_href ? (
<Button
title="Add route"
href={add_href}
size="small"
>
Add route
</Button>
) : undefined
}
/>
}
/>
</Panel>
);
}
@@ -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<number, BUN_SQLITE_WGUI_HOSTS>;
show_host?: boolean;
onDeleted?: ({ id }: { id: number }) => void;
};
export default function ClientRouteMobileCard({
route,
hosts_by_id,
show_host,
onDeleted,
}: Props) {
return (
<Card noHover className="p-4">
<Stack className="gap-2">
<Row className="justify-between gap-2">
<Stack className="gap-1 min-w-0">
<ClientRouteNameCell route={route} />
{show_host ? (
<MutedText>
{hosts_by_id?.get(Number(route.host_id || 0))
?.name || `Host #${Number(route.host_id || 0)}`}
</MutedText>
) : null}
<MutedText>{route.upstream_url || "—"}</MutedText>
</Stack>
<ClientRouteRowActions route={route} onDeleted={onDeleted} />
</Row>
<Row className="gap-3 flex-wrap">
<MutedText>
{route.path_prefix || "/"} ·{" "}
{route.enabled == 0 ? "Disabled" : "Enabled"}
</MutedText>
<StatusDot
tone={
route.certificate_status == "active"
? "success"
: route.certificate_status == "error"
? "error"
: route.certificate_status == "expiring" ||
route.certificate_status == "issuing"
? "warning"
: "neutral"
}
label={route.certificate_status || "missing"}
/>
</Row>
</Stack>
</Card>
);
}
@@ -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 (
<Link
href={`/admin/hosts/${host_id}/routes/${Number(route.id || 0)}`}
className="border-b-0! no-underline!"
>
<MonoText className="text-fg hover:text-primary">
{route.hostname || route.name || "—"}
</MonoText>
</Link>
);
}
@@ -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 (
<ActionMenu
label={`Actions for ${route.name || route.hostname || route_id}`}
className="items-end"
items={[
{
key: "open",
title: "Open",
href: `/admin/hosts/${host_id}/routes/${route_id}`,
icon: <Eye size={14} />,
},
{
key: "edit",
title: "Edit",
href: `/admin/hosts/${host_id}/routes/edit/${route_id}`,
icon: <Pencil size={14} />,
},
{
key: "delete",
title: "Delete",
tone: "danger",
icon: <Trash2 size={14} />,
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 });
}
},
},
]}
/>
);
}
@@ -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<number, BUN_SQLITE_WGUI_HOSTS>;
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<ClientRouteView>[] {
const columns: DataTableColumn<ClientRouteView>[] = [
{
key: "name",
header: "Route",
render: ({ item }) => <ClientRouteNameCell route={item} />,
},
];
if (show_host) {
columns.push({
key: "host",
header: "Host",
hide_below: "lg",
render: ({ item }) => (
<MutedText>
{hosts_by_id?.get(Number(item.host_id || 0))?.name ||
`Host #${Number(item.host_id || 0)}`}
</MutedText>
),
});
}
columns.push(
{
key: "path",
header: "Path",
render: ({ item }) => <MonoText>{item.path_prefix || "/"}</MonoText>,
},
{
key: "upstream",
header: "Upstream",
hide_below: "lg",
render: ({ item }) => (
<MonoText className="text-muted">{item.upstream_url || "—"}</MonoText>
),
},
{
key: "certificate",
header: "Certificate",
render: ({ item }) => (
<StatusDot
tone={certificateTone(item.certificate_status)}
label={item.certificate_status || "missing"}
/>
),
},
{
key: "state",
header: "State",
render: ({ item }) =>
item.enabled == 0 ? (
<MutedText>Disabled</MutedText>
) : (
<MutedText>Enabled</MutedText>
),
},
{
key: "actions",
header: <span className="sr-only">Actions</span>,
align: "right",
className: "w-10",
render: ({ item }) => (
<ClientRouteRowActions route={item} onDeleted={onDeleted} />
),
},
);
return columns;
}
@@ -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<number, BUN_SQLITE_WGUI_HOSTS>;
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 }) => (
<ClientRouteMobileCard
route={item}
hosts_by_id={hosts_by_id}
show_host={show_host}
onDeleted={onDeleted}
/>
),
[hosts_by_id, show_host, onDeleted],
);
return (
<DataTable
caption="Client routes"
items={routes}
columns={columns}
getKey={({ item }) => Number(item.id)}
loading={loading}
empty={empty}
skeleton_rows={3}
MobileItem={MobileItem}
/>
);
}
+3 -1
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import Form from "@/src/components/twui/form/Form"; import Form from "@/src/components/twui/form/Form";
import useFormInit from "@/src/hooks/use-form-init"; import useFormInit from "@/src/hooks/use-form-init";
import useToast from "@/src/hooks/use-toast"; import useToast from "@/src/hooks/use-toast";
import useConfirm from "@/src/hooks/use-confirm";
import type { UserFormObject } from "@/src/types"; import type { UserFormObject } from "@/src/types";
import type { BUN_SQLITE_WGUI_USERS_JOIN } from "@/src/types/sql-joins"; import type { BUN_SQLITE_WGUI_USERS_JOIN } from "@/src/types/sql-joins";
import InlineAlert from "../inline-alert"; import InlineAlert from "../inline-alert";
@@ -22,6 +23,7 @@ type Props = {
export default function UserForm({ existing_user, existing_user_full, is_first_user }: Props) { export default function UserForm({ existing_user, existing_user_full, is_first_user }: Props) {
const { toast } = useToast(); const { toast } = useToast();
const { confirm } = useConfirm();
const init = useFormInit<UserFormObject>({ const init = useFormInit<UserFormObject>({
existing: existing_user, existing: existing_user,
@@ -40,7 +42,7 @@ export default function UserForm({ existing_user, existing_user_full, is_first_u
className="w-full gap-5" className="w-full gap-5"
onSubmit={() => { onSubmit={() => {
if (!passwords_match) return; if (!passwords_match) return;
submitUserForm({ init, toast }); submitUserForm({ init, toast, confirm });
}} }}
> >
{status?.error ? <InlineAlert tone="error" msg={status.msg} /> : null} {status?.error ? <InlineAlert tone="error" msg={status.msg} /> : null}
@@ -12,15 +12,40 @@ import fetchApi from "../../twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types"; import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { ToastContextValue } from "../toast/toast-context"; import type { ToastContextValue } from "../toast/toast-context";
import type { ConfirmContextValue } from "../confirm/confirm-context";
type Params = { type Params = {
init: ReturnType<typeof useFormInit<UserFormObject>>; init: ReturnType<typeof useFormInit<UserFormObject>>;
toast: ToastContextValue["toast"]; 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 { 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 { try {
const new_user_data: BUN_SQLITE_WGUI_USERS = _.pick< const new_user_data: BUN_SQLITE_WGUI_USERS = _.pick<
UserFormObject, UserFormObject,
+35
View File
@@ -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;
@@ -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<APIResponseObject> {
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 },
};
}
}
@@ -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),
};
}
@@ -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");
});
});
@@ -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,
};
}
@@ -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.
}
}
@@ -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<string> {
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;
}
@@ -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<ClientCertificateView | null> {
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,
});
}
@@ -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,
};
}
@@ -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<APIResponseObject> {
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,
}),
};
}
}
@@ -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<number, ManualDnsChallenge>();
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);
}
@@ -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);
});
});
@@ -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<string, string> = {};
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,
};
}
@@ -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<string, string>;
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<string, string> = {};
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,
};
}
@@ -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;
}
}
@@ -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<string, string>;
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<string, string> = {};
if (route?.acme_credentials) {
try {
const decrypted = await decrypt(route.acme_credentials);
const parsed = JSON.parse(decrypted) as Record<string, string>;
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,
},
};
}
@@ -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],
});
}
@@ -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;
}
@@ -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)),
};
}
@@ -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<APIResponseObject> {
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`,
};
}
}
@@ -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}`,
};
}
@@ -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>,
): 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");
});
});
@@ -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<number, string>();
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,
};
}
@@ -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,
};
}
@@ -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"),
};
}
@@ -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<typeof grabSystemSetupStatus>["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`,
};
}
}
@@ -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,
};
}
@@ -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<CaddySyncResult> = 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<CaddySyncResult> {
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<BUN_SQLITE_WGUI_HOSTS, TableType>({
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<CaddySyncResult> {
const run = sync_chain.then(
() => doSync(params || {}),
() => doSync(params || {}),
);
sync_chain = run.then(
() => ({ success: true, warnings: [] }),
() => ({ success: true, warnings: [] }),
);
return run;
}
@@ -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);
}
});
});
@@ -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,
};
}
@@ -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);
}
});
});
@@ -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,
};
}
@@ -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:[email protected]: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);
}
});
});
@@ -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:",
};
}
@@ -52,11 +52,18 @@ export default async function grabClients(
sql += `LEFT JOIN hosts h ON c.host_id = h.id\n`; sql += `LEFT JOIN hosts h ON c.host_id = h.id\n`;
sql += `WHERE 1=1\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_user_id = params?.user_id || query?.user_id;
const target_client_id = params?.client_id || query?.client_id || query?.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`; sql += ` AND c.host_id=?\n`;
values.push(target_host_id); values.push(target_host_id);
} }
@@ -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", () => { test("rejects oversized host id", () => {
const res = buildHostIptablesScripts({ const res = buildHostIptablesScripts({
...base, ...base,
@@ -19,6 +19,8 @@ type Params = {
host_subnet?: string; host_subnet?: string;
sibling_subnets?: string[]; sibling_subnets?: string[];
clients?: HostIptablesClient[]; 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._-]*$/; const SAFE_IFACE = /^[A-Za-z][A-Za-z0-9._-]*$/;
@@ -148,6 +150,7 @@ export default function buildHostIptablesScripts({
host_subnet, host_subnet,
sibling_subnets, sibling_subnets,
clients, clients,
ingress_ports,
}: Params) { }: Params) {
if (!Number.isSafeInteger(host_id) || host_id < 0 || host_id > 999999999) { if (!Number.isSafeInteger(host_id) || host_id < 0 || host_id > 999999999) {
return { return {
@@ -184,6 +187,21 @@ export default function buildHostIptablesScripts({
const accept_lines: string[] = []; const accept_lines: string[] = [];
const host_clients = clients || []; 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++) { for (let i = 0; i < host_clients.length; i++) {
const client = host_clients[i]; const client = host_clients[i];
@@ -84,6 +84,11 @@ const TOOL_CHECKS: {
command: "bun", command: "bun",
version_command: "bun --version", version_command: "bun --version",
}, },
{
name: "caddy",
command: "/opt/caddy/caddy",
version_command: "/opt/caddy/caddy version",
},
]; ];
const tryShell = ({ const tryShell = ({
@@ -21,7 +21,10 @@ export default async function readWireguardClientConfig({
} }
const { HOST_CLIENTS_DIR } = grabHostDirnames({ 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( const config_path = path.join(
@@ -17,6 +17,7 @@ import grabClientDirnames from "./grab-client-dir-names";
import buildHostIptablesScripts from "./build-host-iptables-scripts"; import buildHostIptablesScripts from "./build-host-iptables-scripts";
import allowSubnetFirewallRules from "../firewall/allow-subnet-firewall-rules"; import allowSubnetFirewallRules from "../firewall/allow-subnet-firewall-rules";
import { AppData } from "@/src/data/app-data"; 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 } = const { WIREGUARD_PRIVATE_KEY_FILE_NAME, WIREGUARD_PUBLIC_KEY_FILE_NAME } =
grabDirNames(); grabDirNames();
@@ -215,6 +216,9 @@ export default async function setupWireguardHost({
interface_name: INTERFACE_NAME, interface_name: INTERFACE_NAME,
host_subnet: HOST_SUBNET, host_subnet: HOST_SUBNET,
sibling_subnets, sibling_subnets,
ingress_ports: TlsData.IngressEnabled
? [TlsData.CaddyHttpsPort]
: [],
clients: clients.map((client) => ({ clients: clients.map((client) => ({
id: client.id, id: client.id,
wg_ip_address: client.wg_ip_address, wg_ip_address: client.wg_ip_address,
@@ -1,6 +1,7 @@
import { import {
LayoutDashboard, LayoutDashboard,
LogOut, LogOut,
Network,
Server, Server,
Settings, Settings,
Users, Users,
@@ -34,6 +35,7 @@ export const AdminNav: AdminNavGroup[] = [
items: [ items: [
{ title: "Hosts", url: "/admin/hosts", Icon: Server }, { title: "Hosts", url: "/admin/hosts", Icon: Server },
{ title: "Clients", url: "/admin/clients", Icon: Users }, { title: "Clients", url: "/admin/clients", Icon: Users },
{ title: "Routes", url: "/admin/routes", Icon: Network },
], ],
}, },
{ {
@@ -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 ActionMenu from "@/src/components/general/action-menu";
import useDeleteHost from "@/src/components/general/use-delete-host"; import useDeleteHost from "@/src/components/general/use-delete-host";
import deriveHostDisplayName from "@/src/utils/derive-host-display-name"; import deriveHostDisplayName from "@/src/utils/derive-host-display-name";
@@ -20,6 +20,7 @@ export default function HostRowActions({ host, onDeleted }: Props) {
items={[ items={[
{ key: "view", title: "View", href: base, icon: <Eye size={14} /> }, { key: "view", title: "View", href: base, icon: <Eye size={14} /> },
{ key: "clients", title: "Clients", href: `${base}/clients`, icon: <Users size={14} /> }, { key: "clients", title: "Clients", href: `${base}/clients`, icon: <Users size={14} /> },
{ key: "routes", title: "Client routes", href: `${base}/routes`, icon: <Network size={14} /> },
{ {
key: "add-client", key: "add-client",
title: "Add client", title: "Add client",
@@ -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 useDeleteHost from "@/src/components/general/use-delete-host";
import type { ActionMenuItem } from "@/src/components/general/action-menu/types"; import type { ActionMenuItem } from "@/src/components/general/action-menu/types";
import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db"; 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[] = [ const actions: ActionMenuItem[] = [
{ key: "clients", title: "Clients", href: `/admin/hosts/${host_id}/clients`, icon: <Users size={14} /> }, { key: "clients", title: "Clients", href: `/admin/hosts/${host_id}/clients`, icon: <Users size={14} /> },
{ key: "routes", title: "Client routes", href: `/admin/hosts/${host_id}/routes`, icon: <Network size={14} /> },
{ key: "edit", title: "Edit", href: `/admin/hosts/${host_id}/edit`, icon: <Pencil size={14} /> }, { key: "edit", title: "Edit", href: `/admin/hosts/${host_id}/edit`, icon: <Pencil size={14} /> },
...(host ...(host
? [ ? [
@@ -10,6 +10,7 @@ import { AppContext } from "@/src/pages/__root";
import deriveHostDisplayName from "@/src/utils/derive-host-display-name"; import deriveHostDisplayName from "@/src/utils/derive-host-display-name";
import HostStatsSection from "./(sections)/host-stats-section"; import HostStatsSection from "./(sections)/host-stats-section";
import HostInterfaceSection from "./(sections)/host-interface-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 HostHeaderMeta from "./(partials)/host-header-meta";
import useHostHeaderActions from "./(hooks)/use-host-header-actions"; import useHostHeaderActions from "./(hooks)/use-host-header-actions";
@@ -45,6 +46,11 @@ export default function HostDetailPage() {
<PageBody> <PageBody>
<HostStatsSection host={host} clients={clients} /> <HostStatsSection host={host} clients={clients} />
<HostClientsPanel host_id={host_id} /> <HostClientsPanel host_id={host_id} />
<ClientRoutesPanel
host_id={host_id}
title="Routes"
add_href={`/admin/hosts/${host_id}/routes/add`}
/>
<HostInterfaceSection host={host} clients={clients} /> <HostInterfaceSection host={host} clients={clients} />
</PageBody> </PageBody>
</> </>
@@ -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: <Pencil size={14} />,
};
const actions: ActionMenuItem[] = route
? [
{
key: "delete",
title: "Delete route",
tone: "danger",
icon: <Trash2 size={14} />,
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 };
}
@@ -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<ClientCertificateView | null>(
certificate || null,
);
const [challenge, setChallenge] = useState<ManualChallenge | null>(
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<ApiReqParams, APIResponseObject>(
`/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<ApiReqParams, APIResponseObject>(
`/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<ApiReqParams, APIResponseObject>(
`/api/admin/tls/manual/cancel`,
{
method: "POST",
body: { route_id } as ApiReqParams,
},
);
setChallenge(null);
}
return (
<Panel
title="Certificate"
description="Manual DNS-01. Add the TXT record at your registrar, then validate."
>
<Stack className="gap-4 items-stretch">
{!configured ? (
<InlineAlert
tone="warning"
msg="Set the ACME email on the route form before issuing a certificate."
/>
) : null}
{current?.last_error ? (
<InlineAlert tone="error" msg={current.last_error} />
) : null}
{challenge ? (
<Stack className="gap-4 items-stretch">
<InlineAlert
tone="info"
msg="Add this TXT record at Cloudflare (DNS only). Wait for propagation, then click Validate. You can remove the TXT record after the certificate is issued."
/>
<KeyValueList
items={[
{
key: "txt_name",
label: "TXT name",
value: challenge.txt_name,
mono: true,
copy_value: challenge.txt_name,
},
{
key: "txt_value",
label: "TXT value",
value: challenge.txt_value,
mono: true,
copy_value: challenge.txt_value,
},
]}
/>
<Row className="gap-2 flex-wrap">
<Button
title="Validate the TXT record"
type="button"
size="small"
loading={busy}
onClick={complete}
>
Validate
</Button>
<Button
title="Cancel pending certificate request"
type="button"
variant="outlined"
color="gray"
size="small"
loading={busy}
onClick={cancel}
>
Cancel
</Button>
</Row>
</Stack>
) : (
<Row className="justify-between gap-3 flex-wrap">
<Stack className="gap-1">
<Row className="gap-2 flex-wrap">
<StatusDot
tone={certificateTone(current?.status)}
label={current?.status || "missing"}
/>
<MutedText>
{expiryLabel({
expires_at: current?.expires_at,
})}
</MutedText>
</Row>
<Row className="gap-2 flex-wrap">
<MonoText>{route.hostname || "—"}</MonoText>
<MutedText>
{route.acme_staging == 1
? "Let's Encrypt staging"
: "Let's Encrypt production"}
</MutedText>
</Row>
</Stack>
{is_active ? (
<Button
title={`Renew certificate for ${route.hostname}`}
type="button"
variant="outlined"
color="gray"
size="small"
loading={busy}
disabled={!configured}
onClick={() => start({ force: true })}
beforeIcon={<RefreshCw size={14} />}
>
Renew
</Button>
) : (
<Button
title={`Issue certificate for ${route.hostname}`}
type="button"
size="small"
loading={busy}
disabled={!configured}
onClick={() => start({})}
beforeIcon={<ShieldCheck size={14} />}
>
Issue
</Button>
)}
</Row>
)}
</Stack>
</Panel>
);
}
@@ -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 (
<Panel
title="Route"
description="Hostname, path and upstream for this route."
actions={
<Button
title="Edit route"
href={`/admin/hosts/${host_id}/routes/edit/${route_id}`}
variant="outlined"
color="gray"
size="small"
beforeIcon={<Pencil size={14} />}
>
Edit
</Button>
}
>
<KeyValueList
items={[
{
key: "host",
label: "Host",
value: host?.name || `Host #${host_id}`,
},
{
key: "hostname",
label: "Hostname",
value: route.hostname || "—",
mono: true,
copy_value: route.hostname || "",
},
{
key: "path",
label: "Path prefix",
value: route.path_prefix || "/",
mono: true,
},
{
key: "strip",
label: "Strip prefix",
value: route.strip_prefix == 1 ? "Yes" : "No",
},
{
key: "upstream",
label: "Upstream",
value: route.upstream_url || "—",
mono: true,
copy_value: route.upstream_url || "",
},
{
key: "verify",
label: "Verify upstream TLS",
value: route.upstream_tls_verify == 0 ? "No" : "Yes",
},
{
key: "enabled",
label: "State",
value: route.enabled == 0 ? "Disabled" : "Enabled",
},
{
key: "email",
label: "ACME email",
value: route.acme_email || "Not set",
mono: true,
},
{
key: "challenge",
label: "Challenge",
value: "Manual DNS-01",
},
{
key: "staging",
label: "Staging CA",
value: route.acme_staging == 1 ? "Yes" : "No",
},
]}
/>
</Panel>
);
}
@@ -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<PagePropsType> = async ({ query }) => {
const page_query = query as RoutePageQuery;
const route_id = Number(page_query.route_id || 0);
const host_res = await BunSQLite.select<BUN_SQLITE_WGUI_HOSTS, TableType>({
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;
@@ -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 (
<>
<PageHeader
title="Route"
back={{
href: `/admin/hosts/${host_id}/routes`,
title: "Routes",
}}
/>
<PageBody>
<EmptyState
icon={<Network size={18} />}
title="Route not found"
description="It may have been deleted."
/>
</PageBody>
</>
);
}
return (
<>
<PageHeader
title={route.hostname || route.name || "Route"}
description={route.name || route.notes || undefined}
back={{
href: `/admin/hosts/${host_id}/routes`,
title: "Routes",
}}
primary_action={primary_action}
actions={actions}
/>
<PageBody>
<RouteOverviewSection route={route} host={host} />
<RouteCertificateSection
route={route}
certificate={certificate}
pending_challenge={challenge}
/>
</PageBody>
</>
);
}
export const meta: BunextPageModuleMeta = {
title: `Route | ${SiteData["SiteName"]}`,
description: `TLS route details`,
};
@@ -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<PagePropsType> = async ({ query }) => {
const page_query = query as PageQueryObject;
const host_res = await BunSQLite.select<BUN_SQLITE_WGUI_HOSTS, TableType>({
table: "hosts",
targetId: page_query.host_id,
});
return {
props: {
host: host_res.singleRes || null,
},
};
};
export default server;
@@ -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 (
<>
<PageHeader
title="Add route"
description="Terminate TLS for a hostname and forward it to an internal service."
back={{
href: `/admin/hosts/${host_id}/routes`,
title: "Routes",
}}
/>
<PageBody>
<ClientRouteForm
host_id={host_id}
cancel_href={`/admin/hosts/${host_id}/routes`}
/>
</PageBody>
</>
);
}
export const meta: BunextPageModuleMeta = {
title: `Add route | ${SiteData["SiteName"]}`,
description: `Add a TLS route for a WireGuard host`,
};
@@ -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<PagePropsType> = async ({ query }) => {
const page_query = query as RouteEditPageQuery;
const route_id = Number(page_query.route_id || 0);
const host_res = await BunSQLite.select<BUN_SQLITE_WGUI_HOSTS, TableType>({
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;
@@ -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 (
<>
<PageHeader
title="Edit route"
description="Changes regenerate the Caddyfile and reload the ingress."
back={{
href: `/admin/hosts/${host_id}/routes`,
title: "Routes",
}}
/>
<PageBody>
{route ? (
<ClientRouteForm
host_id={host_id}
existing_route={route}
cancel_href={`/admin/hosts/${host_id}/routes`}
/>
) : (
<EmptyState
title="Route not found"
description="It may have been deleted."
/>
)}
</PageBody>
</>
);
}
export const meta: BunextPageModuleMeta = {
title: `Edit route | ${SiteData["SiteName"]}`,
description: `Edit a TLS route for a WireGuard host`,
};
@@ -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<PagePropsType> = async ({ query }) => {
const page_query = query as PageQueryObject;
const host_res = await BunSQLite.select<BUN_SQLITE_WGUI_HOSTS, TableType>({
table: "hosts",
targetId: page_query.host_id,
});
return {
props: {
host: host_res.singleRes || null,
},
};
};
export default server;
@@ -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 (
<>
<PageHeader
title="Routes"
description="Internal services exposed over HTTPS to this host's WireGuard clients."
back={{ href: `/admin/hosts/${host_id}`, title: host?.name || "Host" }}
/>
<PageBody>
<ClientRoutesPanel
host_id={host_id}
title="Routes"
add_href={`/admin/hosts/${host_id}/routes/add`}
/>
</PageBody>
</>
);
}
export const meta: BunextPageModuleMeta = {
title: `Routes | ${SiteData["SiteName"]}`,
description: `TLS routes for a WireGuard host`,
};
+28
View File
@@ -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 (
<>
<PageHeader
title="Routes"
description="HTTPS services exposed to WireGuard clients, with their host and certificate status."
/>
<PageBody>
<ClientRoutesPanel
show_host
title="All routes"
description="Open a route to edit its TLS settings, issue certificates or inspect its upstream."
/>
</PageBody>
</>
);
}
export const meta: BunextPageModuleMeta = {
title: `Routes | ${SiteData["SiteName"]}`,
description: `TLS routes exposed to WireGuard clients`,
};
@@ -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"; import type { SubNavItemType } from "@/src/components/general/sub-nav/sub-nav-item";
export const SetupNav: SubNavItemType[] = [ export const SetupNav: SubNavItemType[] = [
{ title: "System status", href: "/admin/setup/status", Icon: Activity }, { title: "System status", href: "/admin/setup/status", Icon: Activity },
{ title: "Maintenance", href: "/admin/setup/maintenance", Icon: Wrench }, { title: "Maintenance", href: "/admin/setup/maintenance", Icon: Wrench },
{ title: "Firewall", href: "/admin/setup/firewall", Icon: Shield }, { title: "Firewall", href: "/admin/setup/firewall", Icon: Shield },
{ title: "TLS ingress", href: "/admin/setup/tls", Icon: LockKeyhole },
]; ];
@@ -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<ReturnType<typeof grabTlsStatus>>;
type TlsStatusResponse = APIResponseObject & {
singleRes?: TlsStatus | null;
};
export default function useTlsStatus() {
const [tls_status, setTlsStatus] = useState<TlsStatus | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = () =>
fetchApi<ApiReqParams, TlsStatusResponse>(`/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<ApiReqParams, APIResponseObject>(`/api/admin/tls/sync`, {
method: "POST",
}).then(async (res) => {
await refresh();
return res;
});
useEffect(() => {
refreshWithLoading();
}, []);
return {
tls_status,
refresh: refreshWithLoading,
sync,
loading,
error,
};
}
@@ -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 (
<SetupActionRow
title="Caddy TLS ingress"
description="Installs or repairs the Caddy binary and the wgui-caddy service. Certificate issuance is handled by wg-ui with manual DNS-01. Safe to re-run."
icon={<ServerCog size={15} />}
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<ApiReqParams, APIResponseObject>(
`/api/admin/install-caddy`,
{ method: "POST" },
);
on_done?.();
return res;
}}
/>
);
}
@@ -5,6 +5,7 @@ import type { ApiReqParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types"; import type { APIResponseObject } from "@moduletrace/bunext/types";
import type useSystemSetupStatus from "../(hooks)/use-system-setup-status"; import type useSystemSetupStatus from "../(hooks)/use-system-setup-status";
import SetupActionRow from "../(partials)/setup-action-row"; import SetupActionRow from "../(partials)/setup-action-row";
import SetupCaddyAction from "../(partials)/setup-caddy-action";
import SetupUpdateWgUiAction from "../(partials)/setup-update-wg-ui-action"; import SetupUpdateWgUiAction from "../(partials)/setup-update-wg-ui-action";
type Props = { type Props = {
@@ -24,6 +25,11 @@ export default function SetupActionsSection({ setup }: Props) {
.filter((tool) => WG_TOOL_COMMANDS.includes(tool.command)) .filter((tool) => WG_TOOL_COMMANDS.includes(tool.command))
.every((tool) => tool.installed); .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"; const show_restart = !setup_status.is_dev && setup_status.init_system !== "unknown";
return ( return (
@@ -43,6 +49,11 @@ export default function SetupActionsSection({ setup }: Props) {
}} }}
/> />
<SetupCaddyAction
installed={have_caddy}
on_done={() => setup.refresh()}
/>
<SetupUpdateWgUiAction setup={setup} /> <SetupUpdateWgUiAction setup={setup} />
{show_restart ? ( {show_restart ? (
@@ -12,7 +12,7 @@ type Props = PropsWithChildren & {
is_root?: boolean; 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) { export default function SetupPageFrame({ title, description, is_root, children }: Props) {
return ( return (
<> <>
@@ -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<typeof useTlsStatus>;
};
export default function TlsInstallSection({ tls }: Props) {
return (
<Panel flush>
<SetupCaddyAction
installed={tls.tls_status?.caddy.installed}
on_done={tls.refresh}
/>
</Panel>
);
}
@@ -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<typeof useTlsStatus>;
};
export default function TlsSetupSection({ tls }: Props) {
const caddy = tls.tls_status?.caddy;
return (
<Panel
title="Route certificates"
description="Certificates are issued per route with manual DNS-01. No DNS provider token is stored."
actions={
<Button
title="Open routes"
href="/admin/routes"
size="small"
afterIcon={<ArrowRight size={14} />}
>
Open routes
</Button>
}
>
<Stack className="gap-4 items-stretch">
{!caddy ? (
<InlineAlert tone="info" msg="Loading TLS ingress status…" />
) : !caddy.installed ? (
<InlineAlert
tone="warning"
msg="Caddy is not installed. Use Install Caddy above to install the ingress."
/>
) : null}
<P className="text-[15px]!">
Open a route to issue or renew its certificate. The dashboard
displays the exact <MonoText>_acme-challenge</MonoText> TXT record;
add it at Cloudflare, wait for propagation, then click
Validate.
</P>
<P className="text-[15px]!">
Certificates are renewed manually from the route page because
DNS validation needs your registrar. The route list shows
expiry warnings.
</P>
<P className="text-[15px]!">
The Caddy ingress listens on port {caddy?.https_port || 8443}.
It never binds ports 80 or 443.
</P>
</Stack>
</Panel>
);
}
@@ -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<typeof useTlsStatus>;
};
export default function TlsStatusSection({ tls }: Props) {
const { tls_status, refresh, sync, loading, error } = tls;
const caddy = tls_status?.caddy;
return (
<Panel
title="Ingress"
description="Caddy runs as its own service and terminates TLS on the configured high port."
actions={
<>
<StatusDot
tone={
!caddy?.installed
? "error"
: caddy.service_active === false
? "warning"
: caddy.state?.last_error
? "warning"
: "success"
}
label={
!caddy?.installed
? "Caddy not installed"
: caddy.service_active === false
? "wgui-caddy stopped"
: caddy.version || "Caddy installed"
}
/>
<Button
title="Sync Caddy config"
type="button"
variant="outlined"
color="gray"
size="small"
loading={loading}
onClick={() => void sync()}
beforeIcon={<RefreshCw size={14} />}
>
Sync
</Button>
<Button
title="Refresh TLS status"
type="button"
variant="outlined"
color="gray"
size="small"
loading={loading}
onClick={refresh}
>
Refresh
</Button>
</>
}
>
<Stack className="gap-4 items-stretch">
{error ? <InlineAlert tone="error" msg={error} /> : null}
{!caddy?.installed ? (
<InlineAlert
tone="warning"
msg="Caddy is not installed yet. Run the installer/update script, or install it to /opt/caddy/caddy."
/>
) : null}
{caddy?.state?.last_error ? (
<InlineAlert
tone="error"
msg={`Last reload: ${caddy.state.last_error}`}
/>
) : null}
{caddy?.state?.warnings?.length ? (
<InlineAlert
tone="warning"
msg={caddy.state.warnings.join("; ")}
/>
) : null}
<KeyValueList
items={[
{
key: "ingress",
label: "HTTPS port",
value: caddy
? `${caddy.https_port}/tcp`
: "—",
},
{
key: "config",
label: "Caddyfile",
value: caddy?.config_path || "—",
mono: true,
},
{
key: "reload",
label: "Last reload",
value: caddy?.state?.last_reload_at
? new Date(
caddy.state.last_reload_at,
).toLocaleString()
: "Never",
},
]}
/>
</Stack>
</Panel>
);
}
+15
View File
@@ -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<PagePropsType> = async ({ req }) => {
const redirect = await guardSetupPage({ req });
if (redirect) {
return { redirect };
}
return { props: {} };
};
export default server;
+27
View File
@@ -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 (
<SetupPageFrame
title="TLS ingress"
description="Serve configured client routes over HTTPS without touching the host's ports 80 or 443."
>
<TlsStatusSection tls={tls} />
<TlsInstallSection tls={tls} />
<TlsSetupSection tls={tls} />
</SetupPageFrame>
);
}
export const meta: BunextPageModuleMeta = {
title: `TLS ingress | ${SiteData["SiteName"]}`,
description: `Caddy ingress and client-route certificates`,
};
@@ -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,
},
],
});
}
@@ -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<APIResponseObject> {
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 },
};
}
@@ -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<APIResponseObject> {
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,
};
}
@@ -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<APIResponseObject> {
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 };
}
}
@@ -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<APIResponseObject> {
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 },
};
}
@@ -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<APIResponseObject> {
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<BUN_SQLITE_WGUI_HOSTS, TableType>({
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 },
};
}
@@ -42,7 +42,7 @@ export default async function runClientSetup({
let host: BUN_SQLITE_WGUI_HOSTS | undefined; 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< const host_res = await BunSQLite.select<
BUN_SQLITE_WGUI_HOSTS, BUN_SQLITE_WGUI_HOSTS,
TableType TableType
@@ -100,7 +100,7 @@ export default async function (
try { try {
let host: BUN_SQLITE_WGUI_HOSTS | undefined; let host: BUN_SQLITE_WGUI_HOSTS | undefined;
if (host_id) { if (Number.isSafeInteger(host_id) && host_id >= 0) {
const host_res = await BunSQLite.select< const host_res = await BunSQLite.select<
BUN_SQLITE_WGUI_HOSTS, BUN_SQLITE_WGUI_HOSTS,
TableType TableType
+8
View File
@@ -15,6 +15,7 @@ import userAuth from "@/src/functions/backend/auth/user-auth";
import checkUserAccess from "@/src/functions/backend/auth/check-user-access"; import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import clients from "./(tables)/clients"; import clients from "./(tables)/clients";
import clientRules from "./(tables)/client_rules"; import clientRules from "./(tables)/client_rules";
import clientRoutes from "./(tables)/client_routes";
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ( export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
params, params,
@@ -64,6 +65,13 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
return await clients(crud_params); return await clients(crud_params);
case "client_rules": case "client_rules":
return await clientRules(crud_params); 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: default:
break; break;
@@ -33,7 +33,11 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
try { try {
let host: BUN_SQLITE_WGUI_HOSTS | undefined; 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< const host_res = await BunSQLite.select<
BUN_SQLITE_WGUI_HOSTS, BUN_SQLITE_WGUI_HOSTS,
TableType TableType
+62
View File
@@ -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<APIResponseObject> = 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 },
};
};
@@ -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<Result> {
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 };
}
+96
View File
@@ -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<APIResponseObject> = 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),
});
};
+37
View File
@@ -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<APIResponseObject> = 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`,
};
};
@@ -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<APIResponseObject> = 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,
});
};
+35
View File
@@ -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<APIResponseObject> = 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,
});
};

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