Add TLS feature with Caddy
This commit is contained in:
@@ -76,13 +76,13 @@ Server functions use `index.server.ts` alongside `index.tsx` (e.g. `single-docum
|
||||
|
||||
## Shared Code Organization
|
||||
|
||||
- **`src/functions/backend/`** — Server-only: `auth/`, `db/`, `firewall/` (firewalld/ufw rules, status), `media/`, `setup/` (host/client setup, iptables script builder, network detection).
|
||||
- **`src/functions/backend/`** — Server-only: `auth/`, `db/`, `firewall/` (firewalld/ufw rules, status), `media/`, `setup/` (host/client setup, iptables script builder, network detection), `caddy/` (Caddyfile generation, ingress sync, route validation), `acme/` (manual DNS-01 with `acme-client`, challenge state, certificate status). Certificates are configured per route in the route form; `/admin/setup/tls` is only for the global Caddy installation and status. The route page displays the `_acme-challenge` TXT record and validates after the user adds it at their DNS registrar.
|
||||
- **`src/functions/frontend/`** — Client-safe: CRUD/fetch handlers, media upload/delete, client rule label formatting.
|
||||
- **`src/functions/server/`** — Request-level server handlers (media server).
|
||||
- **`src/hooks/`** — `use-admin-crud-get` (with `refetch`), `use-client-rules-map`, `use-confirm`, `use-toast`, `use-theme`, `use-fetch-get`, `use-form-init`, `use-users-get`.
|
||||
- **`src/utils/`** — Cookies, crypto, dir names, IP/CIDR/port/interface validators, WireGuard interface naming. Most have `.test.ts` files.
|
||||
- **`src/dict/`**, **`src/data/`** — Constants (`app-data.ts`, `site-data.ts`), user types, client rules, links.
|
||||
- **`src/scripts/`** — Shell scripts: `install-wg-ui.sh`, `setup-wireguard.sh`, `wg-quick-manage.sh`.
|
||||
- **`src/scripts/`** — Shell scripts: `install-wg-ui.sh`, `install-caddy.sh` (idempotent Caddy/acme ingress installer used by the UI and the full installer), `setup-wireguard.sh`, `wg-quick-manage.sh`.
|
||||
- **`src/components/general/`** — Shared app components, all built from twui. Reach for these before writing page markup:
|
||||
- Page frame: `page-header` (title, meta, primary action + overflow menu), `page-body`, `panel` (the one card style), `form-section`, `form-actions` (submit is always rendered).
|
||||
- Text & status: `muted-text`, `mono-text` (IPs, keys, paths), `section-label`, `status-dot`, `inline-alert`, `empty-state`, `stat-tile`, `key-value-list`, `copy-button`, `copyable-value`.
|
||||
@@ -93,6 +93,8 @@ Server functions use `index.server.ts` alongside `index.tsx` (e.g. `single-docum
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Numeric IDs are not booleans — the main WireGuard host has `id = 0`.** Never test an ID with truthiness (`if (!host?.id)`, `if (host_id)`, `id ? ... : ...`); `0` is a valid ID and `AppData.WireguardMainHostID` is `0`. Use `typeof id === "number"` / `Number.isSafeInteger(id)` for numeric IDs, or explicit `id !== undefined && id !== ""` when the ID may arrive as a string. This applies to host lookups especially, but to route/cert IDs too.
|
||||
- **Every form that submits must ask for confirmation first.** Use `useConfirm()` and await `confirm({...})` before submitting; never use `window.confirm` or `window.alert`. This applies to create, update, settings, and auth forms.
|
||||
- Prefer one function/component per file over monolithic files with multiple exports.
|
||||
- **Every rendered array must have a separate component for each array item.** Always extract the item JSX into its own component and render it via `items.map((item) => <ItemComponent {...item} />)` — never inline the item's JSX inside the map callback.
|
||||
- Always use object parameters (`{ foo, bar }`) instead of comma-separated positional parameters.
|
||||
|
||||
@@ -49,8 +49,9 @@ The install script will:
|
||||
5. Install app dependencies (`bun install`)
|
||||
6. Generate `.env` with encryption secrets
|
||||
7. Run WireGuard setup (if available)
|
||||
8. Install the `wg-quick` helper script
|
||||
9. Set up a systemd or OpenRC service (`wgui`)
|
||||
8. Install Caddy (`/opt/caddy`)
|
||||
9. Install the `wg-quick` helper script
|
||||
10. Set up systemd/OpenRC services for `wgui`, `wgui-caddy`, and certificate renewal
|
||||
|
||||
The installer **never modifies your firewall** — it will print the commands to
|
||||
temporarily allow the dashboard port so you can reach the first login, and then
|
||||
@@ -82,6 +83,42 @@ Firewall rules are managed per host, automatically:
|
||||
Rules that no longer match any host (`temporary`/`orphaned`) are flagged
|
||||
with copyable removal commands on the setup page.
|
||||
|
||||
### TLS for Tunnel Services
|
||||
|
||||
Client routes expose internal services to WireGuard clients over HTTPS. The
|
||||
data plane is a dedicated Caddy service (`wgui-caddy`) on `8443`; wg-ui owns
|
||||
the route and certificate records and regenerates the Caddyfile. Caddy never
|
||||
binds ports 80 or 443, and the host's existing proxy is left alone.
|
||||
|
||||
1. If Caddy isn't installed yet, open **Setup → TLS ingress** and click
|
||||
**Install Caddy** (or use the Caddy action on **Setup → Maintenance**).
|
||||
The installer sets up the binary, the `wgui-caddy` service and certificate support.
|
||||
|
||||
2. Open a route form from **Routes** (or a host's **Routes** panel) and set
|
||||
the ACME email and staging toggle. No DNS provider token is needed:
|
||||
certificates use manual DNS-01.
|
||||
|
||||
Let's Encrypt requires a public domain you control; internal-only names such
|
||||
as `home.arpa` or `.local` need a private CA instead.
|
||||
|
||||
3. Add an A/AAAA record for each route hostname pointing at the host's
|
||||
WireGuard IP. Caddy also restricts each route to the subnet of the host that
|
||||
owns it.
|
||||
|
||||
4. Open the route page and click **Issue** (or **Renew**). The dashboard shows
|
||||
the exact `_acme-challenge.<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)
|
||||
|
||||
```bash
|
||||
@@ -105,6 +142,10 @@ The `.env` file (gitignored) controls runtime behavior. It is auto-generated dur
|
||||
| `ENCRYPTION_KEY` | Encryption key for sensitive data | Auto-generated |
|
||||
| `ENCRYPTION_SALT` | Encryption salt for sensitive data | Auto-generated |
|
||||
| `DATA_DIR` | Application data directory | `$INSTALL_DIR/.data` |
|
||||
| `CADDY_HTTPS_PORT`| Caddy ingress HTTPS port (never 80/443) | `8443` |
|
||||
| `CADDY_HTTP_PORT` | Caddy plain HTTP port (never 80) | `8081` |
|
||||
| `CADDY_INGRESS_ENABLED` | Set to `0` to skip Caddy config sync | `1` |
|
||||
| `ACME_STAGING` | Set to `1` to use the Let's Encrypt staging CA | `0` |
|
||||
|
||||
### Database Configuration
|
||||
|
||||
@@ -192,6 +233,8 @@ The database consists of the following tables:
|
||||
| `clients` | WireGuard clients per host |
|
||||
| `hosts` | WireGuard host configurations |
|
||||
| `client_rules` | IP table rules for each client |
|
||||
| `client_routes` | HTTPS routes exposed to WireGuard clients |
|
||||
| `tls_certificates`| Certificates used by the Caddy ingress |
|
||||
| `variables` | Application-wide key-value variables |
|
||||
|
||||
Generate types from the schema:
|
||||
@@ -206,9 +249,13 @@ bun run db:schema
|
||||
| ----------------------------------------------- | ------------ | ------------------------------------------------------------ |
|
||||
| `/admin` | Admin | Dashboard overview |
|
||||
| `/admin/hosts` | Admin | Host management (add, edit, per-host detail) |
|
||||
| `/admin/routes` | Admin | Global HTTPS route list with host and certificate status |
|
||||
| `/admin/hosts/[host_id]/routes` | Admin | HTTPS routes and upstreams exposed to tunnel clients |
|
||||
| `/admin/hosts/[host_id]/routes/[route_id]` | Admin | Route details, TLS settings and certificate issue/renew |
|
||||
| `/admin/clients` | Admin | Client management (add, edit, client rules per host) |
|
||||
| `/admin/settings` | Admin | Account settings |
|
||||
| `/admin/setup` | Super admin | System setup (hosts, WireGuard tools, update, restart) |
|
||||
| `/admin/setup/tls` | Super admin | Caddy ingress installation and status |
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"dependencies": {
|
||||
"@moduletrace/bun-sqlite": "^1.2.0",
|
||||
"@moduletrace/bunext": "^1.2.4",
|
||||
"acme-client": "5.4.0",
|
||||
"html-to-react": "^1.7.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^1.31.0",
|
||||
@@ -236,6 +237,30 @@
|
||||
|
||||
"@oven/bun-windows-x64-baseline": ["@oven/[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/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=="],
|
||||
|
||||
"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-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-styles": ["[email protected]", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"combined-stream": ["[email protected]", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"comma-separated-tokens": ["[email protected]", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"delayed-stream": ["[email protected]", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"dequal": ["[email protected]", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"es-define-property": ["[email protected]", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"get-caller-file": ["[email protected]", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"gopd": ["[email protected]", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"object-assign": ["[email protected]", "", {}, "sha512-c6legOHWepAbWnp3j5SRUMpxCXBKI4rD7A5Osn9IzZ8w4O/KccXdW0lqdkQKbpk0eHGjNgKihgzY6WuEq99Tfw=="],
|
||||
@@ -754,6 +827,12 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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/strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
+145
@@ -281,6 +281,151 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tableName: "client_routes",
|
||||
tableDescription: `TLS routes served to WireGuard clients`,
|
||||
fields: [
|
||||
{
|
||||
fieldName: "user_id",
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "host_id",
|
||||
dataType: "INTEGER",
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
fieldName: "name",
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "hostname",
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "path_prefix",
|
||||
dataType: "TEXT",
|
||||
defaultValue: "/",
|
||||
},
|
||||
{
|
||||
fieldName: "strip_prefix",
|
||||
dataType: "INTEGER",
|
||||
options: [0, 1],
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
fieldName: "upstream_url",
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "upstream_tls_verify",
|
||||
dataType: "INTEGER",
|
||||
options: [0, 1],
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
fieldName: "enabled",
|
||||
dataType: "INTEGER",
|
||||
options: [0, 1],
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
fieldName: "notes",
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "acme_email",
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "acme_dns_provider",
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "acme_staging",
|
||||
dataType: "INTEGER",
|
||||
options: [0, 1],
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
fieldName: "acme_credentials",
|
||||
dataType: "TEXT",
|
||||
},
|
||||
],
|
||||
uniqueConstraints: [
|
||||
{
|
||||
constraintName: "Unique path per route hostname",
|
||||
alias: "unique_path_per_route_hostname",
|
||||
constraintTableFields: [
|
||||
{ value: "hostname" },
|
||||
{ value: "path_prefix" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tableName: "tls_certificates",
|
||||
tableDescription: `TLS certificates served by the Caddy ingress`,
|
||||
fields: [
|
||||
{
|
||||
fieldName: "user_id",
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "hostname",
|
||||
dataType: "TEXT",
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
fieldName: "provider",
|
||||
dataType: "TEXT",
|
||||
defaultValue: "letsencrypt",
|
||||
},
|
||||
{
|
||||
fieldName: "challenge",
|
||||
dataType: "TEXT",
|
||||
defaultValue: "dns-01",
|
||||
},
|
||||
{
|
||||
fieldName: "dns_provider",
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "cert_path",
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "key_path",
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "expires_at",
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "status",
|
||||
dataType: "TEXT",
|
||||
options: [
|
||||
"missing",
|
||||
"issuing",
|
||||
"active",
|
||||
"error",
|
||||
"expiring",
|
||||
],
|
||||
defaultValue: "missing",
|
||||
},
|
||||
{
|
||||
fieldName: "last_error",
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "auto_renew",
|
||||
dataType: "INTEGER",
|
||||
options: [0, 1],
|
||||
defaultValue: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tableName: "variables",
|
||||
fields: [
|
||||
|
||||
+58
-1
@@ -6,6 +6,8 @@ export const BunSQLiteTables = [
|
||||
"clients",
|
||||
"hosts",
|
||||
"client_rules",
|
||||
"client_routes",
|
||||
"tls_certificates",
|
||||
"variables",
|
||||
] as const
|
||||
|
||||
@@ -162,6 +164,61 @@ export type BUN_SQLITE_WGUI_CLIENT_RULES = {
|
||||
protocol?: "any" | "tcp" | "udp" | "";
|
||||
}
|
||||
|
||||
export type BUN_SQLITE_WGUI_CLIENT_ROUTES = {
|
||||
/**
|
||||
* The unique identifier of the record.
|
||||
*/
|
||||
id?: number | "";
|
||||
/**
|
||||
* The time when the record was created. (Unix Timestamp)
|
||||
*/
|
||||
created_at?: number | "";
|
||||
/**
|
||||
* The time when the record was updated. (Unix Timestamp)
|
||||
*/
|
||||
updated_at?: number | "";
|
||||
user_id?: number | "";
|
||||
host_id?: number | "";
|
||||
name?: string;
|
||||
hostname?: string;
|
||||
path_prefix?: string;
|
||||
strip_prefix?: 0 | 1 | "";
|
||||
upstream_url?: string;
|
||||
upstream_tls_verify?: 0 | 1 | "";
|
||||
enabled?: 0 | 1 | "";
|
||||
notes?: string;
|
||||
acme_email?: string;
|
||||
acme_dns_provider?: string;
|
||||
acme_staging?: 0 | 1 | "";
|
||||
acme_credentials?: string;
|
||||
}
|
||||
|
||||
export type BUN_SQLITE_WGUI_TLS_CERTIFICATES = {
|
||||
/**
|
||||
* The unique identifier of the record.
|
||||
*/
|
||||
id?: number | "";
|
||||
/**
|
||||
* The time when the record was created. (Unix Timestamp)
|
||||
*/
|
||||
created_at?: number | "";
|
||||
/**
|
||||
* The time when the record was updated. (Unix Timestamp)
|
||||
*/
|
||||
updated_at?: number | "";
|
||||
user_id?: number | "";
|
||||
hostname?: string;
|
||||
provider?: string;
|
||||
challenge?: string;
|
||||
dns_provider?: string;
|
||||
cert_path?: string;
|
||||
key_path?: string;
|
||||
expires_at?: number | "";
|
||||
status?: "missing" | "issuing" | "active" | "error" | "expiring" | "";
|
||||
last_error?: string;
|
||||
auto_renew?: 0 | 1 | "";
|
||||
}
|
||||
|
||||
export type BUN_SQLITE_WGUI_VARIABLES = {
|
||||
/**
|
||||
* The unique identifier of the record.
|
||||
@@ -179,4 +236,4 @@ export type BUN_SQLITE_WGUI_VARIABLES = {
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export type BUN_SQLITE_WGUI_ALL_TYPEDEFS = BUN_SQLITE_WGUI_USERS & BUN_SQLITE_WGUI_USER_TYPES & BUN_SQLITE_WGUI_MEDIA & BUN_SQLITE_WGUI_MEDIA_PARADIGMS & BUN_SQLITE_WGUI_CLIENTS & BUN_SQLITE_WGUI_HOSTS & BUN_SQLITE_WGUI_CLIENT_RULES & BUN_SQLITE_WGUI_VARIABLES
|
||||
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
|
||||
@@ -31,6 +31,7 @@
|
||||
"dependencies": {
|
||||
"@moduletrace/bun-sqlite": "^1.2.0",
|
||||
"@moduletrace/bunext": "^1.2.4",
|
||||
"acme-client": "5.4.0",
|
||||
"html-to-react": "^1.7.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^1.31.0",
|
||||
|
||||
@@ -30,14 +30,23 @@ export default async function submitClientForm({
|
||||
const { setLoading, setStatus, form, existing_full, app_context, clearLocalForm } = init;
|
||||
const is_edit = Boolean(existing_full?.id);
|
||||
|
||||
if (is_edit) {
|
||||
const confirmed = await confirm({
|
||||
title: "Save changes?",
|
||||
message: "The client's config is regenerated. Devices may need the updated config.",
|
||||
confirm_label: "Save changes",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
}
|
||||
const confirmed = await confirm(
|
||||
is_edit
|
||||
? {
|
||||
title: "Save changes?",
|
||||
message:
|
||||
"The client's config is regenerated. Devices may need the updated config.",
|
||||
confirm_label: "Save changes",
|
||||
}
|
||||
: {
|
||||
title: "Create this client?",
|
||||
message:
|
||||
"A key pair and WireGuard config will be generated for this client.",
|
||||
confirm_label: "Create client",
|
||||
},
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import Form from "@/src/components/twui/form/Form";
|
||||
import useFormInit from "@/src/hooks/use-form-init";
|
||||
import useToast from "@/src/hooks/use-toast";
|
||||
import useConfirm from "@/src/hooks/use-confirm";
|
||||
import type { UserFormObject } from "@/src/types";
|
||||
import type { BUN_SQLITE_WGUI_USERS_JOIN } from "@/src/types/sql-joins";
|
||||
import InlineAlert from "../inline-alert";
|
||||
@@ -22,6 +23,7 @@ type Props = {
|
||||
|
||||
export default function UserForm({ existing_user, existing_user_full, is_first_user }: Props) {
|
||||
const { toast } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
const init = useFormInit<UserFormObject>({
|
||||
existing: existing_user,
|
||||
@@ -40,7 +42,7 @@ export default function UserForm({ existing_user, existing_user_full, is_first_u
|
||||
className="w-full gap-5"
|
||||
onSubmit={() => {
|
||||
if (!passwords_match) return;
|
||||
submitUserForm({ init, toast });
|
||||
submitUserForm({ init, toast, confirm });
|
||||
}}
|
||||
>
|
||||
{status?.error ? <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 { ToastContextValue } from "../toast/toast-context";
|
||||
import type { ConfirmContextValue } from "../confirm/confirm-context";
|
||||
|
||||
type Params = {
|
||||
init: ReturnType<typeof useFormInit<UserFormObject>>;
|
||||
toast: ToastContextValue["toast"];
|
||||
confirm: ConfirmContextValue["confirm"];
|
||||
};
|
||||
|
||||
export default async function submitUserForm({ init, toast }: Params) {
|
||||
export default async function submitUserForm({ init, toast, confirm }: Params) {
|
||||
const { setLoading, setStatus, form, existing_full, image, is_first_user } = init;
|
||||
|
||||
const is_update =
|
||||
existing_full?.id !== undefined && existing_full?.id !== "";
|
||||
const confirmed = await confirm(
|
||||
is_first_user
|
||||
? {
|
||||
title: "Create the admin account?",
|
||||
message:
|
||||
"This account becomes the super admin and is used to sign in.",
|
||||
confirm_label: "Create account",
|
||||
}
|
||||
: is_update
|
||||
? {
|
||||
title: "Save profile changes?",
|
||||
confirm_label: "Save changes",
|
||||
}
|
||||
: {
|
||||
title: "Create this user?",
|
||||
confirm_label: "Create user",
|
||||
},
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const new_user_data: BUN_SQLITE_WGUI_USERS = _.pick<
|
||||
UserFormObject,
|
||||
|
||||
@@ -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 += `WHERE 1=1\n`;
|
||||
|
||||
const target_host_id = params?.host_id || query?.host_id;
|
||||
const target_host_id =
|
||||
params?.host_id !== undefined
|
||||
? params.host_id
|
||||
: query?.host_id;
|
||||
const target_user_id = params?.user_id || query?.user_id;
|
||||
const target_client_id = params?.client_id || query?.client_id || query?.id;
|
||||
|
||||
if (target_host_id) {
|
||||
if (
|
||||
target_host_id !== undefined &&
|
||||
target_host_id !== null &&
|
||||
target_host_id !== ""
|
||||
) {
|
||||
sql += ` AND c.host_id=?\n`;
|
||||
values.push(target_host_id);
|
||||
}
|
||||
|
||||
@@ -455,6 +455,30 @@ describe("buildHostIptablesScripts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("allows the TLS ingress port from the wireguard interface", () => {
|
||||
const res = buildHostIptablesScripts({
|
||||
...base,
|
||||
clients: [],
|
||||
ingress_ports: [8443],
|
||||
});
|
||||
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.post_up).toContain(
|
||||
"iptables -A WGUI0IN -i wgui0 -p tcp --dport 8443 -j ACCEPT",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects an invalid ingress port", () => {
|
||||
const res = buildHostIptablesScripts({
|
||||
...base,
|
||||
clients: [],
|
||||
ingress_ports: [70000],
|
||||
});
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.msg).toBe("Invalid ingress port");
|
||||
});
|
||||
|
||||
test("rejects oversized host id", () => {
|
||||
const res = buildHostIptablesScripts({
|
||||
...base,
|
||||
|
||||
@@ -19,6 +19,8 @@ type Params = {
|
||||
host_subnet?: string;
|
||||
sibling_subnets?: string[];
|
||||
clients?: HostIptablesClient[];
|
||||
/** Host-level TCP ports clients may reach (e.g. the Caddy ingress). */
|
||||
ingress_ports?: number[];
|
||||
};
|
||||
|
||||
const SAFE_IFACE = /^[A-Za-z][A-Za-z0-9._-]*$/;
|
||||
@@ -148,6 +150,7 @@ export default function buildHostIptablesScripts({
|
||||
host_subnet,
|
||||
sibling_subnets,
|
||||
clients,
|
||||
ingress_ports,
|
||||
}: Params) {
|
||||
if (!Number.isSafeInteger(host_id) || host_id < 0 || host_id > 999999999) {
|
||||
return {
|
||||
@@ -184,6 +187,21 @@ export default function buildHostIptablesScripts({
|
||||
const accept_lines: string[] = [];
|
||||
const host_clients = clients || [];
|
||||
|
||||
for (let i = 0; i < (ingress_ports || []).length; i++) {
|
||||
const port = ingress_ports?.[i];
|
||||
|
||||
if (!Number.isInteger(port) || Number(port) < 1 || Number(port) > 65535) {
|
||||
return {
|
||||
success: false,
|
||||
msg: `Invalid ingress port`,
|
||||
};
|
||||
}
|
||||
|
||||
accept_lines.push(
|
||||
`iptables -A ${input} -i ${interface_name} -p tcp --dport ${port} -j ACCEPT`,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < host_clients.length; i++) {
|
||||
const client = host_clients[i];
|
||||
|
||||
|
||||
@@ -84,6 +84,11 @@ const TOOL_CHECKS: {
|
||||
command: "bun",
|
||||
version_command: "bun --version",
|
||||
},
|
||||
{
|
||||
name: "caddy",
|
||||
command: "/opt/caddy/caddy",
|
||||
version_command: "/opt/caddy/caddy version",
|
||||
},
|
||||
];
|
||||
|
||||
const tryShell = ({
|
||||
|
||||
@@ -21,7 +21,10 @@ export default async function readWireguardClientConfig({
|
||||
}
|
||||
|
||||
const { HOST_CLIENTS_DIR } = grabHostDirnames({
|
||||
host: host_id ? { id: Number(host_id) } : undefined,
|
||||
host:
|
||||
host_id !== undefined && host_id !== ""
|
||||
? { id: Number(host_id) }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const config_path = path.join(
|
||||
|
||||
@@ -17,6 +17,7 @@ import grabClientDirnames from "./grab-client-dir-names";
|
||||
import buildHostIptablesScripts from "./build-host-iptables-scripts";
|
||||
import allowSubnetFirewallRules from "../firewall/allow-subnet-firewall-rules";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { TlsData } from "@/src/data/tls-data";
|
||||
|
||||
const { WIREGUARD_PRIVATE_KEY_FILE_NAME, WIREGUARD_PUBLIC_KEY_FILE_NAME } =
|
||||
grabDirNames();
|
||||
@@ -215,6 +216,9 @@ export default async function setupWireguardHost({
|
||||
interface_name: INTERFACE_NAME,
|
||||
host_subnet: HOST_SUBNET,
|
||||
sibling_subnets,
|
||||
ingress_ports: TlsData.IngressEnabled
|
||||
? [TlsData.CaddyHttpsPort]
|
||||
: [],
|
||||
clients: clients.map((client) => ({
|
||||
id: client.id,
|
||||
wg_ip_address: client.wg_ip_address,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Network,
|
||||
Server,
|
||||
Settings,
|
||||
Users,
|
||||
@@ -34,6 +35,7 @@ export const AdminNav: AdminNavGroup[] = [
|
||||
items: [
|
||||
{ title: "Hosts", url: "/admin/hosts", Icon: Server },
|
||||
{ title: "Clients", url: "/admin/clients", Icon: Users },
|
||||
{ title: "Routes", url: "/admin/routes", Icon: Network },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Eye, Pencil, Plus, Trash2, Users } from "lucide-react";
|
||||
import { Eye, Network, Pencil, Plus, Trash2, Users } from "lucide-react";
|
||||
import ActionMenu from "@/src/components/general/action-menu";
|
||||
import useDeleteHost from "@/src/components/general/use-delete-host";
|
||||
import deriveHostDisplayName from "@/src/utils/derive-host-display-name";
|
||||
@@ -20,6 +20,7 @@ export default function HostRowActions({ host, onDeleted }: Props) {
|
||||
items={[
|
||||
{ key: "view", title: "View", href: base, icon: <Eye 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",
|
||||
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 type { ActionMenuItem } from "@/src/components/general/action-menu/types";
|
||||
import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db";
|
||||
@@ -24,6 +24,7 @@ export default function useHostHeaderActions({ host, host_id }: Params) {
|
||||
|
||||
const actions: ActionMenuItem[] = [
|
||||
{ key: "clients", title: "Clients", href: `/admin/hosts/${host_id}/clients`, icon: <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} /> },
|
||||
...(host
|
||||
? [
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AppContext } from "@/src/pages/__root";
|
||||
import deriveHostDisplayName from "@/src/utils/derive-host-display-name";
|
||||
import HostStatsSection from "./(sections)/host-stats-section";
|
||||
import HostInterfaceSection from "./(sections)/host-interface-section";
|
||||
import ClientRoutesPanel from "@/src/components/general/client-routes-panel";
|
||||
import HostHeaderMeta from "./(partials)/host-header-meta";
|
||||
import useHostHeaderActions from "./(hooks)/use-host-header-actions";
|
||||
|
||||
@@ -45,6 +46,11 @@ export default function HostDetailPage() {
|
||||
<PageBody>
|
||||
<HostStatsSection host={host} clients={clients} />
|
||||
<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} />
|
||||
</PageBody>
|
||||
</>
|
||||
|
||||
+63
@@ -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 };
|
||||
}
|
||||
+269
@@ -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>
|
||||
);
|
||||
}
|
||||
+96
@@ -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`,
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
export const SetupNav: SubNavItemType[] = [
|
||||
{ title: "System status", href: "/admin/setup/status", Icon: Activity },
|
||||
{ title: "Maintenance", href: "/admin/setup/maintenance", Icon: Wrench },
|
||||
{ title: "Firewall", href: "/admin/setup/firewall", Icon: Shield },
|
||||
{ title: "TLS ingress", href: "/admin/setup/tls", Icon: LockKeyhole },
|
||||
];
|
||||
|
||||
@@ -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 useSystemSetupStatus from "../(hooks)/use-system-setup-status";
|
||||
import SetupActionRow from "../(partials)/setup-action-row";
|
||||
import SetupCaddyAction from "../(partials)/setup-caddy-action";
|
||||
import SetupUpdateWgUiAction from "../(partials)/setup-update-wg-ui-action";
|
||||
|
||||
type Props = {
|
||||
@@ -24,6 +25,11 @@ export default function SetupActionsSection({ setup }: Props) {
|
||||
.filter((tool) => WG_TOOL_COMMANDS.includes(tool.command))
|
||||
.every((tool) => tool.installed);
|
||||
|
||||
const have_caddy = Boolean(
|
||||
setup_status.tools.find((tool) => tool.command == "/opt/caddy/caddy")
|
||||
?.installed,
|
||||
);
|
||||
|
||||
const show_restart = !setup_status.is_dev && setup_status.init_system !== "unknown";
|
||||
|
||||
return (
|
||||
@@ -43,6 +49,11 @@ export default function SetupActionsSection({ setup }: Props) {
|
||||
}}
|
||||
/>
|
||||
|
||||
<SetupCaddyAction
|
||||
installed={have_caddy}
|
||||
on_done={() => setup.refresh()}
|
||||
/>
|
||||
|
||||
<SetupUpdateWgUiAction setup={setup} />
|
||||
|
||||
{show_restart ? (
|
||||
|
||||
@@ -12,7 +12,7 @@ type Props = PropsWithChildren & {
|
||||
is_root?: boolean;
|
||||
};
|
||||
|
||||
/** Header, sub-navigation and body shared by the three setup subpages. */
|
||||
/** Header, sub-navigation and body shared by the setup subpages. */
|
||||
export default function SetupPageFrame({ title, description, is_root, children }: Props) {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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`,
|
||||
};
|
||||
+54
@@ -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;
|
||||
|
||||
if (client?.host_id) {
|
||||
if (client?.host_id !== undefined && client.host_id !== "") {
|
||||
const host_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_HOSTS,
|
||||
TableType
|
||||
|
||||
@@ -100,7 +100,7 @@ export default async function (
|
||||
try {
|
||||
let host: BUN_SQLITE_WGUI_HOSTS | undefined;
|
||||
|
||||
if (host_id) {
|
||||
if (Number.isSafeInteger(host_id) && host_id >= 0) {
|
||||
const host_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_HOSTS,
|
||||
TableType
|
||||
|
||||
@@ -15,6 +15,7 @@ import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
|
||||
import clients from "./(tables)/clients";
|
||||
import clientRules from "./(tables)/client_rules";
|
||||
import clientRoutes from "./(tables)/client_routes";
|
||||
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
|
||||
params,
|
||||
@@ -64,6 +65,13 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
|
||||
return await clients(crud_params);
|
||||
case "client_rules":
|
||||
return await clientRules(crud_params);
|
||||
case "client_routes":
|
||||
return await clientRoutes(crud_params);
|
||||
case "tls_certificates":
|
||||
return {
|
||||
success: false,
|
||||
msg: `TLS certificates are managed by the TLS ingress`,
|
||||
};
|
||||
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -33,7 +33,11 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
|
||||
try {
|
||||
let host: BUN_SQLITE_WGUI_HOSTS | undefined;
|
||||
|
||||
if (body?.host_id) {
|
||||
if (
|
||||
body?.host_id !== undefined &&
|
||||
body.host_id !== null &&
|
||||
body.host_id !== ""
|
||||
) {
|
||||
const host_res = await BunSQLite.select<
|
||||
BUN_SQLITE_WGUI_HOSTS,
|
||||
TableType
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user