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