Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98b79042bf | |||
| 72afecd539 | |||
| 180280791a | |||
| 753fb33d52 | |||
| 7846342412 | |||
| 2d0e9989ed | |||
| 92f3430892 | |||
| 5c84e6cf73 | |||
| cb1bdc6aca | |||
| b62f27f415 | |||
| e39e1c3245 | |||
| f1e89af839 | |||
| 609037fc2c | |||
| 399ca4ddaf | |||
| ac88b3b924 | |||
| 1867bdea81 | |||
| d9028aa8dc | |||
| 4050b004bd | |||
| 1aee27b539 | |||
| a6060627d9 | |||
| 13ff035fae | |||
| 69677fa9a8 | |||
| 98c4fd331d | |||
| 60f36dbfb7 | |||
| fb961ae96d | |||
| d98642cbc2 | |||
| 56e0583b2d | |||
| 8e430a9127 | |||
| 4576845604 | |||
| 987cf48b6c | |||
| e62f0cc988 | |||
| 823a116d09 | |||
| fd414ddaf4 | |||
| 89cabf4baf | |||
| f39d405de8 | |||
| 2bb875eb98 | |||
| d73f0cea3f | |||
| 72b2dc7458 | |||
| b17fce9adb | |||
| 1ff8b6691d | |||
| 97d62a1c3b | |||
| 94a651e4e5 | |||
| d30357777e | |||
| 8c62eaca9d | |||
| 1050cf9144 | |||
| 538a617709 | |||
| eeecf7b586 | |||
| 2c4be2bf42 | |||
| 01035371ff | |||
| d24edf4d6a | |||
| 4e624323e4 | |||
| 02271d9072 | |||
| 73edc8e665 | |||
| f0857612e9 | |||
| 1f0082d9ac | |||
| 5cce273b2c | |||
| a857f8be0e | |||
| 6c3ef9f57b | |||
| df318bed4f | |||
| bebf4322c0 | |||
| 799276eaa1 | |||
| 5f43f87474 | |||
| efc48fbff2 | |||
| c8dade1e6e | |||
| fe98cd64b0 | |||
| 731d41377c | |||
| 05ec32ff3c | |||
| 34b7dbfdc3 | |||
| edb5d58bb4 | |||
| 34d7c2a77f | |||
| 46360a2453 | |||
| a7201fbb8c | |||
| 44777072d5 | |||
| 575c1816c3 | |||
| 9a4f91d40d |
+1
-1
@@ -5,7 +5,7 @@ FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libcap-setcap su-exec tini && corepack enable
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
RUN pnpm install --prod --no-frozen-lockfile
|
||||
COPY src ./src
|
||||
COPY --from=caddy /usr/bin/caddy /usr/bin/caddy
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
@@ -1,295 +1,172 @@
|
||||
<div align="center">
|
||||
<img src="src/public/icon.png" alt="Site Gateway icon" width="180">
|
||||
<h1>Site Gateway</h1>
|
||||
<p>
|
||||
<img src="src/public/site-gateway-icon-approved.png" alt="Site Gateway icon" width="88">
|
||||
</p>
|
||||
<p>
|
||||
<img src="src/public/site-gateway-wordmark-approved.png" alt="Site Gateway" width="280">
|
||||
</p>
|
||||
<p><strong>Host. Proxy. Secure.</strong></p>
|
||||
<p>A friendly, self-hosted gateway for websites, applications, domains, and automatic HTTPS.</p>
|
||||
<p>A friendly, self-hosted gateway for homelabs and small teams — publish static sites, reverse-proxy your apps, forward raw TCP/UDP streams, and manage TLS and access from one calm dashboard.</p>
|
||||
<p>
|
||||
<a href="https://github.com/mfwadejr/site-gateway2/actions/workflows/container.yml"><img alt="Container build" src="https://github.com/mfwadejr/site-gateway2/actions/workflows/container.yml/badge.svg"></a>
|
||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white">
|
||||
<img alt="Architectures" src="https://img.shields.io/badge/platform-amd64%20%7C%20arm64-5965F2">
|
||||
<img alt="Caddy" src="https://img.shields.io/badge/powered%20by-Caddy-1F88C0">
|
||||
<img alt="Public alpha" src="https://img.shields.io/badge/status-public%20alpha-FFBF69">
|
||||
<img alt="Version" src="https://img.shields.io/badge/version-0.11.101-62E6A7">
|
||||
</p>
|
||||
<p>
|
||||
<a href="#why-site-gateway">Why Site Gateway</a> ·
|
||||
<a href="#what-you-get">What you get</a> ·
|
||||
<a href="#quick-start">Quick start</a> ·
|
||||
<a href="#domains-proxy-hosts-and-tls">Domains & TLS</a> ·
|
||||
<a href="#unraid-alpha-install">Unraid</a> ·
|
||||
<a href="#zimaos-alpha-install">ZimaOS</a> ·
|
||||
<a href="INSTALL-v0.9.0-alpha.1.md">v0.9 installation guide</a> ·
|
||||
<a href="#configuration">Configuration</a> ·
|
||||
<a href="#unraid">Unraid</a> ·
|
||||
<a href="#zimaos">ZimaOS</a> ·
|
||||
<a href="ROADMAP.md">Roadmap</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
Site Gateway gives a home server one clear control panel for two jobs: publishing uploaded static sites and routing domains to applications already running on your network. Caddy handles the gateway, certificates, renewals, redirects, compression, and WebSocket forwarding behind the scenes.
|
||||
## Why Site Gateway
|
||||
|
||||
| Publish | Route | Protect | Operate |
|
||||
Most homelabs end up with the same problem: a handful of self-hosted apps, a couple of static sites, maybe a game server, and no clean way to expose any of it without hand-editing Nginx or Caddy configs every time something changes. Site Gateway is a single container that gives that setup one dashboard: point a domain at it, pick what you're publishing, and it handles routing, certificates, and renewal behind the scenes with [Caddy](https://caddyserver.com/).
|
||||
|
||||
It's intentionally narrower than a general-purpose proxy manager. You describe *what* you want (a site, a proxy target, a redirect, a raw port forward) and Site Gateway writes and safely reloads the underlying gateway configuration — no Caddyfile required.
|
||||
|
||||
## What you get
|
||||
|
||||
| Hosted Sites | Proxy Hosts | Redirect Hosts | Streaming Hosts |
|
||||
| --- | --- | --- | --- |
|
||||
| Upload a ZIP or `index.html` | Proxy domains to LAN apps or containers | Automatic HTTPS certificates and renewal | Enable, disable, replace, and delete from one dashboard |
|
||||
| Assign direct testing ports | Host multiple domains on ports 80/443 | Optional HSTS and HTTPS redirects | Persistent `/data` storage with PUID/PGID support |
|
||||
| Upload a ZIP or `index.html` and publish static files on a domain and/or a direct port | Point a domain at Plex, Jellyfin, Vaultwarden, or any HTTP app — TLS, HSTS, and headers included | Send one or more domains to a canonical destination with 301/302/307/308 | Forward raw TCP/UDP ports straight to a service — game servers, SSH, anything that isn't HTTP |
|
||||
|
||||
> [!NOTE]
|
||||
> Site Gateway is intentionally simpler than a general-purpose proxy manager. You provide the site or destination; the guided interface writes and safely reloads the gateway configuration.
|
||||
- **Automatic HTTPS** — Caddy issues and renews public certificates; internal, HTTP-only, and uploaded custom-certificate modes are also supported.
|
||||
- **Live dashboard** — gateway/HTTP/HTTPS/storage health, hosted and proxy counts, certificate status, throughput, uptime, memory, disk, and version info at a glance.
|
||||
- **Access Lists** — reusable login/network policies combining accounts, groups, and IP/CIDR rules across any host.
|
||||
- **Two-factor authentication** — TOTP-based MFA for administrator and user accounts, with recovery codes.
|
||||
- **Users, groups, and roles** — Administrator and Standard User roles, with account lifecycle controls.
|
||||
- **Backups** — configuration or complete `.sgbackup` archives, downloadable, importable, schedulable, and optionally AES-256-GCM encrypted.
|
||||
- **Certificates page** — issuer, expiration, days remaining, and renewal health for every managed and uploaded certificate.
|
||||
- **Performance and logs** — request throughput, response times, and rotating access/activity logs per host.
|
||||
- **SQLite-backed persistence** — no external database container; everything lives under one `/data` volume.
|
||||
|
||||
## Alpha features
|
||||
|
||||
- Password-protected, responsive dashboard with live gateway health
|
||||
- Hosted-site, proxy-host, TLS-domain, and attention totals at a glance
|
||||
- Runtime uptime, memory, persistent-data size, disk space, and installed versions
|
||||
- Recent configuration activity for the current container session
|
||||
- Confirmed Caddy, HTTP port 80, and HTTPS port 443 health checks with manual and automatic refresh
|
||||
- Searchable Dashboard Icons picker with validated local storage under `/data/icons`
|
||||
- Consistent two-letter icon fallbacks for hosted sites and proxy hosts
|
||||
- Create a site from a ZIP archive or a single `index.html`
|
||||
- One independently enabled/disabled port per site
|
||||
- Caddy gateway on ports 80 and 443
|
||||
- Domain routing and automatic HTTPS for hosted sites
|
||||
- Reverse proxy hosts for containers, LAN services, and applications
|
||||
- Redirect Hosts with 301, 302, 307, and 308 responses and optional path preservation
|
||||
- Reusable Access Lists with LAN/CIDR rules and a themed username/password sign-in page
|
||||
- Collapsible Proxy Host controls for custom locations, headers, compression, upstream TLS, health expectations, and expert Caddy snippets
|
||||
- Public, internal, HTTP-only, and uploaded custom-certificate modes
|
||||
- Automatic certificate renewal and HTTP-to-HTTPS redirects
|
||||
- Configurable themed welcome, 404, redirect, no-response, and custom-HTML fallback pages
|
||||
- Integrated, searchable documentation with real-world setup examples
|
||||
- Administrator workspace for users, gateway defaults, security guidance, and backup/restore
|
||||
- Downloadable, importable, scheduled, retained, and optionally encrypted `.sgbackup` archives
|
||||
- Replace a site's files without recreating it
|
||||
- Delete sites and their stored files
|
||||
- Persistent configuration and uploads under `/data`
|
||||
- Built-in transactional SQLite configuration database at `/data/database/site-gateway.sqlite`
|
||||
- Unified certificate storage under `/data/certificates` and fixed backup storage under `/data/backups`
|
||||
- Path traversal protection for ZIP extraction and a 250 MB upload limit
|
||||
- Clean shutdown and automatic site restart after a container restart
|
||||
|
||||
Hosted uploads remain static-only (HTML, CSS, JavaScript, images, fonts, and downloads). Dynamic applications can be connected as proxy hosts. Site Gateway does not execute uploaded PHP, Node, Python, or database code.
|
||||
Hosted uploads remain static-only (HTML, CSS, JS, images, fonts, downloads). Dynamic applications are connected as Proxy Hosts instead — Site Gateway does not execute uploaded PHP, Node, Python, or database code.
|
||||
|
||||
## Quick start
|
||||
|
||||
Requirements: Docker Engine with Docker Compose.
|
||||
Requirements: Docker Engine with Docker Compose, and ports 80/443 free on the host (plus 8080 for the dashboard).
|
||||
|
||||
1. Edit `compose.yaml` and replace `change-this-password` with a strong password.
|
||||
2. From this folder, run:
|
||||
1. Copy `.env.example` to `.env` and set `ADMIN_PASSWORD` and `SESSION_SECRET`.
|
||||
2. Pull and start the published image:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
docker compose -f compose.release.yaml pull
|
||||
docker compose -f compose.release.yaml up -d
|
||||
```
|
||||
|
||||
3. Open `http://YOUR-SERVER-IP:8080`.
|
||||
4. Sign in with `admin` and the password you chose.
|
||||
5. Select **New site**, provide a name and unused port, then upload either:
|
||||
- a ZIP with `index.html` at its root; or
|
||||
- a single `index.html` file.
|
||||
6. Open the site from its arrow button or visit `http://YOUR-SERVER-IP:PORT`.
|
||||
3. Open `http://YOUR-SERVER-IP:8080` and sign in with `admin` and the password you set.
|
||||
4. Finish first-time setup (you'll be asked to confirm or change the display name, username, and password).
|
||||
5. Create your first route from the dashboard — Hosted, Proxy, Redirect, or Streaming.
|
||||
|
||||
The included Compose file publishes site ports 9000–9099. Docker cannot add a host port to an already-running container, so any site port must be included in the published range. Change `SITE_PORT_MIN`, `SITE_PORT_MAX`, and the Compose `ports` range together before starting the container if you want a different range.
|
||||
Prefer to build from source instead of pulling the image? Use `compose.yaml` and `docker compose up -d --build`.
|
||||
|
||||
For domain routing and automatic certificates, point the domain's DNS record at this server and forward public ports 80 and 443 to the container. If another reverse proxy already owns those ports, stop it or map Site Gateway to temporary alternate host ports for LAN testing; public ACME issuance will not work until 80/443 traffic reaches Site Gateway.
|
||||
The included Compose files publish site ports 9000–9099 for direct-LAN access to Hosted Sites. Docker can't add a host port to an already-running container, so change `SITE_PORT_MIN`, `SITE_PORT_MAX`, and the Compose `ports` range together, before starting the container, if you want a different range. The same applies to Streaming Hosts — publish the TCP/UDP port you plan to use before creating the route in the dashboard.
|
||||
|
||||
For domain routing and automatic certificates, point the domain's DNS record at this server and forward public ports 80 and 443 to the container. If another reverse proxy already owns those ports, stop it or use temporary alternate host ports for LAN testing — public ACME issuance won't work until 80/443 traffic actually reaches Site Gateway.
|
||||
|
||||
## Domains, proxy hosts, and TLS
|
||||
|
||||
Use **Hosted sites** for uploaded files. A domain is optional; when present, Caddy serves the site on ports 80/443 and automatically obtains and renews a public certificate. Direct site ports remain available for LAN testing.
|
||||
Use **Hosted Sites** for uploaded files. A domain is optional; when present, Caddy serves the site on ports 80/443 and automatically obtains and renews a public certificate. Direct site ports remain available for LAN testing.
|
||||
|
||||
Use **Proxy hosts** to connect a domain to an existing application such as `http://192.168.1.20:3000` or another container name and port. Caddy supplies the normal forwarded headers and supports WebSocket upgrades automatically.
|
||||
Use **Proxy Hosts** to connect a domain to an existing application, such as `http://192.168.1.20:3000` or another container's name and port. Caddy supplies the standard forwarded headers and supports WebSocket upgrades automatically.
|
||||
|
||||
Automatic HTTPS requires valid public DNS and inbound access to port 80 or 443. Caddy renews certificates automatically before expiration. HSTS is optional and should only be enabled after HTTPS works reliably.
|
||||
Use **Streaming Hosts** for anything that isn't HTTP — game servers, SSH, or other raw TCP/UDP services. These need their port published in Compose up front, since Docker can't add ports to a running container.
|
||||
|
||||
## Install from the published image
|
||||
Automatic HTTPS requires valid public DNS and inbound access to port 80 or 443. HSTS is optional and should only be enabled after HTTPS is confirmed working.
|
||||
|
||||
Each push to `main` automatically publishes `ghcr.io/mfwadejr/site-gateway2:latest` for both Intel/AMD and ARM64 servers. Copy `.env.example` to `.env`, replace the password and session secret, then run:
|
||||
## Configuration
|
||||
|
||||
```bash
|
||||
docker compose -f compose.release.yaml pull
|
||||
docker compose -f compose.release.yaml up -d
|
||||
```
|
||||
| Variable | Default | Purpose |
|
||||
|---|---:|---|
|
||||
| `ADMIN_USERNAME` | `admin` | Bootstrap dashboard login name |
|
||||
| `ADMIN_PASSWORD` | — | Bootstrap dashboard password; **required**, always change it |
|
||||
| `SESSION_SECRET` | — | **Required.** Any random string; rotating it signs everyone out |
|
||||
| `ADMIN_PORT` | `8080` | Dashboard port inside the container |
|
||||
| `SITE_PORT_MIN` / `SITE_PORT_MAX` | `9000` / `9099` | Direct-LAN port range Hosted Sites can bind to |
|
||||
| `DATA_DIR` | `/data` | Persistent state location |
|
||||
| `BACKUP_PASSWORD` | empty | Encryption password used only when encrypted scheduled backups are enabled |
|
||||
| `PUID` / `PGID` | `1000` / `1000` | User/group the container writes files as (Unraid: `99`/`100`) |
|
||||
| `ACME_EMAIL` | empty | Optional certificate account email |
|
||||
|
||||
To upgrade later:
|
||||
At startup, the container creates the complete `/data` hierarchy, applies `PUID`/`PGID` ownership, then drops root privileges. Configuration lives in SQLite at `/data/database/site-gateway.sqlite`; hosted files live under `/data/sites`; backups under `/data/backups`; certificates under `/data/certificates`.
|
||||
|
||||
```bash
|
||||
docker compose -f compose.release.yaml pull
|
||||
docker compose -f compose.release.yaml up -d
|
||||
```
|
||||
## Unraid
|
||||
|
||||
This recreates only the application container. Uploaded sites remain in the persistent data mount.
|
||||
1. Add the container from **Docker → Add Container** using the image `ghcr.io/mfwadejr/site-gateway2:latest`, or search Community Applications once a template is published.
|
||||
2. Map ports `80`, `443` (TCP+UDP), `8080`, and `9000-9099` as above, plus any Streaming Host ports you plan to use.
|
||||
3. Map one path, e.g. `/mnt/user/appdata/site-gateway:/data`.
|
||||
4. Set `PUID=99` and `PGID=100` so the container writes to `/data` as the `nobody`/`users` account Unraid expects.
|
||||
5. Set `ADMIN_PASSWORD` and `SESSION_SECRET`, then start the container and open `http://UNRAID-IP:8080`.
|
||||
|
||||
## ZIP layout
|
||||
For automatic image-based upgrades, Unraid's **Update Container** action pulls the newest `latest` image; if you use Watchtower, `compose.release.yaml` includes its opt-in label.
|
||||
|
||||
Preferred:
|
||||
## ZimaOS
|
||||
|
||||
```text
|
||||
my-site.zip
|
||||
├── index.html
|
||||
├── styles.css
|
||||
├── app.js
|
||||
└── images/
|
||||
└── logo.png
|
||||
```
|
||||
The simplest path is [`compose.zimaos.yaml`](compose.zimaos.yaml) — a ready-to-import file with the `x-casaos` metadata ZimaOS's app installer and App Store use for the icon, title, and port mapping.
|
||||
|
||||
A ZIP containing one top-level folder is also accepted; Site Gateway unwraps that folder automatically.
|
||||
1. In ZimaOS, go to **Docker → Install a Customized App**, and paste or select `compose.zimaos.yaml`.
|
||||
2. Before starting it, edit `ADMIN_PASSWORD` and `SESSION_SECRET` in the environment fields.
|
||||
3. Confirm the data path — it defaults to `/DATA/AppData/site-gateway` — and start the app.
|
||||
4. Open `http://ZIMAOS-IP:8080`.
|
||||
|
||||
## Unraid alpha install
|
||||
Prefer a plain Compose file instead? `compose.yaml` (build from source) and `compose.release.yaml` (pull the published image) both work the same way:
|
||||
|
||||
### Option A: Compose Manager
|
||||
|
||||
1. Install **Compose Manager** from Community Applications if it is not already present.
|
||||
2. Copy this project folder to `/mnt/user/appdata/site-gateway/app`.
|
||||
3. In `compose.yaml`, change the volume to `/mnt/user/appdata/site-gateway/data:/data`.
|
||||
4. Set a strong `ADMIN_PASSWORD`. Optionally set a long random `SESSION_SECRET`.
|
||||
5. Add the stack in Compose Manager and choose **Compose Up**.
|
||||
6. Open `http://UNRAID-IP:8080`.
|
||||
|
||||
For automatic image-based upgrades, use `compose.release.yaml` instead. Unraid's **Update Container** action can pull the newest `latest` image. If you already use Watchtower, the release Compose file includes its opt-in label.
|
||||
|
||||
### Option B: build from the Unraid terminal
|
||||
|
||||
```bash
|
||||
cd /mnt/user/appdata/site-gateway/app
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
If Unraid reports a port conflict, change the admin port mapping's left side (for example `8180:8080`) or choose a different site-port range. Allow the selected site ports through any LAN firewall.
|
||||
|
||||
## ZimaOS alpha install
|
||||
|
||||
1. Copy this folder into ZimaOS storage, for example `/DATA/AppData/site-gateway/app`.
|
||||
2. Change the Compose volume to `/DATA/AppData/site-gateway/data:/data`.
|
||||
3. Set a strong `ADMIN_PASSWORD` and optionally `SESSION_SECRET`.
|
||||
4. In the ZimaOS app interface, use its custom app / Compose import option and paste or select `compose.yaml`. If that option is unavailable in your release, use the terminal:
|
||||
1. Copy this folder into ZimaOS storage, e.g. `/DATA/AppData/site-gateway/app`.
|
||||
2. Point the Compose volume at `/DATA/AppData/site-gateway/data:/data`.
|
||||
3. Set `ADMIN_PASSWORD` and `SESSION_SECRET` (and `PUID`/`PGID` if needed — ZimaOS typically uses `1000:1000`).
|
||||
4. Import through ZimaOS's custom app / Compose import option, or run it from the terminal:
|
||||
|
||||
```bash
|
||||
cd /DATA/AppData/site-gateway/app
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
5. Open `http://ZIMAOS-IP:8080`.
|
||||
|
||||
For simple upgrades, import `compose.release.yaml`; use ZimaOS's container update/recreate action whenever a new image is published. The `/data` mount keeps all sites during replacement.
|
||||
|
||||
## Migrating from Web Server
|
||||
|
||||
The product, repository, image, and default container are now named Site Gateway. Existing data does not need to move. Stop and remove the old container, then run the new image while mounting the existing folder:
|
||||
|
||||
```bash
|
||||
docker stop web-server
|
||||
docker rm web-server
|
||||
docker pull ghcr.io/mfwadejr/site-gateway2:latest
|
||||
docker run -d --name site-gateway --restart unless-stopped \
|
||||
-p 8080:8080 -p 80:80 -p 443:443 -p 9000-9099:9000-9099 \
|
||||
-v /DATA/AppData/web-server:/data \
|
||||
-e ADMIN_USERNAME=admin \
|
||||
-e ADMIN_PASSWORD='YOUR_EXISTING_PASSWORD' \
|
||||
-e SESSION_SECRET='YOUR_EXISTING_SESSION_SECRET' \
|
||||
-e PUID=1000 -e PGID=1000 \
|
||||
ghcr.io/mfwadejr/site-gateway2:latest
|
||||
```
|
||||
|
||||
After confirming the sites appear, you may keep the legacy host folder or rename it to `/DATA/AppData/site-gateway` while the container is stopped and update the mount accordingly. Unraid users should retain `/mnt/user/appdata/web-server` as the template's Data path for the first upgraded launch.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---:|---|
|
||||
| `ADMIN_USERNAME` | `admin` | Dashboard login name |
|
||||
| `ADMIN_PASSWORD` | `change-this-password` | Dashboard password; always change it |
|
||||
| `SESSION_SECRET` | derived | Optional stable signing secret for login sessions |
|
||||
| `ADMIN_PORT` | `8080` | Dashboard port inside the container |
|
||||
| `SITE_PORT_MIN` | `9000` | Lowest allowed site port |
|
||||
| `SITE_PORT_MAX` | `9099` | Highest allowed site port |
|
||||
| `DATA_DIR` | `/data` | Persistent state location |
|
||||
| `BACKUP_PASSWORD` | empty | Encryption password used only when encrypted scheduled backups are enabled |
|
||||
| `PUID` | `1000` | UID that owns and runs against persistent files |
|
||||
| `PGID` | `1000` | GID that owns and runs against persistent files |
|
||||
| `ACME_EMAIL` | empty | Optional certificate account email |
|
||||
|
||||
At startup, the container creates the complete `/data` hierarchy, applies `PUID`/`PGID` ownership, and then drops root privileges. Configuration is stored in SQLite, hosted files remain in `/data/sites`, backups use `/data/backups`, uploaded certificates use `/data/certificates/custom`, and Caddy owns `/data/certificates/managed`. With the ZimaOS bind mount, these appear under `/DATA/AppData/site-gateway` on the host. Unraid commonly uses `PUID=99` and `PGID=100`; ZimaOS typically uses `1000:1000`.
|
||||
5. Open `http://ZIMAOS-IP:8080`. Use ZimaOS's container update/recreate action whenever a new image is published — the `/data` mount keeps all sites during replacement.
|
||||
|
||||
## Backup and update
|
||||
|
||||
Open **Administration → Backup & restore** to create a Configuration or Complete backup. Manual backups download to the browser. Scheduled backups are stored under `/data/backups`, retained according to the interface setting, and can use AES-256-GCM encryption when `BACKUP_PASSWORD` is configured. A Complete backup contains a consistent SQLite snapshot, portable JSON recovery data, hosted files, local icons, custom fallback assets, and both custom and Caddy-managed certificate storage; logs are optional. Because certificate backups contain private keys, encryption is strongly recommended.
|
||||
Open **Administration → Backup & restore** to create a Configuration or Complete backup. Manual backups download to the browser; scheduled backups are stored under `/data/backups` and can be AES-256-GCM encrypted when `BACKUP_PASSWORD` is set. A Complete backup contains a consistent SQLite snapshot, portable JSON recovery data, hosted files, local icons, custom fallback assets, and certificate storage. Because certificate backups contain private keys, encryption is strongly recommended.
|
||||
|
||||
Before restoring, Site Gateway checks the archive manifest and creates a complete pre-restore safety backup. It then reloads persisted state and validates the resulting Caddy configuration. Store important backups on a separate disk or NAS share—copies in the same appdata volume do not protect against disk failure.
|
||||
Before restoring, Site Gateway checks the archive manifest, creates a complete pre-restore safety backup, then reloads and validates the resulting configuration.
|
||||
|
||||
To rebuild after pulling a new version:
|
||||
To upgrade:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
docker compose -f compose.release.yaml pull
|
||||
docker compose -f compose.release.yaml up -d
|
||||
```
|
||||
|
||||
Your sites remain intact because they live in the mounted data directory.
|
||||
|
||||
## Publishing updates
|
||||
|
||||
The GitHub Actions workflow builds and publishes a fresh multi-architecture container whenever code is pushed to `main`. Alpha release tags publish an exact version and the moving `alpha` channel. For example, `v0.9.0-alpha.1` publishes `ghcr.io/mfwadejr/site-gateway2:0.9.0-alpha.1` and `ghcr.io/mfwadejr/site-gateway2:alpha`. The package starts private if the GitHub account's package defaults require it; make the `site-gateway2` package public in GitHub package settings so Unraid and ZimaOS can pull without credentials.
|
||||
|
||||
### Monitoring in v0.5.0-alpha.1
|
||||
|
||||
- Certificate inventory shows issuer, expiration date, days remaining, provisioning state, and the last certificate-file update reported by Caddy.
|
||||
- Dashboard alerts call out certificates within 30 days of expiration and unreachable proxy upstreams.
|
||||
- Enabled proxy targets are checked every 60 seconds with a four-second timeout; status, HTTP response, latency, and recent in-memory history are available to the dashboard.
|
||||
- Caddy access logs are stored as rotating JSON files under `/data/logs` and displayed without request headers. Gateway activity and errors are also appended to `/data/logs/activity.jsonl`.
|
||||
|
||||
`v0.5.0-alpha.2` clarifies that a missing stored certificate is **not detected**, rather than claiming issuance is actively provisioning, and includes a consistency pass for dashboard indicators, cards, and log controls.
|
||||
|
||||
### Users and roles in v0.6.0-alpha.1
|
||||
|
||||
- The environment-defined administrator becomes the initial persistent Administrator on first startup after upgrading.
|
||||
- Administrators can create users, assign Administrator or Standard User roles, reset passwords, disable accounts, and archive or restore accounts.
|
||||
- Standard Users have read-only access to dashboard health, hosted sites, proxy hosts, certificates, and logs. Per-host ownership and granular permissions are planned for a later release.
|
||||
- Passwords are stored as salted scrypt hashes in the SQLite database; plaintext passwords are never written to disk.
|
||||
- Site Gateway prevents removal of the final active Administrator and blocks users from disabling or archiving their own active session.
|
||||
|
||||
### Gateway management in v0.7.0-alpha.1
|
||||
|
||||
- Administration and Documentation appear directly above the installed-version divider; Administration is role-restricted.
|
||||
- Proxy Hosts support multiple custom locations using `path | destination | strip-or-preserve`, request/response headers, upstream TLS controls, custom certificates, Access Lists, compression, and configurable health checks.
|
||||
- Access List credentials are stored as salted password hashes and presented through a Site Gateway-themed login form. Network rules accept exact IP addresses, CIDR ranges, or Caddy's `private_ranges` token.
|
||||
- Redirect Hosts and the configurable Default Site compile to native Caddy routes and are validated before reload.
|
||||
- Expert Caddy snippets are administrator-only, size-limited, screened against global directives, and validated as part of the complete generated configuration.
|
||||
|
||||
### Storage foundation in v0.8.0-alpha.1
|
||||
|
||||
- SQLite is built into the container and stores configuration at `/data/database/site-gateway.sqlite`; no external database container or port is required.
|
||||
- A seeded Local Gateway instance scopes every stored entity in preparation for future multi-instance management.
|
||||
- Existing JSON installations are imported once into SQLite after a complete migration backup is written to `/data/backups`; original JSON snapshots remain under `/data/migrations`.
|
||||
- Failed first-time imports remove the incomplete database so the migration safely retries after the source problem is corrected.
|
||||
- Caddy-managed certificates and internal CA data live under `/data/certificates/managed`; uploaded certificates live under `/data/certificates/custom`; public exports are reserved under `/data/certificates/exports`.
|
||||
- Backups contain a consistent SQLite snapshot, portable JSON recovery records, checksums, and optional complete filesystem content.
|
||||
|
||||
### First login in v0.8.0-alpha.2
|
||||
|
||||
- Fresh installations explain that the administrator credentials supplied to Docker are bootstrap credentials.
|
||||
- After the first successful sign-in, the administrator must confirm or change the display name, username, and password before opening the dashboard.
|
||||
- Completing setup rotates the account session identity and requires one final sign-in with the finalized credentials.
|
||||
- Existing installations are treated as already configured and are not interrupted by the new workflow.
|
||||
|
||||
### Trust and visibility in v0.9.0-alpha.1
|
||||
|
||||
- Certificate details include source, covered domains, issuer, validity, serial number, fingerprint, expiration, and the last detected file update.
|
||||
- Configurable warning thresholds, 30-day and 7-day totals, uploaded-certificate mismatch detection, and linked dashboard alerts make certificate state actionable.
|
||||
- On-demand diagnostics distinguish DNS, HTTP/HTTPS listener, TLS, and upstream failures for each configured domain.
|
||||
- Administrators can download a redacted support report that excludes credentials, private keys, cookies, secrets, and expert configuration.
|
||||
- Password minimums are eight characters throughout, and authentication forms are cleared after use.
|
||||
This recreates only the application container — your sites, certificates, and configuration remain in the mounted data directory.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Use unique bootstrap credentials during installation, then finalize the persistent administrator account during first-time setup.
|
||||
- Keep the dashboard on a trusted LAN or behind a trusted HTTPS reverse proxy/VPN. The alpha dashboard itself serves plain HTTP.
|
||||
- Do not expose the admin dashboard directly to the internet.
|
||||
- Uploaded static JavaScript runs for visitors. Only publish files you trust.
|
||||
- The container starts as root only to apply `PUID`/`PGID` ownership and grant Caddy the `cap_net_bind_service` capability, then drops both the Node app and Caddy to the unprivileged `PUID:PGID` user (default `1000:1000`) via `su-exec`. It does not require access to the Docker socket.
|
||||
- Use a unique bootstrap password during installation, then finish first-time setup to finalize the persistent administrator account.
|
||||
- Enable two-factor authentication on administrator accounts.
|
||||
- Keep the dashboard on a trusted LAN or behind a trusted HTTPS reverse proxy/VPN — don't expose the admin dashboard directly to the internet.
|
||||
- Uploaded static JavaScript runs for visitors; only publish files you trust.
|
||||
- The container starts as root only to apply `PUID`/`PGID` ownership and grant Caddy `cap_net_bind_service`, then drops both the Node app and Caddy to the unprivileged `PUID:PGID` user. It does not require access to the Docker socket.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Site shows Error:** another process probably owns its port. Check `docker logs site-gateway`, then recreate the site on a free published port.
|
||||
- **Site cannot be reached:** confirm the port is within the published Compose range and allowed through the server firewall.
|
||||
- **Permission denied under `/data`:** make the host data directory writable by UID/GID 1000, or adjust ownership to match your environment.
|
||||
- **Permission denied under `/data`:** make the host data directory writable by the configured `PUID`/`PGID`.
|
||||
- **Upload fails:** verify the file is below 250 MB and the extracted root contains `index.html`.
|
||||
- **Dashboard port is busy:** change only the host side, such as `8180:8080`, then browse to port 8180.
|
||||
- **Dashboard port is busy:** change only the host side, e.g. `8180:8080`, then browse to port 8180.
|
||||
- **Streaming Host has no traffic:** confirm the TCP/UDP port is published in Compose *before* creating the route — Docker can't add ports to a running container.
|
||||
|
||||
## Alpha roadmap
|
||||
## Roadmap
|
||||
|
||||
Good next additions are per-site access logs, certificate status reporting, drag-and-drop folder upload, rollback/history, health checks, access lists, and guided DNS diagnostics.
|
||||
See [ROADMAP.md](ROADMAP.md) for what's shipped and what's next.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+45
-129
@@ -2,157 +2,73 @@
|
||||
|
||||
## Current release status
|
||||
|
||||
`v0.10.0-alpha.95.3` completes the initial configuration audit-log work. Alpha 96 is the integration and release-hardening phase; backup and restore validation is intentionally scheduled last.
|
||||
`v0.11.101` is a stable, day-to-day release. The product has moved well past the original alpha creation flow described in earlier versions of this document — Hosted Sites, Proxy Hosts, Redirect Hosts, and Streaming Hosts are all implemented, along with authentication, access control, certificates, backups, and full dashboard reporting. This document reflects what's actually shipped and what's genuinely still ahead.
|
||||
|
||||
## Product direction
|
||||
|
||||
Site Gateway should remain simpler than a general-purpose proxy manager: one dashboard, clear health reporting, and guided setup instead of exposing server configuration. It can still cover most home-server publishing needs with an HTTP/HTTPS gateway alongside the existing static-file service.
|
||||
Site Gateway stays simpler than a general-purpose proxy manager: one dashboard, clear health reporting, and guided setup instead of exposing raw server configuration. **Caddy** remains the managed gateway — Site Gateway stores a small route model and generates/validates Caddy configuration rather than reimplementing certificate and proxy behavior itself.
|
||||
|
||||
## Recommended gateway
|
||||
## Shipped
|
||||
|
||||
Use **Caddy** as the managed gateway rather than rebuilding certificate and proxy behavior in Node or exposing raw Nginx configuration. The dashboard would store a small site model and generate/apply gateway configuration. Caddy provides automatic certificate issuance and renewal, redirects HTTP to HTTPS, supports reverse proxying and WebSockets, and has a configuration API suitable for safe validation before activation.
|
||||
### Routing
|
||||
|
||||
The existing Node application remains responsible for authentication, the wizard, uploads, persistence, status, and audit events. Static sites can continue to use internal listeners while Caddy becomes the only public entry point on ports 80 and 443.
|
||||
- **Hosted Sites** — upload a ZIP or `index.html`, publish on a domain and/or a direct LAN port, replace files without recreating the site.
|
||||
- **Proxy Hosts** — forward a domain to any HTTP(S) target, with custom locations, headers, compression, upstream TLS, health checks, load-balancing across multiple upstreams, and expert Caddy snippets.
|
||||
- **Redirect Hosts** — 301/302/307/308 responses with optional path preservation.
|
||||
- **Streaming Hosts** — native TCP/UDP port forwarding with monitoring, for services that aren't HTTP (game servers, SSH, etc).
|
||||
- Configurable themed welcome, 404, redirect, no-response, and custom-HTML fallback pages, with a live preview pane in Gateway Defaults.
|
||||
|
||||
## Proposed creation wizard
|
||||
### Access and identity
|
||||
|
||||
### Step 1: What are you publishing?
|
||||
- Local users with Administrator and Standard User roles, account lifecycle controls (disable/archive/restore).
|
||||
- Groups, used to grant Access List membership without managing users one by one.
|
||||
- Access Lists combining accounts, groups, and IP/CIDR network rules behind a themed sign-in page.
|
||||
- Optional two-factor authentication (TOTP) with a self-service My Account view for enrolling and managing it.
|
||||
- First-time setup flow that finalizes the persistent administrator account from bootstrap credentials.
|
||||
|
||||
- Static website — upload a ZIP or `index.html`
|
||||
- Existing application — proxy to an IP/hostname and port
|
||||
- Redirect — send a domain or path to another URL
|
||||
- Offline page — intentionally return a friendly maintenance/404 response
|
||||
### Certificates and TLS
|
||||
|
||||
### Step 2: Address
|
||||
- Automatic public HTTPS via Caddy, plus internal, HTTP-only, and uploaded custom-certificate modes.
|
||||
- Certificate inventory: issuer, covered domains, validity, serial number, fingerprint, expiration, and last detected update.
|
||||
- Dashboard alerts for certificates nearing expiration.
|
||||
|
||||
- Domain name(s)
|
||||
- Optional path such as `/photos`
|
||||
- Internal target and port for proxied applications
|
||||
- Validation that ports and domains are not duplicated
|
||||
### Observability
|
||||
|
||||
### Step 3: Security
|
||||
- Live dashboard health for the gateway, HTTP, HTTPS, and storage, plus hosted/proxy/certificate counts and throughput.
|
||||
- System panel: uptime, memory, persistent-data size, disk space, installed app/Caddy versions, public IP.
|
||||
- Performance view with request throughput, response times, and per-route breakdowns.
|
||||
- Rotating access and activity logs.
|
||||
- Update-available banner when a newer image is deployed.
|
||||
|
||||
- Automatic public TLS certificate
|
||||
- HTTP only for trusted LAN use
|
||||
- Upload an existing certificate
|
||||
- Force HTTPS
|
||||
- HSTS, shown as an advanced option with a clear lockout warning
|
||||
### Data and operations
|
||||
|
||||
### Step 4: Access
|
||||
- Built-in SQLite persistence at `/data/database/site-gateway.sqlite` — no external database container.
|
||||
- Configuration and Complete backups, downloadable, importable, schedulable, and optionally AES-256-GCM encrypted; pre-restore safety backups and configuration validation before activation.
|
||||
- PUID/PGID-aware startup for Unraid and ZimaOS-style permission models.
|
||||
|
||||
- Public
|
||||
- Basic username/password
|
||||
- IP allow/deny list
|
||||
- Optional security headers preset
|
||||
### Brand and docs
|
||||
|
||||
### Step 5: Review and publish
|
||||
- Current icon and wordmark (v0.11.99) used consistently across the login screen, sidebar, themed default pages, and this README.
|
||||
- Integrated, searchable in-app documentation covering every configurable field, including 2FA and the update-notification banner.
|
||||
- Companion marketing site with an installation guide covering Docker Compose, plain `docker run`, and Unraid.
|
||||
|
||||
- Plain-language configuration summary
|
||||
- DNS and router checks
|
||||
- Configuration validation before activation
|
||||
- Immediate rollback if gateway reload fails
|
||||
## What's next
|
||||
|
||||
## Delivery phases
|
||||
Roughly in priority order:
|
||||
|
||||
### Dashboard foundation (implemented in v0.4.0-alpha.1)
|
||||
|
||||
- Default overview with hosted-site, proxy-host, TLS-domain, and attention totals
|
||||
- Gateway, hosted-site, and proxy-host health indicators
|
||||
- Safe runtime reporting for uptime, memory, persistent-data size, disk space, and installed versions
|
||||
- Recent configuration activity for the current container session
|
||||
- Responsive navigation for desktop and mobile
|
||||
- Infrastructure-focused live health for Caddy, HTTP, HTTPS automation, and persistent storage (refined in v0.4.0-alpha.2)
|
||||
- Confirmed port health, clearer storage reporting, local service icons, and resilient dashboard controls (v0.4.0-alpha.3)
|
||||
- Certificate inventory and expiration alerts, proxy upstream monitoring, and filtered rotating access logs (v0.5.0-alpha.1)
|
||||
- Corrected certificate wording and standardized dashboard, card, and log-control spacing (v0.5.0-alpha.2)
|
||||
- Persistent local users, Administrator and Standard User roles, account lifecycle controls, and role-aware sessions (v0.6.0-alpha.1)
|
||||
- Redirect Hosts, Access Lists with themed authentication, advanced Proxy Host controls, custom certificates, configurable fallback pages, integrated documentation, Administration, and backup/restore (v0.7.0-alpha.1)
|
||||
- Built-in SQLite persistence, Local Gateway instance scoping, JSON migration safeguards, unified certificate storage, and database-aware backups (v0.8.0-alpha.1)
|
||||
- First-install sign-in guidance and required one-time administrator account finalization (v0.8.0-alpha.2)
|
||||
- Certificate details, configurable expiration thresholds, guided domain diagnostics, on-demand health checks, redacted support reports, and authentication cleanup (v0.9.0-alpha.1)
|
||||
|
||||
### Completed in v0.9.0-alpha.1 — visibility and certificate health
|
||||
|
||||
This should be the next implementation target. It adds the reporting people rely on in NGINX Proxy Manager without expanding the creation workflow yet.
|
||||
|
||||
- Certificate inventory derived from Caddy's managed certificate storage
|
||||
- Domain, issuer, valid-from, expiration date, and days remaining
|
||||
- Clear **Healthy**, **Renewing soon**, **Expired**, and **Needs attention** states
|
||||
- Dashboard counts for certificates expiring within 30 and 7 days
|
||||
- Last successful renewal and last certificate error when available
|
||||
- Per-host upstream reachability checks with response time and last-check timestamp
|
||||
- Recent gateway errors and a concise per-host access-log view
|
||||
- Diagnostics that distinguish DNS, inbound port, certificate, and upstream failures
|
||||
- Never display private keys, account credentials, or raw sensitive configuration
|
||||
|
||||
### v0.10.0-alpha.1 — gateway completeness (in progress)
|
||||
|
||||
- Basic Caddy upstream pools for load balancing across multiple targets
|
||||
- Universal Dashboard Icons search, custom upload, HTTPS URL, and two-letter fallback
|
||||
- Access List assignment visibility on hosts
|
||||
- Complete themed Default Site responses
|
||||
- Clear certificate renewal-event wording and per-host operational reporting
|
||||
|
||||
### Phase 1 — Domains and automatic HTTPS (gateway alpha implemented)
|
||||
|
||||
- Publish ports 80 and 443
|
||||
- Domain assignment for static sites
|
||||
- Automatic certificate issue and renewal
|
||||
- Force-HTTPS option
|
||||
- Certificate status and expiration reporting (next alpha milestone)
|
||||
- Guided DNS/router readiness checks (next alpha milestone)
|
||||
|
||||
### Phase 2 — Reverse proxy and redirects (implemented)
|
||||
|
||||
- Proxy to other containers, LAN devices, or URLs
|
||||
- WebSocket support
|
||||
- Redirect hosts and offline/404 hosts
|
||||
- Standard security-header presets
|
||||
- Optional HSTS after HTTPS is verified
|
||||
- Per-host access logs and simple health checks
|
||||
|
||||
### Phase 3 — Access and advanced certificates (partially implemented)
|
||||
|
||||
- Themed-login access policies reusable across proxy hosts (implemented)
|
||||
- IP/CIDR allow lists (implemented)
|
||||
- Custom certificate upload (implemented)
|
||||
- Wildcard certificates through selected DNS providers
|
||||
- Backup/export and restore, including encryption and scheduling (implemented)
|
||||
- Configuration validation and automatic restore rollback (implemented); browsable history remains planned
|
||||
|
||||
### Phase 4 — Multi-user and specialist features
|
||||
|
||||
- Multiple administrators and roles
|
||||
- Audit log
|
||||
- TCP/UDP stream forwarding
|
||||
- Rate limiting
|
||||
- Carefully constrained advanced configuration snippets
|
||||
- **Richer certificate diagnostics** — on-demand checks that distinguish DNS, inbound port, TLS, and upstream failures per domain, plus a redacted support-report export.
|
||||
- **Wildcard/DNS-challenge certificates** — selected DNS-provider integrations for domains that can't use HTTP-01 validation. Needs encrypted secret storage for provider API credentials before it ships.
|
||||
- **Browsable backup/restore history** — today a restore validates and rolls back safely, but there's no UI history of past backups beyond what's on disk.
|
||||
- **Container picker for Proxy/Streaming targets** — letting a target be selected from a list of running Docker containers instead of typed as an IP/hostname, gated behind an opt-in Docker-socket mount since it needs real access to the Engine API. Also needs a shared Docker network between Site Gateway and the target container to actually be reachable, not just discoverable.
|
||||
- **Tailscale integration** — documented patterns exist today (host-level Tailscale for private dashboard access, a sidecar container for proxying to tailnet-only targets, `tailscale serve`/`funnel` for exposing a route without opening router ports), but nothing is built into Site Gateway itself yet.
|
||||
- **Dynamic DNS** and **deeper Caddy controls** for advanced users who outgrow the guided options.
|
||||
- **Rate limiting** and other specialist gateway controls.
|
||||
|
||||
## Important constraints
|
||||
|
||||
- Public automatic certificates require working public DNS and inbound access to ports 80/443 unless a DNS challenge is configured.
|
||||
- HSTS should never be enabled by default; a bad configuration can make a domain inaccessible until the browser policy expires.
|
||||
- Wildcard/DNS certificates require storing DNS-provider credentials and therefore need encrypted secret storage.
|
||||
- Wildcard/DNS certificates require storing DNS-provider credentials and therefore need encrypted secret storage before they can ship.
|
||||
- Ports 80 and 443 must not already be owned by another reverse proxy on the same host.
|
||||
- Arbitrary Nginx/Caddy snippets substantially increase support and security risk and should remain an expert-only feature.
|
||||
|
||||
## Scope recommendation
|
||||
|
||||
Prioritize reporting before adding more creation options: certificate health, renewal visibility, upstream checks, and useful logs make the existing gateway trustworthy. Follow that with redirect hosts and reusable access lists. Custom certificates, DNS challenges, streams, multi-user roles, and raw snippets should remain later advanced work because they add credential-storage, validation, and support complexity.
|
||||
|
||||
## NGINX Proxy Manager alignment
|
||||
|
||||
| Capability | Site Gateway direction | Priority |
|
||||
| --- | --- | --- |
|
||||
| Proxy hosts, WebSockets, automatic HTTPS | Implemented through guided Caddy configuration | Current |
|
||||
| Certificate expiration and renewal reporting | First-class certificate health page and dashboard alerts | Next |
|
||||
| Access logs and traffic reporting | Recent requests, status distribution, bytes, and errors per host; avoid promising full analytics | Next |
|
||||
| Upstream health | Reachability, response time, and failure reason per proxy target | Next |
|
||||
| Redirect hosts and maintenance responses | Implemented as Redirect Hosts and configurable Default Site behaviors | Current |
|
||||
| Access lists and authentication | Reusable policies with network rules and a themed sign-in flow | Current |
|
||||
| DNS and reachability diagnostics | Guided checks for resolution, public IP, ports 80/443, and certificate eligibility | Near term |
|
||||
| Custom certificates | Validated matching certificate/key upload and complete-backup support | Current |
|
||||
| Wildcard certificates | Selected DNS-provider integrations with encrypted API credentials | Later |
|
||||
| Advanced proxy options | Custom locations, headers, compression, upstream TLS, health expectations, and validated snippets | Current |
|
||||
| TCP/UDP streams | Separate advanced area with explicit port-conflict checks | Later |
|
||||
| Backup and restore | Configuration/complete archives, browser download/import, schedules, retention, encryption, and rollback | Current |
|
||||
- A Docker-socket-based container picker is opt-in only — socket access is root-equivalent on the host and should never be a default requirement.
|
||||
- Arbitrary Caddy snippets substantially increase support and security risk and stay an expert-only, size-limited, validated feature.
|
||||
|
||||
+10
-1
@@ -1,3 +1,8 @@
|
||||
|
||||
# Pulls the published multi-arch image instead of building from source.
|
||||
# Copy .env.example to .env, set ADMIN_PASSWORD and SESSION_SECRET, then:
|
||||
# docker compose -f compose.release.yaml pull
|
||||
# docker compose -f compose.release.yaml up -d
|
||||
services:
|
||||
site-gateway:
|
||||
image: ${SITE_GATEWAY_IMAGE:-ghcr.io/mfwadejr/site-gateway2:latest}
|
||||
@@ -18,9 +23,13 @@ services:
|
||||
ports:
|
||||
- "${HTTP_PORT:-80}:80"
|
||||
- "${HTTPS_PORT:-443}:443"
|
||||
- "${HTTPS_PORT:-443}:443/udp"
|
||||
- "${HTTPS_PORT:-443}:443/udp" # HTTP/3 (QUIC) — forward UDP 443 on your router/firewall too
|
||||
- "8080:8080"
|
||||
- "9000-9099:9000-9099"
|
||||
# Streaming Hosts (optional): add one line per TCP/UDP port you forward
|
||||
# from the dashboard, matching the target port you'll enter there.
|
||||
# - "25565:25565"
|
||||
# - "25565:25565/udp"
|
||||
volumes:
|
||||
- ${SITE_GATEWAY_DATA:-/DATA/AppData/site-gateway}:/data
|
||||
labels:
|
||||
|
||||
+34
-3
@@ -1,27 +1,58 @@
|
||||
|
||||
services:
|
||||
site-gateway:
|
||||
build: .
|
||||
container_name: site-gateway
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Bootstrap dashboard credentials. Required — change the password before
|
||||
# starting the container. These become the initial Administrator account;
|
||||
# first-time setup will ask you to confirm or replace them.
|
||||
ADMIN_USERNAME: admin
|
||||
ADMIN_PASSWORD: change-this-password
|
||||
# Optional: set a long random SESSION_SECRET to keep sessions valid across
|
||||
# container rebuilds. If omitted, one is derived from ADMIN_USERNAME/ADMIN_PASSWORD.
|
||||
|
||||
# Optional: set a long random string to keep login sessions valid across
|
||||
# container rebuilds. If omitted, one is derived from the admin
|
||||
# username/password above. Rotating this signs everyone out.
|
||||
# SESSION_SECRET: ""
|
||||
|
||||
# Dashboard port inside the container. Change the left side of the
|
||||
# matching entry under `ports:` below if you remap it.
|
||||
ADMIN_PORT: 8080
|
||||
|
||||
# Direct-LAN port range Hosted Sites can bind to. Keep this in sync with
|
||||
# the "9000-9099:9000-9099" line under `ports:` below — Docker can't add
|
||||
# a host port to an already-running container, so widen both together
|
||||
# before starting the container if you need a different range.
|
||||
SITE_PORT_MIN: 9000
|
||||
SITE_PORT_MAX: 9099
|
||||
|
||||
# Where persistent state lives inside the container. Leave this as
|
||||
# /data and change the host side of the volume mount instead.
|
||||
DATA_DIR: /data
|
||||
|
||||
# Optional: encryption password for scheduled Complete/Configuration
|
||||
# backups. Leave empty to store backups unencrypted (not recommended,
|
||||
# since Complete backups include certificate private keys).
|
||||
BACKUP_PASSWORD: ""
|
||||
|
||||
# User/group the container writes persistent files as. Defaults suit a
|
||||
# typical Linux host; Unraid commonly uses PUID=99, PGID=100.
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
|
||||
# Optional: certificate account email, passed to Caddy's ACME client.
|
||||
ACME_EMAIL: ""
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp"
|
||||
- "443:443/udp" # HTTP/3 (QUIC) — forward UDP 443 on your router/firewall too
|
||||
- "8080:8080"
|
||||
- "9000-9099:9000-9099"
|
||||
# Streaming Hosts (optional): add one line per TCP/UDP port you forward
|
||||
# from the dashboard, matching the target port you'll enter there.
|
||||
# Example for a Minecraft server:
|
||||
# - "25565:25565"
|
||||
# - "25565:25565/udp"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
|
||||
# ZimaOS App Store / custom-install compose file for Site Gateway.
|
||||
#
|
||||
# Import this file directly (ZimaOS → Docker → Install a Customized App →
|
||||
# paste/select this file), or use it as the source compose for a ZimaOS
|
||||
# App Store submission — the `x-casaos` block below follows ZimaOS's v2
|
||||
# app-store schema (see docs.zimaspace.com/docs/developer/app-store-compose-x-casaos).
|
||||
#
|
||||
# After install, open the ZimaOS app's settings and change ADMIN_PASSWORD
|
||||
# and SESSION_SECRET before exposing this to anything beyond your LAN.
|
||||
|
||||
name: site-gateway
|
||||
|
||||
services:
|
||||
site-gateway:
|
||||
image: ghcr.io/mfwadejr/site-gateway2:latest
|
||||
container_name: site-gateway
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ADMIN_USERNAME: admin
|
||||
ADMIN_PASSWORD: change-this-password
|
||||
SESSION_SECRET: change-this-too-at-least-32-characters
|
||||
ADMIN_PORT: 8080
|
||||
SITE_PORT_MIN: 9000
|
||||
SITE_PORT_MAX: 9099
|
||||
DATA_DIR: /data
|
||||
BACKUP_PASSWORD: ""
|
||||
# ZimaOS commonly runs its data volumes as 1000:1000; adjust if your
|
||||
# instance differs.
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
ACME_EMAIL: ""
|
||||
ports:
|
||||
- target: 8080
|
||||
published: "8080"
|
||||
protocol: tcp
|
||||
- target: 80
|
||||
published: "80"
|
||||
protocol: tcp
|
||||
- target: 443
|
||||
published: "443"
|
||||
protocol: tcp
|
||||
- target: 443
|
||||
published: "443"
|
||||
protocol: udp
|
||||
- target: 9000
|
||||
published: "9000-9099"
|
||||
protocol: tcp
|
||||
# Streaming Hosts (optional): add one entry per TCP/UDP port you forward
|
||||
# from the dashboard, matching the target port you'll enter there.
|
||||
# - target: 25565
|
||||
# published: "25565"
|
||||
# protocol: tcp
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /DATA/AppData/site-gateway
|
||||
target: /data
|
||||
|
||||
x-casaos:
|
||||
id: dev.mfwadejr.sitegateway
|
||||
main: site-gateway
|
||||
index: /
|
||||
port_map: "8080"
|
||||
scheme: http
|
||||
icon: https://raw.githubusercontent.com/mfwadejr/site-gateway2/main/src/public/site-gateway-icon-approved.png
|
||||
title:
|
||||
en_US: Site Gateway
|
||||
tagline:
|
||||
en_US: Host. Proxy. Secure. A calm dashboard for sites, proxies, and TLS.
|
||||
description:
|
||||
en_US: >-
|
||||
A friendly, self-hosted gateway for homelabs and small teams. Publish
|
||||
static sites, reverse-proxy your apps, forward raw TCP/UDP streams, and
|
||||
manage TLS and access from one dashboard — powered by Caddy.
|
||||
category: Networking
|
||||
author: mfwadejr
|
||||
developer: mfwadejr
|
||||
architectures: ["amd64", "arm64"]
|
||||
version: "0.11.101"
|
||||
update_at: "2026-09-16"
|
||||
website: https://github.com/mfwadejr/site-gateway2
|
||||
repo: https://github.com/mfwadejr/site-gateway2
|
||||
support: https://github.com/mfwadejr/site-gateway2/issues
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "site-gateway",
|
||||
"version": "0.11.35",
|
||||
"version": "0.11.109",
|
||||
"private": true,
|
||||
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
|
||||
"type": "module",
|
||||
@@ -11,7 +11,8 @@
|
||||
"dependencies": {
|
||||
"adm-zip": "0.5.16",
|
||||
"express": "5.1.0",
|
||||
"multer": "2.0.2"
|
||||
"multer": "2.0.2",
|
||||
"qrcode": "1.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
diff -ruN a/package.json b/package.json
|
||||
--- a/package.json 2026-09-13 18:26:23.565353612 +0000
|
||||
+++ b/package.json 2026-09-13 18:26:23.579589390 +0000
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "site-gateway",
|
||||
- "version": "0.11.45",
|
||||
+ "version": "0.11.46",
|
||||
"private": true,
|
||||
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
|
||||
"type": "module",
|
||||
diff -ruN a/src/public/features.js b/src/public/features.js
|
||||
--- a/src/public/features.js 2026-09-13 18:26:23.584420397 +0000
|
||||
+++ b/src/public/features.js 2026-09-13 18:26:23.586550870 +0000
|
||||
@@ -126,7 +126,7 @@
|
||||
document.querySelector("#backup-list").addEventListener("click", async event => { const button = event.target.closest("[data-backup-action]"), row = button?.closest("[data-backup]"); if (!button || !row) return; const filename = row.dataset.backup; try { if (button.dataset.backupAction === "delete") { if (!await themedConfirm("Delete backup?", `This permanently removes ${filename}. It cannot be restored unless you have another copy.`, "Delete backup")) return; await api(`/api/backups/${encodeURIComponent(filename)}`, {method:"DELETE"}); } else { if (!await themedConfirm("Restore this backup?", "Current data will be replaced after a safety backup is created. Site Gateway validates the archive and can roll back if restoration fails.", "Restore backup")) return; const password = document.querySelector('#backup-settings-form [name="backupPassword"]').value; await api(`/api/backups/${encodeURIComponent(filename)}/restore`, {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({password})}); await refresh(); } state.backups = await api("/api/backups"); renderBackups(); toast(button.dataset.backupAction === "delete" ? "Backup deleted." : "Backup restored."); } catch (error) { toast(error.message); } });
|
||||
const restoreButton = document.querySelector("#restore-defaults"), restoreCredentialsBlock = document.querySelector(".danger-credentials"); if (restoreButton && restoreCredentialsBlock && !restoreButton.closest(".restore-form")) { const restoreForm = document.createElement("form"); restoreForm.className = "danger-form restore-form"; restoreCredentialsBlock.replaceWith(restoreForm); restoreForm.append(restoreCredentialsBlock, restoreButton); } const restoreCancel = document.createElement("button"); restoreCancel.type = "button"; restoreCancel.className = "button secondary"; restoreCancel.textContent = "Cancel"; restoreCancel.id = "restore-defaults-cancel"; const restoreActions = document.createElement("div"); restoreActions.className = "danger-actions"; restoreButton.parentNode.insertBefore(restoreActions, restoreButton); restoreActions.append(restoreCancel, restoreButton); restoreCancel.addEventListener("click", () => { document.querySelector("#restore-admin-username").value = ""; document.querySelector("#restore-admin-password").value = ""; document.querySelector("#restore-confirmation").value = ""; document.querySelector("#restore-defaults-error").textContent = ""; }); document.querySelector("#factory-reset-cancel")?.addEventListener("click", () => { document.querySelector("#factory-reset-form").reset(); document.querySelector("#factory-reset-error").textContent = ""; });
|
||||
|
||||
-document.querySelectorAll("#docs-content article").forEach((article, index) => { article.id = `doc-${index}`; }); document.querySelectorAll("[data-doc-jump]").forEach(button => button.addEventListener("click", () => { const key = button.dataset.docJump; const article = [...document.querySelectorAll("#docs-content article")].find(item => item.dataset.doc.includes(key)); article?.scrollIntoView({ behavior:"smooth", block:"start" }); })); document.querySelector("#doc-search").addEventListener("input", event => { const query = event.target.value.trim().toLowerCase(), articles = [...document.querySelectorAll("#docs-content article")]; let visible = 0; articles.forEach((article, index) => { const eyebrow = (article.querySelector(".eyebrow")?.textContent || "").toLowerCase(), heading = (article.querySelector("h2")?.textContent || "").toLowerCase(), keywords = (article.dataset.doc || "").toLowerCase(), topicMatch = !query || eyebrow.includes(query) || heading.includes(query), keywordMatch = !topicMatch && keywords.includes(query), match = topicMatch || keywordMatch || article.textContent.toLowerCase().includes(query); article.classList.toggle("hidden", !match); article.style.order = query && match ? (topicMatch ? index : keywordMatch ? index + articles.length : index + articles.length * 2) : ""; if (match) visible++; }); document.querySelector("#doc-empty").classList.toggle("hidden", visible > 0); });
|
||||
+document.querySelectorAll("#docs-content article").forEach((article, index) => { article.id = `doc-${index}`; }); document.querySelectorAll("[data-doc-jump]").forEach(button => button.addEventListener("click", () => { const key = button.dataset.docJump; const article = [...document.querySelectorAll("#docs-content article")].find(item => item.dataset.doc.includes(key)); article?.scrollIntoView({ behavior:"instant", block:"start" }); })); document.querySelector("#doc-search").addEventListener("input", event => { const query = event.target.value.trim().toLowerCase(), articles = [...document.querySelectorAll("#docs-content article")]; let visible = 0, topResult = null, topOrder = Infinity; articles.forEach((article, index) => { const eyebrow = (article.querySelector(".eyebrow")?.textContent || "").toLowerCase(), heading = (article.querySelector("h2")?.textContent || "").toLowerCase(), keywords = (article.dataset.doc || "").toLowerCase(), topicMatch = !query || eyebrow.includes(query) || heading.includes(query), keywordMatch = !topicMatch && keywords.includes(query), match = topicMatch || keywordMatch || article.textContent.toLowerCase().includes(query), order = topicMatch ? index : keywordMatch ? index + articles.length : index + articles.length * 2; article.classList.toggle("hidden", !match); article.style.order = query && match ? order : ""; if (match) { visible++; if (query && order < topOrder) { topOrder = order; topResult = article; } } }); document.querySelector("#doc-empty").classList.toggle("hidden", visible > 0); if (query && topResult) topResult.scrollIntoView({ behavior:"instant", block:"start" }); });
|
||||
|
||||
document.querySelector("#proxy-dialog").addEventListener("close", () => document.querySelector("#proxy-dialog details")?.removeAttribute("open"));
|
||||
document.querySelectorAll("#proxy-form, #settings-form").forEach(form => form.elements.tls.addEventListener("change", () => { const fields = form.querySelector("#custom-certificate-fields, .custom-certificate-fields"); fields?.classList.toggle("custom-certificate-visible", form.elements.tls.value === "custom"); }));
|
||||
+351
-45
@@ -1,14 +1,25 @@
|
||||
// ============================================================================
|
||||
// app.js -- core client application: state, API helper, theme, dashboard,
|
||||
// Hosted Sites & Proxy Hosts rendering, routing between views, dialogs (create/
|
||||
// edit/icon/user/account/MFA), and every event listener for those areas. The
|
||||
// remaining views (Streaming, Redirects, Access Lists, Administration panels)
|
||||
// live in features.js and are invoked from here via window.renderExtendedViews.
|
||||
// ============================================================================
|
||||
|
||||
// --- Shared DOM shortcut and app state ----------------------------------------
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const summaryBar = document.querySelector("#management-summary");
|
||||
const redirectView = document.querySelector("#redirects-view");
|
||||
if (summaryBar && redirectView) redirectView.parentElement.insertBefore(summaryBar, redirectView);
|
||||
const state = { sites: [], proxies: [], redirects: [], streams: [], accessLists: [], groups: [], backups: [], settings: null, dashboard: null, certificates: null, readiness: null, logs: null, users: [], user: null, config: null, view: "overview", loaded: false, pendingDelete: null, pendingReplace: null, editing: null, iconTarget: null, passwordTarget: null, healthTimer: null };
|
||||
const state = { sites: [], proxies: [], redirects: [], streams: [], accessLists: [], groups: [], backups: [], settings: null, dashboard: null, certificates: null, readiness: null, logs: null, users: [], user: null, config: null, view: "overview", loaded: false, pendingDelete: null, pendingReplace: null, editing: null, iconTarget: null, passwordTarget: null, healthTimer: null, updateCheckTimer: null, loadedVersion: null, updateAvailable: false };
|
||||
|
||||
// One-time DOM patches: move the Access List field into the create/settings
|
||||
// forms (features.js owns the Access List data, this file owns these forms).
|
||||
document.querySelector("#create-form [name=domain]")?.closest("label")?.childNodes[0] && (document.querySelector("#create-form [name=domain]").closest("label").childNodes[0].textContent = "Primary domain ");
|
||||
if (!document.querySelector("#create-form [name=accessListId]")) { const anchor = document.querySelector("#create-form [name=tls]")?.closest("label"); if (anchor) { const label = document.createElement("label"); label.innerHTML = '<span>Access List <span class="optional">Optional</span></span><select name="accessListId"><option value="">Public — no Access List</option></select><small>Protect this hosted site and all of its domains.</small>'; anchor.before(label); } }
|
||||
if (!document.querySelector("#settings-access-list")) { const anchor = document.querySelector("#settings-form [name=domain]")?.closest("label"); if (anchor) { const label = document.createElement("label"); label.innerHTML = '<span>Access List <span class="optional">Optional</span></span><select id="settings-access-list" name="accessListId"><option value="">Public — no Access List</option></select><small>Protect this route and all of its domains.</small>'; anchor.after(label); } }
|
||||
const proxyAccessLabel = document.querySelector("#proxy-form [name=accessListId]")?.closest("label"); const proxyTlsLabel = document.querySelector("#proxy-form [name=tls]")?.closest("label"); if (proxyAccessLabel && proxyTlsLabel) proxyTlsLabel.before(proxyAccessLabel);
|
||||
const settingsAccessLabel = document.querySelector("#settings-access-list")?.closest("label"); const settingsTlsLabel = document.querySelector("#settings-form [name=tls]")?.closest("label"); if (settingsAccessLabel && settingsTlsLabel) settingsTlsLabel.before(settingsAccessLabel);
|
||||
document.querySelector("#settings-advanced [name=accessListId]")?.closest("label")?.remove();
|
||||
|
||||
// --- Theme (light/dark/system) -------------------------------------------------
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
function applyTheme(preference) {
|
||||
@@ -21,17 +32,22 @@ $("#theme-select").value = savedTheme; applyTheme(savedTheme);
|
||||
$("#theme-select").addEventListener("change", event => { localStorage.setItem("webserver-theme", event.target.value); applyTheme(event.target.value); });
|
||||
systemTheme.addEventListener("change", () => { if ($("#theme-select").value === "system") applyTheme("system"); });
|
||||
|
||||
// --- API helper ------------------------------------------------------------------
|
||||
|
||||
async function api(url, options = {}) {
|
||||
const response = await fetch(url, options);
|
||||
if (response.status === 401) { showLogin(); throw new Error("Please sign in again."); }
|
||||
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || "Request failed."); }
|
||||
return response.status === 204 ? null : response.json();
|
||||
}
|
||||
function showLogin(message = "") { state.user = null; state.users = []; state.view = "overview"; const form = $("#login-form"); form.reset(); form.elements.username.value = ""; form.elements.password.value = ""; $("#login").classList.remove("hidden"); $("#dashboard").classList.add("hidden"); $("#login-error").textContent = message; }
|
||||
|
||||
// --- Login/dashboard shell, toast, and small formatting helpers ------------------
|
||||
function showLogin(message = "") { state.user = null; state.users = []; state.view = "overview"; const form = $("#login-form"); form.reset(); form.elements.username.value = ""; form.elements.password.value = ""; $("#login").classList.remove("hidden"); $("#dashboard").classList.add("hidden"); $("#login-error").textContent = message; $("#mfa-login-form").reset(); $("#mfa-login-form").classList.add("hidden"); $("#login-form").classList.remove("hidden"); $("#mfa-login-error").textContent = ""; setTimeout(() => form.elements.username.focus(), 0); }
|
||||
function showDashboard() { $("#login").classList.add("hidden"); $("#dashboard").classList.remove("hidden"); }
|
||||
function toast(message) { const el = $("#toast"); el.textContent = message; el.classList.add("show"); setTimeout(() => el.classList.remove("show"), 2800); }
|
||||
function escapeHtml(value) { const el = document.createElement("div"); el.textContent = value ?? ""; return el.innerHTML; }
|
||||
function publicUrl(item) { return item.domain ? `${item.tls === "http" ? "http" : "https"}://${item.domain}` : `${location.protocol}//${location.hostname}:${item.port}`; }
|
||||
|
||||
function formatBytes(value) {
|
||||
if (!Number.isFinite(value)) return "Unavailable";
|
||||
if (value < 1024) return `${value} B`;
|
||||
@@ -39,28 +55,51 @@ function formatBytes(value) {
|
||||
for (let index = 1; size >= 1024 && index < units.length; index++) { size /= 1024; unit = units[index]; }
|
||||
return `${size >= 10 ? size.toFixed(0) : size.toFixed(1)} ${unit}`;
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (!Number.isFinite(seconds)) return "Unavailable";
|
||||
const days = Math.floor(seconds / 86400), hours = Math.floor(seconds % 86400 / 3600), minutes = Math.floor(seconds % 3600 / 60);
|
||||
if (days) return `${days}d ${hours}h`; if (hours) return `${hours}h ${minutes}m`; return `${minutes}m`;
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) return "Just now";
|
||||
const date = new Date(value); return Number.isNaN(date.getTime()) ? "Recently" : date.toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
|
||||
}
|
||||
|
||||
function formatRelativeTime(value) {
|
||||
if (!value) return "Just now";
|
||||
const date = new Date(value); if (Number.isNaN(date.getTime())) return "Recently";
|
||||
const seconds = Math.round((Date.now() - date.getTime()) / 1000);
|
||||
if (seconds < 45) return "Just now";
|
||||
if (seconds < 90) return "1 minute ago";
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} minutes ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
||||
const days = Math.round(hours / 24);
|
||||
if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;
|
||||
return formatTime(value);
|
||||
}
|
||||
|
||||
function certificateStatusLabel(status) { return ({ healthy:"Healthy", warning:"Renewal due soon", critical:"Renewal required urgently", expired:"Expired", pending:"Awaiting Caddy / ACME certificate", mismatch:"Certificate does not cover this domain" }[status] || String(status || "Unknown")).replaceAll("-", " "); }
|
||||
function parseHeaderLines(value) { return String(value || "").split("\n").map(line => { const index = line.indexOf(":"); return index > 0 ? { name:line.slice(0,index).trim(), value:line.slice(index+1).trim() } : null; }).filter(Boolean); }
|
||||
function monitoringChecked(form, kind) { const scope = kind === "proxy" ? "#settings-advanced" : "#settings-hosted-advanced"; return Boolean(form.querySelector(`${scope} [name="healthEnabled"]`)?.checked); }
|
||||
|
||||
// event.submitter is null on implicit form submission (e.g. pressing Enter in a field instead of
|
||||
// clicking the button), which previously crashed every save handler below on `button.disabled = true`
|
||||
// and silently dropped the whole save. Fall back to the form's actual submit button.
|
||||
function resolveSubmitter(event) { return event.submitter || event.target.querySelector('button:not([type="button"])'); }
|
||||
function scopedValue(form, scope, name, fallback = "") { return form.querySelector(`${scope} [name="${name}"]`)?.value || fallback; }
|
||||
|
||||
// #settings-form reuses field names (healthEnabled, healthPath, accessListId, compression, etc.) between the
|
||||
// hidden site-scoped (#settings-hosted-advanced) and proxy-scoped (#settings-advanced) sections. form.elements.NAME
|
||||
// resolves to a RadioNodeList when a name is duplicated, and assigning .value/.checked to a RadioNodeList of
|
||||
// non-radio inputs silently does nothing — so every one of these fields must be read/written through its scope.
|
||||
function setScoped(form, scope, name, value) { const el = form.querySelector(`${scope} [name="${name}"]`); if (!el) return; if (el.type === "checkbox") el.checked = Boolean(value); else el.value = value; }
|
||||
|
||||
// advancedFormBody -- reads the scoped or unscoped "advanced options" fields off
|
||||
// a create/edit form and merges them into the outgoing request body.
|
||||
function advancedFormBody(form, body, scoped) {
|
||||
// scoped = { scope, formEl } — pass this when `form` came from a shared form (like #settings-form) where
|
||||
// field names collide with another section, so every ambiguous field is read from its own scope instead of
|
||||
@@ -68,29 +107,43 @@ function advancedFormBody(form, body, scoped) {
|
||||
const read = (name, fallback = "") => scoped ? scopedValue(scoped.formEl, scoped.scope, name, fallback) : (form.get(name) || fallback);
|
||||
const checked = (name) => scoped ? Boolean(scoped.formEl.querySelector(`${scoped.scope} [name="${name}"]`)?.checked) : form.has(name);
|
||||
body.domains = String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean);
|
||||
body.hsts = form.has("hsts"); body.hstsSubdomains = checked("hstsSubdomains"); body.healthEnabled = checked("healthEnabled"); body.upstreamTlsInsecure = checked("upstreamTlsInsecure");
|
||||
body.hsts = form.has("hsts"); body.hstsSubdomains = checked("hstsSubdomains"); body.healthEnabled = checked("healthEnabled"); body.upstreamTlsInsecure = checked("upstreamTlsInsecure"); body.blockCommonExploits = checked("blockCommonExploits");
|
||||
body.accessListId = read("accessListId", body.accessListId || "");
|
||||
body.requestHeaders = parseHeaderLines(read("requestHeadersText")); body.responseHeaders = parseHeaderLines(read("responseHeadersText")); body.compression = read("compression", "automatic"); body.customConfig = read("customConfig");
|
||||
body.locations = String(form.get("customLocationsText") || "").split("\n").map(line => { const [path, target, behavior] = line.split("|").map(value => value.trim()); return path && target ? { path, target, stripPrefix:behavior.toLowerCase() === "strip" } : null; }).filter(Boolean);
|
||||
body.upstreams = String(form.get("upstreamsText") || "").split("\n").map(value => value.trim()).filter(Boolean);
|
||||
body.upstreams = String(form.get("upstreamsText") || "").split("\n").map(value => value.trim()).filter(Boolean); body.lbPolicy = read("lbPolicy", "random");
|
||||
body.healthPath = read("healthPath", "/"); body.healthMethod = read("healthMethod", "GET"); body.healthExpected = read("healthExpected", "200-499"); body.healthTimeoutSeconds = Number(read("healthTimeoutSeconds", "4")); body.healthRetries = Number(read("healthRetries", "0"));
|
||||
delete body.requestHeadersText; delete body.responseHeadersText; delete body.customLocationsText;
|
||||
return body;
|
||||
}
|
||||
|
||||
// --- Proxy/Hosted settings dialog: submit handler ---------------------------------
|
||||
// Single capture-path for monitoring settings: unchecked checkboxes must be sent as false.
|
||||
document.addEventListener("submit", async event => {
|
||||
if (event.target?.id !== "settings-form" || !state.editing) return;
|
||||
event.preventDefault(); event.stopImmediatePropagation();
|
||||
const form = new FormData(event.target), button = resolveSubmitter(event);
|
||||
const certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile");
|
||||
let body = Object.fromEntries(form); delete body.certificateFile; delete body.privateKeyFile;
|
||||
if (state.editing.kind === "proxy") body = advancedFormBody(form, body, { scope: "#settings-advanced", formEl: event.target });
|
||||
else { const scope = "#settings-hosted-advanced"; body = { domain: body.domain, tls: body.tls, hsts: form.has("hsts"), accessListId: scopedValue(event.target, scope, "accessListId"), healthEnabled: monitoringChecked(event.target, "site"), healthPath: scopedValue(event.target, scope, "healthPath", "/"), healthMethod: scopedValue(event.target, scope, "healthMethod", "GET"), healthExpected: scopedValue(event.target, scope, "healthExpected", "200-499"), healthTimeoutSeconds: Number(scopedValue(event.target, scope, "healthTimeoutSeconds", "4")), healthRetries: Number(scopedValue(event.target, scope, "healthRetries", "0")), compression: scopedValue(event.target, scope, "compression", "automatic"), requestHeaders: parseHeaderLines(scopedValue(event.target, scope, "requestHeadersText")), responseHeaders: parseHeaderLines(scopedValue(event.target, scope, "responseHeadersText")), hstsSubdomains: event.target.querySelector(`${scope} [name="hstsSubdomains"]`)?.checked === true, customConfig: scopedValue(event.target, scope, "customConfig") }; }
|
||||
else { const scope = "#settings-hosted-advanced"; body = { name: body.name, domain: body.domain, domains: String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean), tls: body.tls, hsts: form.has("hsts"), accessListId: scopedValue(event.target, scope, "accessListId"), healthEnabled: monitoringChecked(event.target, "site"), healthPath: scopedValue(event.target, scope, "healthPath", "/"), healthMethod: scopedValue(event.target, scope, "healthMethod", "GET"), healthExpected: scopedValue(event.target, scope, "healthExpected", "200-499"), healthTimeoutSeconds: Number(scopedValue(event.target, scope, "healthTimeoutSeconds", "4")), healthRetries: Number(scopedValue(event.target, scope, "healthRetries", "0")), compression: scopedValue(event.target, scope, "compression", "automatic"), requestHeaders: parseHeaderLines(scopedValue(event.target, scope, "requestHeadersText")), responseHeaders: parseHeaderLines(scopedValue(event.target, scope, "responseHeadersText")), hstsSubdomains: event.target.querySelector(`${scope} [name="hstsSubdomains"]`)?.checked === true, customConfig: scopedValue(event.target, scope, "customConfig") }; }
|
||||
const uploadCustom = state.editing.kind === "proxy" && body.tls === "custom" && certificate?.size && privateKey?.size;
|
||||
if (state.editing.kind === "proxy" && body.tls === "custom" && !uploadCustom) {
|
||||
const existing = state.proxies.find(item => item.id === state.editing.id);
|
||||
if (!existing?.certificatePath) { $("#settings-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; return; }
|
||||
}
|
||||
button.disabled = true;
|
||||
try { await api(`/api/${state.editing.kind === "proxy" ? "proxies" : "sites"}/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); $("#settings-dialog").close(); await refresh(); toast("Gateway settings applied."); }
|
||||
try {
|
||||
await api(`/api/${state.editing.kind === "proxy" ? "proxies" : "sites"}/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
if (uploadCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${state.editing.id}/certificate`, { method: "POST", body: files }); }
|
||||
$("#settings-dialog").close(); await refresh(); toast("Gateway settings applied.");
|
||||
}
|
||||
catch (error) { $("#settings-error").textContent = error.message; }
|
||||
finally { button.disabled = false; }
|
||||
}, true);
|
||||
|
||||
|
||||
// --- Dashboard rendering ----------------------------------------------------------
|
||||
function healthCopy(group, label) {
|
||||
if (!group.total) return "Nothing configured";
|
||||
if (group.errors) return `${group.errors} ${group.errors === 1 ? label.replace(/s$/, "") : label} need attention`;
|
||||
@@ -105,6 +158,7 @@ function probeCopy(service, ready, error, unconfigured = "Not configured") {
|
||||
return service.status === "ready" ? ready : error;
|
||||
}
|
||||
|
||||
|
||||
function renderDashboardJobs(system) { const columns = document.querySelector("#dashboard-view .dashboard-columns"), health = columns?.firstElementChild; if (!columns) return; let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; columns.insertBefore(panel, columns.children[1] || null); } if (health && health.parentElement === columns) columns.parentElement.insertBefore(health, columns); panel.innerHTML = `<div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div class="dashboard-jobs-list">${(system.jobs || []).map(job => `<div class="dashboard-list-item"><span class="status-dot ${job.enabled ? "running" : "idle"}"></span><span><strong>${escapeHtml(job.name)}</strong><small>${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}</small></span></div>`).join("")}</div>`; }
|
||||
function updateDashboardUptime(seconds) { const started = window.__dashboardStartedAt || (window.__dashboardStartedAt = Date.now() - Number(seconds || 0) * 1000); const target = document.querySelector("#system-uptime"); if (!target) return; const elapsed = Math.max(0, Math.floor((Date.now() - started) / 1000)); target.textContent = formatDuration(elapsed); }
|
||||
function renderDashboard() {
|
||||
@@ -116,12 +170,19 @@ function renderDashboard() {
|
||||
$("#dash-proxy-detail").textContent = healthCopy(data.proxies, "routes");
|
||||
$("#dash-tls-total").textContent = data.tlsDomains;
|
||||
$("#dash-tls-detail").textContent = data.certificates.total ? `${data.certificates.healthy} healthy · ${data.certificates.pending} not detected` : "No TLS domains";
|
||||
$("#dash-redirect-total").textContent = state.redirects?.length || 0;
|
||||
$("#dash-stream-total").textContent = state.streams?.length || 0;
|
||||
$("#dash-attention-total").textContent = data.attention.length;
|
||||
$("#dash-attention-detail").textContent = data.attention.length ? `${data.attention.length} item${data.attention.length === 1 ? "" : "s"} to review` : "No current issues";
|
||||
$("#dash-attention-chip").classList.toggle("accent-warning", data.attention.length > 0);
|
||||
$("#dash-attention-chip").classList.toggle("accent-green", data.attention.length === 0);
|
||||
$("#dash-attention-icon").textContent = data.attention.length > 0 ? "!" : "✓";
|
||||
$("#dash-throughput-total").textContent = data.throughput?.liveRequests ?? 0;
|
||||
const hasErrors = data.attention.length > 0, isChecking = [data.gateway, data.services.http, data.services.https].some(service => service.status === "checking"), hasNothingRunning = !data.hosted.running && !data.proxies.running;
|
||||
const overall = $("#overall-health");
|
||||
overall.className = `health-badge ${hasErrors ? "error" : isChecking || hasNothingRunning ? "warning" : "healthy"}`;
|
||||
overall.textContent = hasErrors ? "Needs attention" : isChecking ? "Checking" : hasNothingRunning ? "Idle" : "Healthy";
|
||||
$("#health-panel").className = `dashboard-panel health-panel ${hasErrors ? "status-error" : isChecking || hasNothingRunning ? "status-warning" : "status-healthy"}`;
|
||||
$("#gateway-health-dot").className = `status-dot ${probeClass(data.gateway)}`;
|
||||
$("#gateway-health-copy").textContent = probeCopy(data.gateway, data.gateway.lastReload ? `Ready · reloaded ${formatTime(data.gateway.lastReload)}` : "Ready and responding", "Caddy is not responding");
|
||||
$("#http-health-dot").className = `status-dot ${probeClass(data.services.http)}`;
|
||||
@@ -130,7 +191,13 @@ function renderDashboard() {
|
||||
$("#https-health-copy").textContent = probeCopy(data.services.https, `Ready and responding · ${data.services.https.activeDomains} TLS domain${data.services.https.activeDomains === 1 ? "" : "s"}`, "Not responding", "Not configured · no TLS domains enabled");
|
||||
$("#storage-health-dot").className = `status-dot ${data.services.storage.healthy ? "running" : "error"}`;
|
||||
$("#storage-health-copy").textContent = data.services.storage.healthy ? "Ready · /data is readable and writable" : "Permission error · check /data";
|
||||
$("#health-checked").textContent = `Last checked ${formatTime(data.checkedAt)}`;
|
||||
const streaming = data.streamingPorts || { total: 0, listening: 0 };
|
||||
$("#streaming-health-dot").className = `status-dot ${!streaming.total ? "inactive" : streaming.listening === streaming.total ? "running" : "error"}`;
|
||||
$("#streaming-health-copy").textContent = !streaming.total ? "No streaming hosts configured" : `${streaming.listening} of ${streaming.total} port${streaming.total === 1 ? "" : "s"} listening`;
|
||||
const upstreams = data.upstreams || { total: 0, healthy: 0, unhealthy: 0 };
|
||||
$("#upstream-health-dot").className = `status-dot ${!upstreams.total ? "inactive" : upstreams.unhealthy > 0 ? "error" : "running"}`;
|
||||
$("#upstream-health-copy").textContent = !upstreams.total ? "No proxy hosts configured" : `${upstreams.healthy} of ${upstreams.total} healthy`;
|
||||
$("#health-checked").innerHTML = `<span class="live-dot" id="health-live-dot"></span>Last checked ${formatTime(data.checkedAt)}`;
|
||||
updateDashboardUptime(data.system.uptimeSeconds);
|
||||
$("#system-memory").textContent = formatBytes(data.system.memoryBytes);
|
||||
$("#system-data").textContent = formatBytes(data.system.dataBytes);
|
||||
@@ -140,11 +207,18 @@ function renderDashboard() {
|
||||
$("#system-caddy-version").textContent = data.system.caddyVersion;
|
||||
$("#system-database").textContent = `${data.system.databaseEngine} · ${data.system.databaseStatus}`;
|
||||
$("#system-database-detail").textContent = `${formatBytes(data.system.databaseBytes)} configuration database`;
|
||||
$("#attention-list").innerHTML = data.attention.length ? data.attention.map(item => `<${item.target ? "button" : "div"} class="dashboard-list-item issue ${item.target ? "issue-link" : ""}" ${item.target ? `data-issue-target="${escapeHtml(item.target)}"` : ""}><span class="status-dot error"></span><span><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.message)}</small></span></${item.target ? "button" : "div"}>`).join("") : '<p class="quiet-state">Everything looks good.</p>';
|
||||
$("#activity-list").innerHTML = data.activity.length ? data.activity.slice(0, 5).map(item => `<div class="dashboard-list-item"><span class="activity-mark ${item.status === "error" ? "bad" : item.status === "warning" ? "warn" : ""}">${item.status === "error" || item.status === "warning" ? "!" : "✓"}</span><span><strong>${escapeHtml(item.message)}</strong><small>${escapeHtml(formatTime(item.at))}</small></span></div>`).join("") : '<p class="quiet-state">No recent activity.</p>';
|
||||
$("#system-public-ip").textContent = data.system.publicIp || (data.system.publicIpError ? "Unavailable" : "Checking…");
|
||||
$("#system-public-ip-detail").textContent = data.system.publicIpError ? `Check failed · ${data.system.publicIpError}` : data.system.publicIpCheckedAt ? `Checked ${formatTime(data.system.publicIpCheckedAt)}` : "Not yet checked";
|
||||
$("#attention-panel").classList.toggle("is-clear", data.attention.length === 0);
|
||||
$("#dashboard-lower-columns").classList.toggle("attention-clear", data.attention.length === 0);
|
||||
$("#attention-list").innerHTML = data.attention.length ? data.attention.map(item => `<${item.target ? "button" : "div"} class="attention-tile ${item.target ? "issue-link" : ""}" ${item.target ? `data-issue-target="${escapeHtml(item.target)}"` : ""}><span class="status-dot error"></span><span class="attention-copy"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.message)}</small></span></${item.target ? "button" : "div"}>`).join("") : '<div class="all-clear"><span class="status-dot running"></span><span>Everything looks good — no issues to review.</span></div>';
|
||||
$("#activity-list").innerHTML = data.activity.length ? data.activity.slice(0, 5).map(item => `<div class="activity-tile"><span class="activity-mark ${item.status === "error" ? "bad" : item.status === "warning" ? "warn" : ""}">${item.status === "error" || item.status === "warning" ? "!" : "✓"}</span><span class="activity-copy"><strong>${escapeHtml(item.message)}</strong><small title="${escapeHtml(formatTime(item.at))}">${escapeHtml(formatRelativeTime(item.at))}</small></span></div>`).join("") : '<p class="quiet-state">No recent activity.</p>';
|
||||
}
|
||||
|
||||
setInterval(() => { if (!document.querySelector("#dashboard-view.hidden")) updateDashboardUptime(); }, 1000);
|
||||
|
||||
|
||||
// --- Card rendering helpers (icons, permissions) -----------------------------------
|
||||
function initials(name) {
|
||||
const words = String(name || "").trim().split(/\s+/).map(word => word.replace(/[^a-z0-9]/gi, "")).filter(Boolean);
|
||||
if (!words.length) return "??";
|
||||
@@ -155,6 +229,8 @@ document.addEventListener('error', event => { const image = event.target; if (!(
|
||||
function canManage() { return ["administrator", "standard"].includes(state.user?.role); }
|
||||
function canAdmin() { return state.user?.role === "administrator"; }
|
||||
|
||||
|
||||
// --- Hosted Sites & Proxy Hosts: card templates ------------------------------------
|
||||
function hostedCard(site) {
|
||||
const status = site.status === "running" ? "running" : site.status === "error" ? "error" : "disabled";
|
||||
const upstream = !site.enabled || site.upstream?.status === "unmonitored" ? "Monitoring paused" : !site.upstream || site.upstream.status === "pending" ? "Upstream check pending" : site.upstream.status === "healthy" ? `Upstream ${site.upstream.httpStatus} · ${site.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(site.upstream.error || "check failed")}`;
|
||||
@@ -171,6 +247,8 @@ function proxyCard(proxy) {
|
||||
return `<article class="site-card proxy" data-id="${proxy.id}" data-kind="proxy"><div class="card-top"><div class="site-icon">${iconMarkup(proxy)}</div>${menu}</div><h2>${escapeHtml(proxy.name)}</h2><p class="address">${escapeHtml(proxy.target)}</p><p class="gateway-address ${proxy.tls !== "http" ? "secure" : ""}">${escapeHtml(publicUrl(proxy))}</p><p class="upstream-copy ${proxy.upstream?.status === "unhealthy" ? "bad" : ""}">${upstream}</p><p class="access-summary">${escapeHtml(access)}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status}"></span>${status === "error" ? "Needs attention" : status[0].toUpperCase() + status.slice(1)}</span><div class="card-actions">${toggle}<a class="launch" href="${publicUrl(proxy)}" target="_blank" rel="noopener" aria-label="Open ${escapeHtml(proxy.name)}">↗</a></div></div></article>`;
|
||||
}
|
||||
|
||||
|
||||
// --- Certificates view --------------------------------------------------------------
|
||||
function renderCertificates() {
|
||||
const data = state.certificates; if (!data) return;
|
||||
$("#certificate-count").textContent = data.summary.total;
|
||||
@@ -181,6 +259,8 @@ function renderCertificates() {
|
||||
renderReadiness();
|
||||
}
|
||||
|
||||
|
||||
// --- Domain readiness (used inside the Certificates view) ---------------------------
|
||||
function renderReadiness() {
|
||||
const routes = state.readiness?.routes || [];
|
||||
$("#readiness-list").innerHTML = routes.length ? routes.map(item => {
|
||||
@@ -189,21 +269,13 @@ function renderReadiness() {
|
||||
const upstreamOk = !item.upstream || item.upstream.status === "healthy";
|
||||
const check = item.upstream;
|
||||
const message = !dnsOk ? `DNS failed${item.dns.error ? ` · ${item.dns.error}` : ""}` : !item.ports.http ? "HTTP port 80 is not responding inside the container" : item.ports.https === false ? "HTTPS port 443 is not responding inside the container" : !tlsOk ? `TLS ${item.tls.status.replaceAll("-", " ")}` : !upstreamOk ? `Upstream ${check?.error || "unavailable"}` : `Ready · DNS ${item.dns.addresses.join(", ")}${check ? ` · upstream ${check.httpStatus || "responding"}` : ""}`;
|
||||
return `<div class="dashboard-list-item readiness-row" role="button" tabindex="0" data-readiness-id="${escapeHtml(item.id)}" aria-label="View diagnostics for ${escapeHtml(item.domain)}"><span class="status-dot ${dnsOk && portsOk && tlsOk && upstreamOk ? "running" : "error"}"></span><span><strong>${escapeHtml(item.domain)}</strong><small>${escapeHtml(message)}</small><span class="readiness-hint">Click to view diagnostics</span></span></div>`;
|
||||
const upstreamDetail = check ? `<div><dt>Upstream</dt><dd>Expected ${escapeHtml(item.upstreamExpected || "200-499")} · received ${check.httpStatus ?? "no response"}${check.responseMs != null ? ` · ${check.responseMs} ms` : ""} · ${check.attempts || 1} attempt${(check.attempts || 1) === 1 ? "" : "s"}</dd></div><div><dt>Last checked</dt><dd>${escapeHtml(formatTime(check.checkedAt))}</dd></div>${check.error ? `<div><dt>Failure detail</dt><dd class="danger-text">${escapeHtml(check.error)}</dd></div>` : ""}` : "<div><dt>Upstream</dt><dd>No upstream health check configured.</dd></div>";
|
||||
return `<details class="certificate-row readiness-row"><summary><span class="status-dot ${dnsOk && portsOk && tlsOk && upstreamOk ? "running" : "error"}"></span><span><strong>${escapeHtml(item.domain)}</strong><small>${escapeHtml(message)}</small></span></summary><dl class="certificate-details"><div><dt>DNS</dt><dd>${item.dns.healthy ? `Resolved${item.dns.addresses.length ? ` · ${escapeHtml(item.dns.addresses.join(", "))}` : ""}` : `Failed${item.dns.error ? ` · ${escapeHtml(item.dns.error)}` : ""}`}</dd></div><div><dt>Gateway ports</dt><dd>HTTP 80 ${item.ports.http ? "responding" : "not responding"} · HTTPS 443 ${item.ports.https === false ? "not responding" : "responding"}</dd></div><div><dt>TLS</dt><dd>${escapeHtml(item.tls.status.replaceAll("-", " "))}</dd></div>${upstreamDetail}</dl></details>`;
|
||||
}).join("") : '<p class="quiet-state">No configured domains to check.</p>';
|
||||
}
|
||||
|
||||
function showReadinessDetails(item) {
|
||||
const check = item.upstream;
|
||||
const upstream = check ? `<div><dt>Upstream</dt><dd>Expected ${escapeHtml(item.upstreamExpected || "200-499")} · received ${check.httpStatus ?? "no response"}${check.responseMs != null ? ` · ${check.responseMs} ms` : ""} · ${check.attempts || 1} attempt${(check.attempts || 1) === 1 ? "" : "s"}</dd></div><div><dt>Last checked</dt><dd>${escapeHtml(formatTime(check.checkedAt))}</dd></div>${check.error ? `<div><dt>Failure detail</dt><dd class="danger-text">${escapeHtml(check.error)}</dd></div>` : ""}` : "<div><dt>Upstream</dt><dd>No upstream health check configured.</dd></div>";
|
||||
$("#readiness-title").textContent = item.domain;
|
||||
$("#readiness-detail-content").innerHTML = `<dl class="readiness-detail-grid"><div><dt>DNS</dt><dd>${item.dns.healthy ? `Resolved${item.dns.addresses.length ? ` · ${escapeHtml(item.dns.addresses.join(", "))}` : ""}` : `Failed${item.dns.error ? ` · ${escapeHtml(item.dns.error)}` : ""}`}</dd></div><div><dt>Gateway ports</dt><dd>HTTP 80 ${item.ports.http ? "responding" : "not responding"} · HTTPS 443 ${item.ports.https === false ? "not responding" : "responding"}</dd></div><div><dt>TLS</dt><dd>${escapeHtml(item.tls.status.replaceAll("-", " "))}</dd></div>${upstream}</dl>`;
|
||||
$("#readiness-dialog").showModal();
|
||||
}
|
||||
|
||||
$("#readiness-list").addEventListener("click", event => { const row = event.target.closest("[data-readiness-id]"); const item = state.readiness?.routes?.find(route => route.id === row?.dataset.readinessId); if (item) showReadinessDetails(item); });
|
||||
$("#readiness-list").addEventListener("keydown", event => { if (event.key !== "Enter" && event.key !== " ") return; const row = event.target.closest("[data-readiness-id]"); if (row) { event.preventDefault(); row.click(); } });
|
||||
|
||||
// --- Logs view -------------------------------------------------------------------------
|
||||
function renderLogs() {
|
||||
const data = state.logs; if (!data) return;
|
||||
const selected = $("#log-host").value; $("#log-host").innerHTML = '<option value="">All domains</option>' + data.hosts.map(host => `<option value="${escapeHtml(host)}">${escapeHtml(host)}</option>`).join(""); $("#log-host").value = selected;
|
||||
@@ -217,6 +289,62 @@ function renderLogs() {
|
||||
$("#gateway-log-list").innerHTML = activity.length ? activity.map(item => { const eventCategory = categoryOf(item.message); const indicatorClass = item.status === "error" ? "disabled" : item.status === "warning" ? "error" : "running"; return `<div class="event-row"><span class="status-dot ${indicatorClass}" aria-label="${escapeHtml(item.status || "ok")}"></span><span><strong>${escapeHtml(item.message)}</strong><small>${escapeHtml(eventCategory)} · ${escapeHtml(formatTime(item.at))}</small></span></div>`; }).join("") : '<div class="gateway-empty-state"><span class="status-dot"></span><strong>No matching gateway events</strong><small>Try a different severity or category filter.</small></div>';
|
||||
}
|
||||
|
||||
|
||||
// --- Performance view: summary, request trend chart (hand-drawn SVG sparkline),
|
||||
// and the per-domain throughput table -----------------------------------------------
|
||||
function renderPerformance() {
|
||||
const data = state.performance; if (!data) return;
|
||||
const selected = $("#performance-host").value;
|
||||
$("#performance-host").innerHTML = '<option value="">All domains</option>' + data.hosts.map(host => `<option value="${escapeHtml(host)}">${escapeHtml(host)}</option>`).join("");
|
||||
$("#performance-host").value = selected;
|
||||
const label = selected ? escapeHtml(selected) : "all domains";
|
||||
$("#performance-summary").innerHTML = `${data.liveRequests} request${data.liveRequests === 1 ? "" : "s"} in the last minute across ${label} · <span id="performance-last-checked">Checked ${escapeHtml(formatTime(data.checkedAt))}</span>`;
|
||||
const rangeLabel = $("#performance-range").selectedOptions[0]?.textContent || "Last 6 hours";
|
||||
$("#performance-trend-title").textContent = `Requests · ${rangeLabel.toLowerCase()}${selected ? ` · ${selected}` : ""}`;
|
||||
const points = data.trend || [];
|
||||
const max = Math.max(1, ...points.map(point => point.count));
|
||||
const left = 34, right = 8, top = 10, bottom = 20, width = 600, height = 140;
|
||||
const plotWidth = width - left - right, plotHeight = height - top - bottom;
|
||||
const xAt = index => left + (points.length > 1 ? (index / (points.length - 1)) * plotWidth : plotWidth);
|
||||
const yAt = count => top + plotHeight - (count / max) * plotHeight;
|
||||
const gridFractions = [0, 0.5, 1];
|
||||
const gridLines = gridFractions.map(fraction => {
|
||||
const y = (top + plotHeight * (1 - fraction)).toFixed(1);
|
||||
return `<line x1="${left}" y1="${y}" x2="${width - right}" y2="${y}" stroke="var(--line)" stroke-width="1" />`;
|
||||
}).join("");
|
||||
const leftPct = (left / width) * 100, topPct = 0, plotHeightPct = (plotHeight / height) * 100, topInsetPct = (top / height) * 100;
|
||||
const axisLabels = gridFractions.map(fraction => {
|
||||
const value = Math.round(max * fraction);
|
||||
const yPct = topInsetPct + plotHeightPct * (1 - fraction);
|
||||
return `<span class="axis-label" style="left:0;width:${(leftPct - 2).toFixed(2)}%;top:${yPct.toFixed(2)}%;text-align:right">${value}</span>`;
|
||||
}).join("");
|
||||
const firstPoint = points[0], lastPoint = points[points.length - 1];
|
||||
const timeLabels = points.length ? `<span class="time-label" style="left:${leftPct.toFixed(2)}%">${escapeHtml(formatTime(firstPoint.at))}</span><span class="time-label time-label-end" style="left:${(100 - (right / width) * 100).toFixed(2)}%">${escapeHtml(formatTime(lastPoint.at))}</span>` : "";
|
||||
$("#performance-sparkline-labels").innerHTML = points.length ? `${axisLabels}${timeLabels}` : "";
|
||||
const coords = points.map((point, index) => [xAt(index), yAt(point.count)]);
|
||||
const smoothLine = coords.length < 2 ? "" : coords.reduce((d, point, index) => {
|
||||
if (index === 0) return `M${point[0].toFixed(1)},${point[1].toFixed(1)}`;
|
||||
const p0 = coords[index - 2 >= 0 ? index - 2 : index - 1];
|
||||
const p1 = coords[index - 1];
|
||||
const p2 = point;
|
||||
const p3 = coords[index + 1] || point;
|
||||
const cp1x = p1[0] + (p2[0] - p0[0]) / 6, cp1y = p1[1] + (p2[1] - p0[1]) / 6;
|
||||
const cp2x = p2[0] - (p3[0] - p1[0]) / 6, cp2y = p2[1] - (p3[1] - p1[1]) / 6;
|
||||
return `${d} C${cp1x.toFixed(1)},${cp1y.toFixed(1)} ${cp2x.toFixed(1)},${cp2y.toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`;
|
||||
}, "");
|
||||
const baseline = (top + plotHeight).toFixed(1);
|
||||
const areaPath = coords.length ? `${smoothLine} L${coords[coords.length - 1][0].toFixed(1)},${baseline} L${coords[0][0].toFixed(1)},${baseline} Z` : "";
|
||||
$("#performance-sparkline").setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||
$("#performance-sparkline").innerHTML = points.length ? `${gridLines}<path d="${areaPath}" fill="var(--green)" opacity="0.12" stroke="none" /><path d="${smoothLine}" fill="none" stroke="var(--green)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />` : "";
|
||||
if (!points.length) $("#performance-sparkline-labels").innerHTML = '<span class="axis-label" style="left:0;width:100%;top:45%;text-align:center">No request data for this window yet.</span>';
|
||||
const routes = data.routes || [];
|
||||
const countCell = (count, errors, breakdown) => { const title = breakdown?.length ? ` title="${escapeHtml(breakdown.map(item => `${item.status}: ${item.count.toLocaleString()}`).join(" · "))}"` : ""; return `${count.toLocaleString()}${errors ? ` <span class="count-divider">·</span> <span class="http-status bad"${title}>${errors.toLocaleString()}</span>` : ""}`; };
|
||||
$("#performance-rows").innerHTML = routes.length ? routes.map(route => `<tr class="${selected && route.host === selected ? "row-highlight" : ""}"><td title="${escapeHtml(route.host)}">${escapeHtml(route.host)}</td><td>${countCell(route.hourRequests, route.hourErrors)}</td><td>${countCell(route.dayRequests, route.dayErrors, route.errorBreakdown)}</td><td>${route.dayAvgMs == null ? "—" : `${route.dayAvgMs} ms`}</td></tr>`).join("") : '<tr><td colspan="4" class="quiet-state">No requests have been logged yet.</td></tr>';
|
||||
if (selected) $(`#performance-rows tr.row-highlight`)?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
|
||||
// --- Administration > Users view ---------------------------------------------------------
|
||||
function renderUsers() {
|
||||
const counts = { administrator: 0, standard: 0, viewer: 0, disabled: 0, archived: 0 };
|
||||
state.users.forEach(user => { if (user.status === "active") counts[user.role] = (counts[user.role] || 0) + 1; else if (counts[user.status] !== undefined) counts[user.status] += 1; });
|
||||
@@ -229,30 +357,51 @@ function renderUsers() {
|
||||
const roleLabel = user.role === "administrator" ? "Administrator" : user.role === "viewer" ? "Viewer" : "Standard User";
|
||||
const lifecycle = user.status === "archived" ? `<button class="button secondary" data-user-action="status" data-value="active">Restore</button>` : `<button class="button secondary danger-text" data-user-action="status" data-value="archived">Archive</button>`;
|
||||
const statusToggle = user.status === "archived" ? "" : `<button class="toggle ${user.status === "active" ? "on" : ""}" data-user-action="status" data-value="${user.status === "active" ? "disabled" : "active"}" aria-label="${user.status === "active" ? "Disable" : "Enable"} ${escapeHtml(user.username)}"><span></span></button>`;
|
||||
const deleteAction = !isSelf ? `<button class="button secondary danger-text" data-user-action="delete">Delete</button>` : "";
|
||||
return `<article class="user-card" data-user-id="${user.id}"><div class="user-card-head"><div class="user-avatar">${escapeHtml(initials(user.displayName))}</div><span class="status-pill"><span class="status-dot ${statusClass}"></span>${escapeHtml(user.status)}</span></div><h2>${escapeHtml(user.displayName)}${isSelf ? ' <small>You</small>' : ""}</h2><p class="address">${escapeHtml(user.username)}</p><div class="user-meta"><span>${roleLabel}</span><span>${user.lastLoginAt ? `Last login ${escapeHtml(formatTime(user.lastLoginAt))}` : "Never signed in"}</span></div><div class="user-actions"><button class="button secondary" data-user-action="role" data-value="${roleAction}">Make ${roleAction === "administrator" ? "Administrator" : roleAction === "viewer" ? "Viewer" : "Standard"}</button><button class="button secondary" data-user-action="password">Reset password</button>${lifecycle}${deleteAction}</div><div class="card-footer">${statusToggle}</div></article>`;
|
||||
const menu = `<div class="menu-wrap"><button class="icon-button menu-button" type="button" aria-label="User options" aria-expanded="false">•••</button><div class="menu"><button data-user-action="icon">Change icon</button>${!isSelf ? `<button data-user-action="delete" class="danger-text">Delete</button>` : ""}</div></div>`;
|
||||
return `<article class="user-card" data-user-id="${user.id}"><div class="user-card-head"><div class="user-avatar">${escapeHtml(initials(user.displayName))}</div><div class="user-head-actions"><span class="status-pill"><span class="status-dot ${statusClass}"></span>${escapeHtml(user.status)}</span>${menu}</div></div><h2>${escapeHtml(user.displayName)}${isSelf ? ' <small>You</small>' : ""}</h2><p class="address">${escapeHtml(user.username)}</p><div class="user-meta"><span>${roleLabel}</span><span>${user.lastLoginAt ? `Last login ${escapeHtml(formatTime(user.lastLoginAt))}` : "Never signed in"}</span></div><div class="user-actions"><button class="button secondary" data-user-action="role" data-value="${roleAction}">Make ${roleAction === "administrator" ? "Administrator" : roleAction === "viewer" ? "Viewer" : "Standard"}</button><button class="button secondary" data-user-action="password">Reset password</button>${lifecycle}</div><div class="card-footer">${statusToggle}</div></article>`;
|
||||
}).join("") : '<p class="quiet-state">No users found.</p>';
|
||||
document.querySelectorAll("#user-list .user-card").forEach(card => { card.style.position = "relative"; card.style.minHeight = "250px"; card.style.paddingBottom = "64px"; const user = state.users.find(item => item.id === card.dataset.userId); const head = card.querySelector(".user-card-head"), status = head?.querySelector(".status-pill"), footer = card.querySelector(".card-footer"); if (!user || !head || !footer) return; if (status) footer.prepend(status); const menu = document.createElement("div"); menu.className = "menu-wrap"; menu.innerHTML = '<button class="icon-button" type="button" aria-label="Change user icon">•••</button>'; menu.querySelector("button").addEventListener("click", () => openIconPicker("users", user.id)); head.append(menu); });
|
||||
document.querySelectorAll("#user-list .user-card").forEach(card => { const user = state.users.find(item => item.id === card.dataset.userId); const old = card.querySelector('[data-user-action="role"]'); if (!user || !old) return; const select = document.createElement("select"); select.className = "user-role-select"; select.style.cssText = "height:44px;min-height:44px;width:100%;box-sizing:border-box;padding:0 42px 0 12px;border:1px solid var(--line);border-radius:9px;background:var(--panel);color:var(--text);line-height:42px"; select.setAttribute("aria-label", `Role for ${user.username}`); select.innerHTML = '<option value="administrator">Administrator</option><option value="standard">Standard User</option><option value="viewer">Viewer</option>'; select.value = user.role; select.addEventListener("change", async () => { try { await api(`/api/users/${user.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ role:select.value }) }); await loadFeatureView(); toast("User role updated."); } catch (error) { select.value = user.role; toast(error.message); } }); old.replaceWith(select); });
|
||||
document.querySelectorAll("#user-list .user-card").forEach(card => { card.style.position = "relative"; card.style.minHeight = "250px"; card.style.paddingBottom = "64px"; const head = card.querySelector(".user-card-head"), status = head?.querySelector(".status-pill"), footer = card.querySelector(".card-footer"); if (!head || !footer) return; if (status) footer.prepend(status); });
|
||||
document.querySelectorAll("#user-list .user-card").forEach(card => { const user = state.users.find(item => item.id === card.dataset.userId); const old = card.querySelector('[data-user-action="role"]'); if (!user || !old) return; const select = document.createElement("select"); select.className = "user-role-select"; select.setAttribute("aria-label", `Role for ${user.username}`); select.innerHTML = '<option value="administrator">Administrator</option><option value="standard">Standard User</option><option value="viewer">Viewer</option>'; select.value = user.role; select.addEventListener("change", async () => { try { await api(`/api/users/${user.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ role:select.value }) }); await loadFeatureView(); toast("User role updated."); } catch (error) { select.value = user.role; toast(error.message); } }); old.replaceWith(select); });
|
||||
}
|
||||
|
||||
|
||||
// --- Account panel (profile, MFA status) --------------------------------------------------
|
||||
function renderAccount() {
|
||||
if (!state.user) return;
|
||||
$("#account-display-name").textContent = state.user.displayName || "—";
|
||||
$("#account-username").textContent = state.user.username || "—";
|
||||
$("#account-role").textContent = state.user.role === "administrator" ? "Administrator" : state.user.role === "viewer" ? "Viewer" : "Standard User";
|
||||
const enabled = Boolean(state.user.mfaEnabled);
|
||||
const pill = $("#account-mfa-status");
|
||||
pill.innerHTML = `<span class="status-dot ${enabled ? "running" : "inactive"}"></span>${enabled ? "On" : "Off"}`;
|
||||
$("#account-mfa-enable").classList.toggle("hidden", enabled);
|
||||
$("#account-mfa-disable").classList.toggle("hidden", !enabled);
|
||||
$("#account-mfa-recovery").classList.toggle("hidden", !enabled);
|
||||
}
|
||||
|
||||
|
||||
// --- View routing: what data to (re)load and what to show for state.view ------------------
|
||||
async function loadFeatureView() {
|
||||
if (state.view === "certificates") { [state.certificates, state.readiness] = await Promise.all([api("/api/certificates"), api("/api/readiness")]); renderCertificates(); }
|
||||
if (state.view === "logs") { state.logs = await api(`/api/logs?host=${encodeURIComponent($("#log-host").value)}`); renderLogs(); }
|
||||
if (state.view === "performance") { state.performance = await api(`/api/performance?host=${encodeURIComponent($("#performance-host").value)}&hours=${encodeURIComponent($("#performance-range").value || "6")}`); renderPerformance(); }
|
||||
if (state.view === "administration") { [state.users, state.settings, state.backups] = await Promise.all([api("/api/users"), api("/api/settings"), api("/api/backups")]); renderUsers(); window.renderExtendedViews?.(); }
|
||||
if (["redirects","access","documentation"].includes(state.view)) window.renderExtendedViews?.();
|
||||
restoreAdminTab();
|
||||
}
|
||||
// render() -- the main view switcher. Shows/hides each top-level section based on
|
||||
// state.view, and for the Hosted/Proxy "management" view, renders the card grid,
|
||||
// empty state, and summary indicator bar directly.
|
||||
function render() {
|
||||
const viewHash = state.view === "administration" ? `administration/${state.adminTab || "users"}` : state.view;
|
||||
if (location.hash !== `#${viewHash}`) history.replaceState(null, "", `${location.pathname}${location.search}#${viewHash}`);
|
||||
if (location.hash !== `#${viewHash}`) history.pushState(null, "", `${location.pathname}${location.search}#${viewHash}`);
|
||||
$("#hosted-count").textContent = state.sites.length; $("#proxy-count").textContent = state.proxies.length; $("#streaming-count").textContent = state.streams.length; $("#redirect-count").textContent = state.redirects.length; $("#access-count").textContent = state.accessLists.length; $("#certificate-count").textContent = state.certificates?.summary.total || 0;
|
||||
document.querySelectorAll("nav [data-view], .aside-utilities [data-view]").forEach(button => button.classList.toggle("nav-active", button.dataset.view === state.view));
|
||||
const overview = state.view === "overview";
|
||||
$("#dashboard-view").classList.toggle("hidden", !overview);
|
||||
const management = state.view === "hosted" || state.view === "proxies";
|
||||
$("#management-view").classList.toggle("hidden", !management); $("#management-summary").classList.toggle("hidden", !(management || state.view === "streaming" || state.view === "redirects" || state.view === "access"));
|
||||
$("#certificates-view").classList.toggle("hidden", state.view !== "certificates"); $("#logs-view").classList.toggle("hidden", state.view !== "logs"); $("#users-view").classList.toggle("hidden", state.view !== "administration");
|
||||
$("#certificates-view").classList.toggle("hidden", state.view !== "certificates"); $("#logs-view").classList.toggle("hidden", state.view !== "logs"); $("#performance-view").classList.toggle("hidden", state.view !== "performance"); $("#users-view").classList.toggle("hidden", state.view !== "administration"); $("#account-view").classList.toggle("hidden", state.view !== "account");
|
||||
if (state.view === "administration") { const adminTab = state.adminTab || "users"; document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item.dataset.adminTab === adminTab)); document.querySelectorAll("[data-admin-panel]").forEach(panel => panel.classList.toggle("hidden", panel.dataset.adminPanel !== adminTab)); }
|
||||
$("#streaming-view").classList.toggle("hidden", state.view !== "streaming"); $("#redirects-view").classList.toggle("hidden", state.view !== "redirects"); $("#access-view").classList.toggle("hidden", state.view !== "access"); $("#documentation-view").classList.toggle("hidden", state.view !== "documentation");
|
||||
const adminUsersActive = state.view === "administration" && document.querySelector("[data-admin-tab].tab-active")?.dataset.adminTab === "users";
|
||||
@@ -264,7 +413,7 @@ function render() {
|
||||
return;
|
||||
}
|
||||
if (!management) {
|
||||
const headings = { certificates:["Certificates","Expiration, issuer, and certificate-detection status for automatic HTTPS."], logs:["Access Logs & Gateway Events","Recent requests, upstream responses, and gateway health events served through Caddy."], administration:["Administration","Users, gateway defaults, backups, security, and updates."], streaming:["Streaming hosts","Forward raw TCP/UDP traffic on a specific port straight to another host and port."], redirects:["Redirect hosts","Send domains to a new destination with clear, predictable rules."], access:["Access Lists","Create reusable network and login protection for your hosts."], documentation:["Documentation","Plain-language guidance and real-world Site Gateway examples."] };
|
||||
const headings = { certificates:["Certificates","Expiration, issuer, and certificate-detection status for automatic HTTPS."], logs:["Access Logs & Gateway Events","Recent requests, upstream responses, and gateway health events served through Caddy."], performance:["Performance","Live and historical request throughput across your gateway."], administration:["Administration","Users, gateway defaults, backups, and updates."], streaming:["Streaming hosts","Forward raw TCP/UDP traffic on a specific port straight to another host and port."], redirects:["Redirect hosts","Send domains to a new destination with clear, predictable rules."], access:["Access Lists","Create reusable network and login protection for your hosts."], documentation:["Documentation","Plain-language guidance and real-world Site Gateway examples."], account:["My Account","Manage your profile, password, and two-factor authentication."] };
|
||||
const heading = headings[state.view] || ["Site Gateway",""]; $("#page-title").textContent = heading[0]; $("#page-subtitle").textContent = heading[1];
|
||||
$("#open-create").textContent = state.view === "administration" ? "+ Create user" : state.view === "streaming" ? "+ New streaming host" : state.view === "redirects" ? "+ New redirect host" : state.view === "access" ? "+ New Access List" : $("#open-create").textContent;
|
||||
if (state.view === "streaming") $("#stream-empty").classList.toggle("hidden", !state.loaded || state.streams.length > 0);
|
||||
@@ -272,12 +421,13 @@ function render() {
|
||||
if (state.view === "redirects") $("#redirect-empty .create-trigger").textContent = "Create a redirect host";
|
||||
if (state.view === "redirects") $("#redirect-empty").classList.toggle("hidden", !state.loaded || state.redirects.length > 0);
|
||||
if (state.view === "redirects") { const items = state.redirects; const running = items.filter(item => item.enabled !== false).length, disabled = items.length - running; $("#running-count").textContent = running; $("#disabled-count").textContent = disabled; $("#error-count").textContent = 0; $("#running-label").textContent = running ? "Running" : "None running"; $("#disabled-label").textContent = disabled ? "Disabled" : "None disabled"; $("#error-label").textContent = "No issues"; $("#running-dot").className = `status-dot ${running ? "running" : "inactive"}`; $("#disabled-dot").className = `status-dot ${disabled ? "disabled" : "inactive"}`; $("#error-dot").className = "status-dot inactive"; $(".port-note").classList.add("hidden"); }
|
||||
if (state.view === "certificates") renderCertificates(); else if (state.view === "administration") renderUsers(); else if (state.view === "logs") renderLogs();
|
||||
if (state.view === "certificates") renderCertificates(); else if (state.view === "administration") renderUsers(); else if (state.view === "logs") renderLogs(); else if (state.view === "performance") renderPerformance(); else if (state.view === "account") renderAccount();
|
||||
return;
|
||||
}
|
||||
const items = state.view === "hosted" ? state.sites : state.proxies;
|
||||
$("#site-grid").innerHTML = items.map(state.view === "hosted" ? hostedCard : proxyCard).join("");
|
||||
$("#empty").classList.toggle("hidden", !state.loaded || items.length > 0);
|
||||
$("#empty .empty-icon").textContent = state.view === "hosted" ? "↗" : "⇌";
|
||||
$("#empty h2").textContent = state.view === "hosted" ? "Publish your first site" : "Create your first proxy host";
|
||||
$("#empty p").textContent = state.view === "hosted" ? "Upload a ZIP and optionally connect a domain with automatic HTTPS." : "Connect a domain to another container, application, or LAN service.";
|
||||
$("#page-title").textContent = state.view === "hosted" ? "Hosted sites" : "Proxy hosts";
|
||||
@@ -292,6 +442,8 @@ function render() {
|
||||
$("#running-label").textContent = running ? "Running" : "None running"; $("#disabled-label").textContent = disabled ? "Disabled" : "None disabled"; $("#error-label").textContent = errors ? "Needs attention" : "No issues";
|
||||
$("#running-dot").className = `status-dot ${running ? "running" : "inactive"}`; $("#disabled-dot").className = `status-dot ${disabled ? "disabled" : "inactive"}`; $("#error-dot").className = `status-dot ${errors ? "error" : "inactive"}`;
|
||||
}
|
||||
|
||||
// --- Data refresh helpers ------------------------------------------------------------------
|
||||
async function refresh() { const requests = [api("/api/sites"), api("/api/proxies"), api("/api/redirects"), api("/api/streams"), api("/api/access-lists"), canAdmin() ? api("/api/groups") : Promise.resolve([]), api("/api/dashboard"), api("/api/certificates")]; const results = await Promise.allSettled(requests); results.forEach((result, index) => { if (result.status !== "fulfilled") return; const keys = ["sites", "proxies", "redirects", "streams", "accessLists", "groups", "dashboard", "certificates"]; state[keys[index]] = result.value; }); state.loaded = true; render(); window.renderExtendedViews?.(); const pending = state.proxies.filter(proxy => proxy.enabled !== false && !proxy.upstream).map(proxy => proxy.id); if (pending.length && !state.pendingProxyRefresh) { state.pendingProxyRefresh = true; refreshPendingProxies(pending).finally(() => { state.pendingProxyRefresh = false; }); } }
|
||||
async function refreshPendingProxies(ids = []) {
|
||||
const pending = new Set(ids.map(String));
|
||||
@@ -303,10 +455,12 @@ async function refreshPendingProxies(ids = []) {
|
||||
}
|
||||
}
|
||||
async function refreshDashboard() {
|
||||
const button = $("#refresh-health"); button.disabled = true; button.classList.add("spinning"); $("#health-checked").textContent = "Checking services…";
|
||||
const button = $("#refresh-health"); button.disabled = true; button.classList.add("spinning"); $("#health-checked").innerHTML = '<span class="live-dot checking"></span>Checking services…';
|
||||
try { state.dashboard = await api("/api/dashboard"); renderDashboard(); }
|
||||
finally { button.disabled = false; button.classList.remove("spinning"); }
|
||||
}
|
||||
|
||||
// --- Boot: session check, initial routing, periodic health/update checks -------------------
|
||||
function restoreAdminTab() { if (state.view === "administration") document.querySelector(`[data-admin-tab="${state.adminTab || "users"}"]`)?.click(); }
|
||||
async function boot() {
|
||||
const requestedHash = location.hash.slice(1); state.adminTab = requestedHash.startsWith("administration/") ? requestedHash.split("/")[1] || "users" : "users"; if (requestedHash.startsWith("administration/")) history.replaceState(null, "", `${location.pathname}${location.search}#administration`);
|
||||
@@ -317,83 +471,126 @@ async function boot() {
|
||||
if (session.setupRequired) { $("#login").classList.add("hidden"); $("#dashboard").classList.add("hidden"); $("#setup-form [name=username]").value = session.user.username; if (!$("#setup-dialog").open) $("#setup-dialog").showModal(); return; }
|
||||
state.view = location.hash.slice(1) || "overview"; state.users = []; showDashboard(); state.user = session.user; $("#user-label").textContent = session.user?.displayName || session.username; document.querySelectorAll(".admin-only").forEach(element => element.classList.toggle("hidden", !canAdmin())); render(); state.config = await api("/api/config");
|
||||
$("#version-label").textContent = `v${state.config.version || "unknown"}`;
|
||||
if (!state.loadedVersion) state.loadedVersion = state.config.version;
|
||||
$("#port-range").textContent = `${state.config.minPort}–${state.config.maxPort}`; $("#port-help").textContent = `Direct LAN access range: ${state.config.minPort}–${state.config.maxPort}`;
|
||||
$("#create-form [name=port]").min = state.config.minPort; $("#create-form [name=port]").max = state.config.maxPort; await refresh(); if (state.view !== "overview") await loadFeatureView();
|
||||
if (!state.healthTimer) state.healthTimer = setInterval(() => { if (state.view === "overview" && !$("#dashboard").classList.contains("hidden")) refreshDashboard().catch(error => toast(error.message)); }, 30000);
|
||||
if (!state.updateCheckTimer) state.updateCheckTimer = setInterval(() => { if (!$("#dashboard").classList.contains("hidden")) checkForUpdate().catch(() => {}); }, 60000);
|
||||
}
|
||||
|
||||
$("#login-form").addEventListener("submit", async event => { event.preventDefault(); $("#login-error").textContent = ""; try { await api("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); event.target.reset(); await boot(); } catch (error) { $("#login-error").textContent = error.message; } });
|
||||
async function checkForUpdate() {
|
||||
if (state.updateAvailable || !state.loadedVersion) return;
|
||||
const config = await api("/api/config");
|
||||
if (config.version && config.version !== state.loadedVersion) { state.updateAvailable = true; $("#update-banner").classList.remove("hidden"); }
|
||||
}
|
||||
|
||||
// --- Update-available banner --------------------------------------------------------------
|
||||
$("#update-banner-refresh").addEventListener("click", () => location.reload());
|
||||
$("#update-banner-dismiss").addEventListener("click", () => { $("#update-banner").classList.add("hidden"); state.updateAvailable = false; });
|
||||
|
||||
|
||||
// --- Login, MFA login, first-run setup, and logout -----------------------------------------
|
||||
$("#login-form").addEventListener("submit", async event => { event.preventDefault(); $("#login-error").textContent = ""; try { const result = await api("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); if (result?.mfaRequired) { $("#login-form").classList.add("hidden"); $("#mfa-login-form").classList.remove("hidden"); $("#mfa-login-form [name=code]").focus(); return; } event.target.reset(); history.replaceState(null, "", `${location.pathname}${location.search}`); await boot(); } catch (error) { $("#login-error").textContent = error.message; } });
|
||||
$("#mfa-login-form").addEventListener("submit", async event => { event.preventDefault(); $("#mfa-login-error").textContent = ""; try { const response = await fetch("/api/login/mfa", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error || "That code didn't match. Try again."); event.target.reset(); history.replaceState(null, "", `${location.pathname}${location.search}`); await boot(); } catch (error) { $("#mfa-login-error").textContent = error.message; } });
|
||||
$("#mfa-login-cancel").addEventListener("click", () => { $("#mfa-login-form").reset(); $("#mfa-login-error").textContent = ""; $("#mfa-login-form").classList.add("hidden"); $("#login-form").classList.remove("hidden"); $("#login-form").elements.password.value = ""; setTimeout(() => $("#login-form").elements.password.focus(), 0); });
|
||||
$("#setup-form").addEventListener("submit", async event => { event.preventDefault(); $("#setup-error").textContent = ""; try { await api("/api/setup/admin", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); $("#setup-dialog").close(); event.target.reset(); await boot(); showLogin("Administrator account saved. Sign in with your finalized credentials."); } catch (error) { $("#setup-error").textContent = error.message; } });
|
||||
$("#setup-dialog").addEventListener("cancel", event => event.preventDefault());
|
||||
$("#logout").addEventListener("click", async () => { await fetch("/api/logout", { method: "POST" }); showLogin(); });
|
||||
|
||||
// --- Dashboard actions: run certificate check, download support report, jump to
|
||||
// an attention item's view -------------------------------------------------------------
|
||||
$("#check-health").addEventListener("click", async event => { const button = event.currentTarget; button.disabled = true; button.textContent = "Checking…"; try { const result = await api("/api/health/check", { method:"POST" }); state.dashboard = result.dashboard; state.certificates = result.certificates; state.readiness = { routes:result.readiness }; renderCertificates(); toast("Certificate and domain checks completed."); } catch (error) { toast(error.message); } finally { button.disabled = false; button.textContent = "Run certificate check"; } });
|
||||
$("#download-support").addEventListener("click", () => { location.href = "/api/support-report"; });
|
||||
$("#attention-list").addEventListener("click", event => { const target = event.target.closest("[data-issue-target]")?.dataset.issueTarget; if (target) { state.view = target; render(); loadFeatureView().catch(error => toast(error.message)); } });
|
||||
|
||||
// --- Primary navigation (sidebar view switching) -------------------------------------------
|
||||
function closeMenus() { document.querySelectorAll(".menu-open").forEach(card => { card.classList.remove("menu-open"); card.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); }); }
|
||||
document.querySelectorAll("nav, .aside-utilities").forEach(nav => nav.addEventListener("click", event => { const button = event.target.closest("[data-view]"); if (button) { closeMenus(); state.view = button.dataset.view; render(); loadFeatureView().catch(error => toast(error.message)); } }));
|
||||
document.querySelectorAll("nav, .aside-utilities, .brand").forEach(nav => nav.addEventListener("click", event => { const button = event.target.closest("[data-view]"); if (button) { closeMenus(); state.view = button.dataset.view; render(); loadFeatureView().catch(error => toast(error.message)); } }));
|
||||
$("#dashboard-view").addEventListener("click", event => { const target = event.target.closest("[data-target], [data-view]"); if (!target) return; state.view = target.dataset.target || target.dataset.view; render(); loadFeatureView().catch(error => toast(error.message)); });
|
||||
|
||||
// --- Logs & Performance filter controls -----------------------------------------------------
|
||||
$("#refresh-logs").addEventListener("click", () => loadFeatureView().catch(error => toast(error.message)));
|
||||
$("#log-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message)));
|
||||
$("#performance-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message)));
|
||||
$("#performance-range").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message)));
|
||||
$("#log-status").addEventListener("change", renderLogs);
|
||||
$("#event-severity").addEventListener("change", renderLogs);
|
||||
$("#event-category").addEventListener("change", renderLogs);
|
||||
|
||||
// --- "Create" dialog: opens the right create form/dialog for the current view --------------
|
||||
function openCreate() {
|
||||
if (state.view === "administration") { $("#user-form").reset(); $("#user-error").textContent = ""; return $("#user-dialog").showModal(); }
|
||||
if (state.view === "streaming") { $("#stream-form").reset(); delete $("#stream-form").dataset.editing; $("#stream-title").textContent = "Create a streaming host"; $("#stream-form .button.primary").textContent = "Create streaming host"; $("#stream-error").textContent = ""; return $("#stream-dialog").showModal(); }
|
||||
if (state.view === "redirects") { $("#redirect-form").reset(); delete $("#redirect-form").dataset.editing; $("#redirect-error").textContent = ""; return $("#redirect-dialog").showModal(); }
|
||||
if (state.view === "access") { $("#access-form").reset(); delete $("#access-form").dataset.editing; $("#access-error").textContent = ""; $("#access-form .access-create-guidance")?.remove(); const assignmentSummary = $("#access-assignment-summary"); assignmentSummary?.classList.add("hidden"); if (assignmentSummary) assignmentSummary.innerHTML = ""; window.renderCredentialEditor?.([]); return $("#access-dialog").showModal(); }
|
||||
if (state.view === "proxies") { $("#proxy-form").reset(); $("#custom-certificate-fields").classList.remove("custom-certificate-visible"); $("#proxy-error").textContent = ""; return $("#proxy-dialog").showModal(); }
|
||||
$("#create-form").reset(); $("#create-error").textContent = ""; const used = new Set(state.sites.map(site => site.port)); let port = state.config.minPort; while (used.has(port)) port++; $("#create-form [name=port]").value = port; $("#create-dialog").showModal();
|
||||
if (state.view === "proxies") { $("#proxy-form").reset(); $("#proxy-form").querySelectorAll("details").forEach(details => details.open = false); $("#custom-certificate-fields").classList.remove("custom-certificate-visible"); $("#proxy-error").textContent = ""; return $("#proxy-dialog").showModal(); }
|
||||
$("#create-form").reset(); $("#create-form").querySelectorAll("details").forEach(details => details.open = false); $("#create-error").textContent = ""; const used = new Set(state.sites.map(site => site.port)); let port = state.config.minPort; while (used.has(port)) port++; $("#create-form [name=port]").value = port; $("#create-dialog").showModal();
|
||||
}
|
||||
$("#open-create").addEventListener("click", openCreate);
|
||||
|
||||
// --- Global dialog/menu behavior (Escape to close menus, dialog close resets state) ---------
|
||||
document.addEventListener("click", event => { if (event.target.closest(".create-trigger")) openCreate(); if (event.target.closest(".close-dialog")) event.target.closest("dialog").close(); if (!event.target.closest(".menu-wrap")) closeMenus(); });
|
||||
document.addEventListener("keydown", event => { if (event.key === "Escape") closeMenus(); });
|
||||
document.querySelectorAll("dialog").forEach(dialog => dialog.addEventListener("close", () => { closeMenus(); dialog.querySelectorAll('input[type="password"]').forEach(input => input.value = ""); }));
|
||||
|
||||
// --- Hosted Sites & Proxy Hosts: create form submit handlers --------------------------------
|
||||
$("#refresh-health").addEventListener("click", () => refreshDashboard().catch(error => toast(error.message)));
|
||||
$("#create-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#create-error").textContent = ""; try { await api("/api/sites", { method: "POST", body: new FormData(event.target) }); $("#create-dialog").close(); await refresh(); toast("Hosted site created and gateway applied."); } catch (error) { $("#create-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
|
||||
$("#proxy-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#proxy-error").textContent = ""; const form = new FormData(event.target), certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"), wantsCustom = form.get("tls") === "custom"; if (wantsCustom && (!certificate?.size || !privateKey?.size)) { $("#proxy-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; button.disabled = false; button.textContent = "Create & publish"; return; } const body = advancedFormBody(form, Object.fromEntries(form)); delete body.certificateFile; delete body.privateKeyFile; if (wantsCustom) body.tls = "http"; try { const created = await api("/api/proxies", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (wantsCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${created.id}/certificate`, { method:"POST", body:files }); } $("#proxy-dialog").close(); await refresh(); toast(wantsCustom ? "Proxy host created with its custom certificate." : "Proxy host created. Certificate provisioning runs automatically."); } catch (error) { $("#proxy-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
|
||||
|
||||
|
||||
// --- Health-check field visibility polish for the create forms ------------------------------
|
||||
function ensureHostedHealthFields() { [document.querySelector("#create-form details"), document.querySelector("#settings-hosted-advanced")].forEach(details => { if (!details || details.querySelector("[name=healthEnabled]")) return; const access = details.querySelector("[name=accessListId]")?.closest("label"); if (!access) return; access.insertAdjacentHTML("afterend", '<label>Health-check path<input name="healthPath" value="/"></label><label>Health-check method<select name="healthMethod"><option value="GET">GET — retrieve a response</option><option value="HEAD">HEAD — headers only</option></select></label><label>Expected status<input name="healthExpected" value="200-499"><small>Examples: 200, 200,204, or 200-399.</small></label><label>Timeout in seconds<input name="healthTimeoutSeconds" type="number" min="1" max="60" value="4"></label><label>Retries<input name="healthRetries" type="number" min="0" max="3" value="0"></label><label class="check-control"><input name="healthEnabled" type="checkbox" checked><span>Monitor this site</span></label>'); }); }
|
||||
setInterval(ensureHostedHealthFields, 300);
|
||||
|
||||
// --- Hosted/Proxy edit ( "Domain & TLS" / "Edit proxy host" ) settings dialog ---------------
|
||||
function openSettings(kind, id) {
|
||||
if (kind === "hosted") kind = "site";
|
||||
const item = (kind === "proxy" ? state.proxies : state.sites).find(value => value.id === id); if (!item) return; state.editing = { kind, id }; const form = $("#settings-form"); form.reset();
|
||||
$("#settings-title").textContent = kind === "proxy" ? "Edit proxy host" : "Domain & TLS"; $("#settings-name-wrap").classList.toggle("hidden", kind !== "proxy"); $("#settings-target-wrap").classList.toggle("hidden", kind !== "proxy"); $("#settings-advanced").classList.toggle("hidden", kind !== "proxy"); $("#settings-hosted-advanced").classList.toggle("hidden", kind !== "site");
|
||||
const item = (kind === "proxy" ? state.proxies : state.sites).find(value => value.id === id); if (!item) return; state.editing = { kind, id }; const form = $("#settings-form"); form.reset(); form.querySelectorAll("details").forEach(details => details.open = false);
|
||||
$("#settings-title").textContent = kind === "proxy" ? "Edit proxy host" : "Domain & TLS"; $("#settings-name-wrap").classList.remove("hidden"); $("#settings-target-wrap").classList.toggle("hidden", kind !== "proxy"); $("#settings-advanced").classList.toggle("hidden", kind !== "proxy"); $("#settings-hosted-advanced").classList.toggle("hidden", kind !== "site");
|
||||
form.elements.name.value = item.name || ""; form.elements.domain.value = item.domain || ""; form.elements.target.value = item.target || ""; form.elements.tls.value = item.tls || "automatic"; form.elements.hsts.checked = Boolean(item.hsts); if (form.elements.settingsAccessListId) form.elements.settingsAccessListId.value = item.accessListId || "";
|
||||
if (kind === "proxy") {
|
||||
const scope = "#settings-advanced";
|
||||
setScoped(form, scope, "accessListId", item.accessListId || ""); setScoped(form, scope, "healthPath", item.healthPath || "/"); setScoped(form, scope, "healthMethod", item.healthMethod || "GET"); setScoped(form, scope, "healthExpected", item.healthExpected || "200-499"); setScoped(form, scope, "healthTimeoutSeconds", item.healthTimeoutSeconds || 4); setScoped(form, scope, "healthEnabled", item.healthEnabled !== false); setScoped(form, scope, "compression", item.compression || "automatic");
|
||||
setScoped(form, scope, "accessListId", item.accessListId || ""); setScoped(form, scope, "healthPath", item.healthPath || "/"); setScoped(form, scope, "healthMethod", item.healthMethod || "GET"); setScoped(form, scope, "healthExpected", item.healthExpected || "200-499"); setScoped(form, scope, "healthTimeoutSeconds", item.healthTimeoutSeconds || 4); setScoped(form, scope, "healthEnabled", item.healthEnabled !== false); setScoped(form, scope, "compression", item.compression || "automatic"); setScoped(form, scope, "blockCommonExploits", Boolean(item.blockCommonExploits));
|
||||
form.elements.customLocationsText.value = (item.locations || []).map(location => `${location.path} | ${location.target} | ${location.stripPrefix ? "strip" : "preserve"}`).join("\n");
|
||||
setScoped(form, scope, "requestHeadersText", (item.requestHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n")); setScoped(form, scope, "responseHeadersText", (item.responseHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n"));
|
||||
form.elements.upstreamTlsServerName.value = item.upstreamTlsServerName || ""; setScoped(form, scope, "upstreamTlsInsecure", Boolean(item.upstreamTlsInsecure)); setScoped(form, scope, "hstsSubdomains", Boolean(item.hstsSubdomains)); setScoped(form, scope, "customConfig", item.customConfig || "");
|
||||
form.elements.upstreamTlsServerName.value = item.upstreamTlsServerName || ""; setScoped(form, scope, "upstreamTlsInsecure", Boolean(item.upstreamTlsInsecure)); setScoped(form, scope, "hstsSubdomains", Boolean(item.hstsSubdomains)); setScoped(form, scope, "customConfig", item.customConfig || ""); form.elements.upstreamsText.value = (item.upstreams || []).join("\n"); setScoped(form, scope, "lbPolicy", item.lbPolicy || "random");
|
||||
}
|
||||
if (kind === "site") {
|
||||
const scope = "#settings-hosted-advanced";
|
||||
setScoped(form, scope, "healthPath", item.healthPath || "/"); setScoped(form, scope, "healthMethod", item.healthMethod || "GET"); setScoped(form, scope, "healthExpected", item.healthExpected || "200-499"); setScoped(form, scope, "healthTimeoutSeconds", item.healthTimeoutSeconds || 4); setScoped(form, scope, "healthRetries", item.healthRetries || 0); setScoped(form, scope, "healthEnabled", item.healthEnabled !== false); setScoped(form, scope, "accessListId", item.accessListId || ""); setScoped(form, scope, "compression", item.compression || "automatic"); setScoped(form, scope, "requestHeadersText", (item.requestHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n")); setScoped(form, scope, "responseHeadersText", (item.responseHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n")); setScoped(form, scope, "hstsSubdomains", Boolean(item.hstsSubdomains)); setScoped(form, scope, "customConfig", item.customConfig || "");
|
||||
}
|
||||
$("#settings-error").textContent = ""; if (kind === "proxy" && form.elements.domainsText) form.elements.domainsText.value = (item.domains || []).filter(domain => domain !== item.domain).join("\n"); $("#settings-dialog").showModal();
|
||||
$("#settings-error").textContent = ""; if (form.elements.domainsText) form.elements.domainsText.value = (item.domains || []).filter(domain => domain !== item.domain).join("\n"); $("#settings-dialog").showModal();
|
||||
document.querySelector("#settings-form .custom-certificate-fields")?.classList.toggle("custom-certificate-visible", kind === "proxy" && form.elements.tls.value === "custom");
|
||||
}
|
||||
$("#settings-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Applying…"; $("#settings-error").textContent = ""; const form = new FormData(event.target), certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"); let body = Object.fromEntries(form); delete body.certificateFile; delete body.privateKeyFile; body = state.editing.kind === "proxy" ? advancedFormBody(form, body) : { domain:body.domain, tls:body.tls, hsts:form.has("hsts") }; const uploadCustom = state.editing.kind === "proxy" && body.tls === "custom" && certificate?.size && privateKey?.size; if (state.editing.kind === "proxy" && body.tls === "custom" && !uploadCustom) { const existing = state.proxies.find(item => item.id === state.editing.id); if (!existing?.certificatePath) { $("#settings-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; button.disabled = false; button.textContent = "Save & apply"; return; } } try { const base = state.editing.kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (uploadCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${state.editing.id}/certificate`, { method:"POST", body:files }); } $("#settings-dialog").close(); await refresh(); toast("Gateway settings applied."); } catch (error) { $("#settings-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Save & apply"; } });
|
||||
|
||||
|
||||
// --- Hosted/Proxy card actions: toggle / edit / delete / replace files / change icon --------
|
||||
$("#site-grid").addEventListener("click", async event => {
|
||||
const card = event.target.closest(".site-card"); if (!card) return; const action = event.target.closest("[data-action]")?.dataset.action, kind = card.dataset.kind;
|
||||
if (event.target.closest(".menu-button")) { const opening = !card.classList.contains("menu-open"); closeMenus(); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button").setAttribute("aria-expanded", String(opening)); return; } if (!action) return;
|
||||
closeMenus();
|
||||
if (action === "toggle") { const base = kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${card.dataset.id}/toggle`, { method: "POST" }); await refresh(); toast("Status and gateway configuration updated."); }
|
||||
if (action === "toggle") { const toggleButton = event.target.closest(".toggle"), wasOn = toggleButton.classList.contains("on"); toggleButton.classList.toggle("on", !wasOn); toggleButton.disabled = true; const base = kind === "proxy" ? "proxies" : "sites"; try { await api(`/api/${base}/${card.dataset.id}/toggle`, { method: "POST" }); await refresh(); toast("Status and gateway configuration updated."); } catch (error) { toggleButton.classList.toggle("on", wasOn); toggleButton.disabled = false; toast(error.message || "Could not update status."); } }
|
||||
if (action === "settings") openSettings(kind, card.dataset.id);
|
||||
if (action === "delete") { state.pendingDelete = { kind, id: card.dataset.id }; $("#confirm-title").textContent = kind === "proxy" ? "Delete this proxy host?" : "Delete this hosted site?"; $("#confirm-copy").textContent = kind === "proxy" ? "Its domain route will be removed from the gateway." : "Its route and uploaded files will be permanently removed."; $("#confirm-dialog").showModal(); }
|
||||
if (action === "replace") { state.pendingReplace = card.dataset.id; $("#replace-files").click(); }
|
||||
if (action === "icon") openIconPicker(kind, card.dataset.id);
|
||||
});
|
||||
|
||||
// --- Redirect card actions delegated from the site grid (menu open/close, edit/
|
||||
// icon/delete/toggle) ---------------------------------------------------------------------
|
||||
document.querySelector("#redirect-list")?.addEventListener("click", event => {
|
||||
const card = event.target.closest(".redirect-card"); if (!card) return;
|
||||
if (event.target.closest(".menu-button")) { const opening = !card.classList.contains("menu-open"); closeMenus(); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button")?.setAttribute("aria-expanded", String(opening)); return; }
|
||||
const action = event.target.closest("[data-redirect-action]")?.dataset.redirectAction; if (action === "icon") { closeMenus(); openIconPicker("redirect", card.dataset.redirectId); }
|
||||
});
|
||||
|
||||
// --- Delete confirmation dialog and replace-files handler ------------------------------------
|
||||
$("#confirm-dialog").addEventListener("close", async () => { if ($("#confirm-dialog").returnValue === "confirm" && state.pendingDelete) { const base = state.pendingDelete.kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${state.pendingDelete.id}`, { method: "DELETE" }); await refresh(); toast("Entry deleted and gateway updated."); } state.pendingDelete = null; });
|
||||
$("#replace-files").addEventListener("change", async event => { if (!event.target.files[0] || !state.pendingReplace) return; const data = new FormData(); data.append("files", event.target.files[0]); try { await api(`/api/sites/${state.pendingReplace}/files`, { method: "POST", body: data }); toast("Site files updated."); } catch (error) { toast(error.message); } event.target.value = ""; state.pendingReplace = null; });
|
||||
|
||||
|
||||
// --- Icon picker dialog: search, upload, URL, and reset-to-fallback -------------------------
|
||||
function openIconPicker(kind, id) {
|
||||
state.iconTarget = { kind, id }; $("#icon-search").value = ""; $("#icon-url").value = ""; $("#icon-upload").value = ""; $("#icon-error").textContent = ""; $("#icon-results").innerHTML = '<p class="quiet-state">Enter at least two characters to search.</p>'; $("#icon-dialog").showModal(); setTimeout(() => $("#icon-search").focus(), 0);
|
||||
}
|
||||
@@ -431,6 +628,8 @@ $("#save-icon-url").addEventListener("click", async () => {
|
||||
try { await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: value }) }); $("#icon-dialog").close(); await refresh(); toast("Icon URL saved."); }
|
||||
catch (error) { $("#icon-error").textContent = error.message; }
|
||||
});
|
||||
|
||||
// --- User management: create, edit (role/status), password reset, delete --------------------
|
||||
$("#user-form").addEventListener("submit", async event => {
|
||||
event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; $("#user-error").textContent = "";
|
||||
try {
|
||||
@@ -439,14 +638,20 @@ $("#user-form").addEventListener("submit", async event => {
|
||||
} catch (error) { $("#user-error").textContent = error.message; }
|
||||
finally { button.disabled = false; }
|
||||
});
|
||||
|
||||
function themedUserConfirm(message, title = "Confirm action") { let dialog = document.querySelector("#user-confirm-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "user-confirm-dialog"; document.body.append(dialog); } dialog.innerHTML = `<form method="dialog" class="dialog-card compact"><div class="dialog-heading"><div><p class="eyebrow">Administration</p><h2>${escapeHtml(title)}</h2></div></div><p class="muted">${escapeHtml(message)}</p><div class="dialog-actions"><button value="cancel" class="button secondary">Cancel</button><button value="confirm" class="button danger">Confirm</button></div></form>`; dialog.showModal(); return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm"), { once: true })); }
|
||||
$("#user-list").addEventListener("click", async event => {
|
||||
const menuCard = event.target.closest(".user-card");
|
||||
if (menuCard && event.target.closest(".menu-button")) { const opening = !menuCard.classList.contains("menu-open"); closeMenus(); menuCard.classList.toggle("menu-open", opening); menuCard.querySelector(".menu-button")?.setAttribute("aria-expanded", String(opening)); return; }
|
||||
const button = event.target.closest("[data-user-action]"); if (!button) return;
|
||||
const card = button.closest("[data-user-id]"); const user = state.users.find(item => item.id === card?.dataset.userId); if (!user) return;
|
||||
if (button.dataset.userAction === "icon") { closeMenus(); openIconPicker("users", user.id); return; }
|
||||
if (button.dataset.userAction === "password") {
|
||||
closeMenus();
|
||||
state.passwordTarget = user.id; $("#password-form").reset(); $("#password-error").textContent = ""; $("#password-title").textContent = `Reset ${user.username} password`; $("#password-dialog").showModal(); return;
|
||||
}
|
||||
if (button.dataset.userAction === "delete") {
|
||||
closeMenus();
|
||||
if (!await themedUserConfirm(`Permanently delete user “${user.username}”? This cannot be undone.`, "Delete user")) return;
|
||||
button.disabled = true;
|
||||
try { await api(`/api/users/${user.id}`, { method: "DELETE" }); await loadFeatureView(); toast("User deleted."); } catch (error) { toast(error.message); } finally { button.disabled = false; }
|
||||
@@ -460,6 +665,7 @@ $("#user-list").addEventListener("click", async event => {
|
||||
} catch (error) { toast(error.message); }
|
||||
finally { button.disabled = false; }
|
||||
});
|
||||
|
||||
$("#password-form").addEventListener("submit", async event => {
|
||||
event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; $("#password-error").textContent = "";
|
||||
try {
|
||||
@@ -468,6 +674,8 @@ $("#password-form").addEventListener("submit", async event => {
|
||||
} catch (error) { $("#password-error").textContent = error.message; }
|
||||
finally { button.disabled = false; }
|
||||
});
|
||||
|
||||
// --- Hash-based routing: back/forward and deep links (#view or #administration/tab) ---------
|
||||
window.addEventListener("hashchange", () => {
|
||||
if (!state.user) return; // Not logged in yet; boot() handles initial routing.
|
||||
const requestedHash = location.hash.slice(1);
|
||||
@@ -477,8 +685,11 @@ window.addEventListener("hashchange", () => {
|
||||
render();
|
||||
loadFeatureView().catch(error => toast(error.message));
|
||||
});
|
||||
|
||||
boot().catch(error => toast(error.message));
|
||||
|
||||
|
||||
// --- Proxy form: keep the upstream-TLS fields in sync with the target URL scheme ------------
|
||||
function syncUpstreamTlsControls(form) {
|
||||
if (!form || !form.elements.target) return;
|
||||
const targets = [form.elements.target.value, form.elements.upstreamsText?.value || ""].join("\n").split(/\n+/).map(value => value.trim()).filter(Boolean);
|
||||
@@ -488,12 +699,107 @@ function syncUpstreamTlsControls(form) {
|
||||
if (tlsSkip && !https) tlsSkip.checked = false;
|
||||
const help = tlsSkip?.closest("label")?.querySelector("small");
|
||||
if (help) help.textContent = https ? "Use only for a trusted internal HTTPS service with a self-signed or hostname-mismatched certificate." : "Available only when the upstream uses HTTPS.";
|
||||
|
||||
const lbPolicy = form.elements.lbPolicy;
|
||||
if (lbPolicy) { const poolTargets = String(form.elements.upstreamsText?.value || "").split("\n").map(value => value.trim()).filter(Boolean); const multi = poolTargets.length > 1; lbPolicy.disabled = !multi; lbPolicy.closest("label")?.classList.toggle("control-disabled", !multi); if (!multi) lbPolicy.value = "random"; }
|
||||
}
|
||||
|
||||
document.addEventListener("input", event => { if (event.target.matches('#proxy-form [name="target"],#proxy-form [name="upstreamsText"],#settings-form [name="target"],#settings-form [name="upstreamsText"]')) syncUpstreamTlsControls(event.target.form); });
|
||||
document.addEventListener("change", event => { if (event.target.matches('#proxy-form [name="target"],#proxy-form [name="upstreamsText"],#settings-form [name="target"],#settings-form [name="upstreamsText"]')) syncUpstreamTlsControls(event.target.form); });
|
||||
document.querySelectorAll("#proxy-form,#settings-form").forEach(form => syncUpstreamTlsControls(form));
|
||||
|
||||
// --- Misc global click delegation (create triggers, generic [data-action] handlers) ---------
|
||||
document.addEventListener("click", event => { if (event.target.closest(".create-trigger,[data-action=edit],[data-card-action=edit]")) setTimeout(() => { syncUpstreamTlsControls(document.querySelector("#proxy-form")); syncUpstreamTlsControls(document.querySelector("#settings-form")); }, 0); });
|
||||
document.addEventListener("click", event => { const trigger = event.target.closest("[data-action=settings],[data-card-action=settings]"); if (!trigger) return; setTimeout(() => { const item = (state.editing?.kind === "proxy" ? state.proxies : state.sites).find(value => value.id === state.editing?.id); if (!item) return; const scope = state.editing.kind === "proxy" ? "#settings-advanced" : "#settings-hosted-advanced"; const checkbox = document.querySelector(`${scope} [name="healthEnabled"]`); if (checkbox) checkbox.checked = !(item.healthEnabled === false || String(item.healthEnabled).toLowerCase() === "false"); }, 0); });
|
||||
|
||||
// --- Access Lists: periodic live refresh while that view is open ----------------------------
|
||||
setInterval(() => { if (state.view !== 'access') return; const items = state.accessLists || []; const enabled = items.filter(item => item.enabled !== false).length; const disabled = items.length - enabled; $('#running-count').textContent = enabled; $('#disabled-count').textContent = disabled; $('#error-count').textContent = 0; $('#running-label').textContent = enabled ? 'Enabled' : 'None enabled'; $('#disabled-label').textContent = disabled ? 'Disabled' : 'None disabled'; $('#error-label').textContent = 'No issues'; $('#running-dot').className = `status-dot ${enabled ? 'running' : 'inactive'}`; $('#disabled-dot').className = `status-dot ${disabled ? 'disabled' : 'inactive'}`; $('#error-dot').className = 'status-dot inactive'; $('.port-note').classList.add('hidden'); }, 500);
|
||||
function renderDashboardJobsSafe(system) { const columns = document.querySelector("#dashboard-view .dashboard-columns"); if (!columns) return; const health = document.querySelector("[data-dashboard-health]") || columns.querySelector(".health-list")?.closest("section"); if (health) { health.dataset.dashboardHealth = "true"; if (health.parentElement === columns) columns.parentElement.insertBefore(health, columns); } let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; columns.insertBefore(panel, columns.children[1] || null); } panel.innerHTML = `<div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div class="dashboard-jobs-list">${(system.jobs || []).map(job => `<div class="dashboard-list-item"><span class="status-dot ${job.enabled ? "running" : "idle"}"></span><span><strong>${escapeHtml(job.name)}</strong><small>${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}</small></span></div>`).join("")}</div>`; }
|
||||
document.addEventListener("submit", async event => { if (event.target?.id !== "settings-form" || state.editing?.kind !== "site") return; event.preventDefault(); event.stopImmediatePropagation(); const button = resolveSubmitter(event); button.disabled = true; const form = new FormData(event.target); try { await api(`/api/sites/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ domain: form.get("domain"), domains: String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean), tls: form.get("tls"), hsts: form.has("hsts"), accessListId: form.get("accessListId") || "", healthEnabled: form.has("healthEnabled"), healthPath: form.get("healthPath") || "/", healthMethod: form.get("healthMethod") || "GET", healthExpected: form.get("healthExpected") || "200-499", healthTimeoutSeconds: Number(form.get("healthTimeoutSeconds") || 4), healthRetries: Number(form.get("healthRetries") || 0), compression: form.get("compression") || "automatic", requestHeaders: parseHeaderLines(form.get("requestHeadersText")), responseHeaders: parseHeaderLines(form.get("responseHeadersText")), hstsSubdomains: form.has("hstsSubdomains"), customConfig: form.get("customConfig") || "" }) }); document.querySelector("#settings-dialog").close(); await refresh(); toast("Gateway settings applied."); } catch (error) { document.querySelector("#settings-error").textContent = error.message; } finally { button.disabled = false; } }, true);
|
||||
|
||||
function renderDashboardJobsSafe(system) { const slot = document.querySelector("#dashboard-jobs-slot"); if (!slot) return; let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; slot.appendChild(panel); } panel.innerHTML = `<div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div class="health-grid">${(system.jobs || []).map(job => `<div class="health-tile"><span class="status-dot ${job.enabled ? "running" : "idle"}"></span><span class="health-tile-copy"><strong>${escapeHtml(job.name)}</strong><small>${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}</small></span></div>`).join("")}</div>`; }
|
||||
|
||||
|
||||
// --- Account: change password form ------------------------------------------------------------
|
||||
$("#account-password-form").addEventListener("submit", async event => {
|
||||
event.preventDefault();
|
||||
$("#account-password-error").textContent = "";
|
||||
const form = event.target;
|
||||
const body = Object.fromEntries(new FormData(form));
|
||||
if (String(body.newPassword) !== String(body.confirmPassword)) { $("#account-password-error").textContent = "The new passwords do not match."; return; }
|
||||
try {
|
||||
await api("/api/account/password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentPassword: body.currentPassword, newPassword: body.newPassword }) });
|
||||
form.reset(); toast("Password changed.");
|
||||
} catch (error) { $("#account-password-error").textContent = error.message; }
|
||||
});
|
||||
|
||||
|
||||
// --- MFA: password re-confirmation dialog used before disabling MFA or
|
||||
// regenerating recovery codes --------------------------------------------------------------
|
||||
let mfaPasswordResolve = null;
|
||||
function requestMfaPassword(title, heading) {
|
||||
$("#mfa-password-title").textContent = title;
|
||||
$("#mfa-password-heading").textContent = heading;
|
||||
$("#mfa-password-error").textContent = "";
|
||||
$("#mfa-password-form").reset();
|
||||
$("#mfa-password-dialog").showModal();
|
||||
return new Promise(resolve => { mfaPasswordResolve = resolve; });
|
||||
}
|
||||
$("#mfa-password-form").addEventListener("submit", event => {
|
||||
event.preventDefault();
|
||||
const password = new FormData(event.target).get("password");
|
||||
$("#mfa-password-dialog").close();
|
||||
mfaPasswordResolve?.(password);
|
||||
mfaPasswordResolve = null;
|
||||
});
|
||||
$("#mfa-password-cancel").addEventListener("click", () => { $("#mfa-password-dialog").close(); mfaPasswordResolve?.(null); mfaPasswordResolve = null; });
|
||||
|
||||
|
||||
// --- MFA: enable / setup / confirm flow --------------------------------------------------------
|
||||
$("#account-mfa-enable").addEventListener("click", async () => {
|
||||
try {
|
||||
const result = await api("/api/account/mfa/setup", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
|
||||
$("#mfa-setup-qr").innerHTML = result.qrSvg;
|
||||
$("#mfa-setup-secret").textContent = result.secret;
|
||||
$("#mfa-setup-error").textContent = "";
|
||||
$("#mfa-setup-confirm-form").reset();
|
||||
$("#mfa-setup-dialog").showModal();
|
||||
} catch (error) { toast(error.message); }
|
||||
});
|
||||
$("#mfa-setup-cancel").addEventListener("click", () => { $("#mfa-setup-dialog").close(); });
|
||||
$("#mfa-setup-confirm-form").addEventListener("submit", async event => {
|
||||
event.preventDefault();
|
||||
$("#mfa-setup-error").textContent = "";
|
||||
try {
|
||||
const code = new FormData(event.target).get("code");
|
||||
const result = await api("/api/account/mfa/confirm", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code }) });
|
||||
$("#mfa-setup-dialog").close();
|
||||
state.user.mfaEnabled = true;
|
||||
renderAccount();
|
||||
$("#mfa-recovery-codes").textContent = result.recoveryCodes.join("\n");
|
||||
$("#mfa-recovery-dialog").showModal();
|
||||
toast("Two-factor authentication enabled.");
|
||||
} catch (error) { $("#mfa-setup-error").textContent = error.message; }
|
||||
});
|
||||
$("#mfa-recovery-done").addEventListener("click", () => { $("#mfa-recovery-dialog").close(); });
|
||||
|
||||
|
||||
// --- MFA: disable and regenerate recovery codes -------------------------------------------------
|
||||
$("#account-mfa-disable").addEventListener("click", async () => {
|
||||
const password = await requestMfaPassword("Disable two-factor authentication", "Confirm your password to continue");
|
||||
if (!password) return;
|
||||
try {
|
||||
await api("/api/account/mfa/disable", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }) });
|
||||
state.user.mfaEnabled = false;
|
||||
renderAccount();
|
||||
toast("Two-factor authentication disabled.");
|
||||
} catch (error) { toast(error.message); }
|
||||
});
|
||||
$("#account-mfa-recovery").addEventListener("click", async () => {
|
||||
const password = await requestMfaPassword("Regenerate recovery codes", "Confirm your password to continue");
|
||||
if (!password) return;
|
||||
try {
|
||||
const result = await api("/api/account/mfa/recovery-codes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }) });
|
||||
$("#mfa-recovery-codes").textContent = result.recoveryCodes.join("\n");
|
||||
$("#mfa-recovery-dialog").showModal();
|
||||
toast("Recovery codes regenerated. Your old codes no longer work.");
|
||||
} catch (error) { toast(error.message); }
|
||||
});
|
||||
|
||||
+139
-12
File diff suppressed because one or more lines are too long
+228
-71
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 387 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
+51
-29
File diff suppressed because one or more lines are too long
+288
-18
File diff suppressed because one or more lines are too long
+41
-4
@@ -6,9 +6,9 @@ import { DatabaseSync } from "node:sqlite";
|
||||
import AdmZip from "adm-zip";
|
||||
|
||||
export const LOCAL_INSTANCE_ID = "local";
|
||||
export const ENTITY_KINDS = ["sites", "proxies", "redirects", "access_lists", "users", "groups"];
|
||||
const legacyFiles = { sites: "sites.json", proxies: "proxies.json", redirects: "redirects.json", access_lists: "access-lists.json", users: "users.json", groups: "groups.json" };
|
||||
const entityTables = { sites: "hosted_sites", proxies: "proxy_hosts", redirects: "redirect_hosts", access_lists: "access_lists", users: "users", groups: "groups" };
|
||||
export const ENTITY_KINDS = ["sites", "proxies", "redirects", "streams", "access_lists", "users", "groups"];
|
||||
const legacyFiles = { sites: "sites.json", proxies: "proxies.json", redirects: "redirects.json", streams: "streams.json", access_lists: "access-lists.json", users: "users.json", groups: "groups.json" };
|
||||
const entityTables = { sites: "hosted_sites", proxies: "proxy_hosts", redirects: "redirect_hosts", streams: "stream_hosts", access_lists: "access_lists", users: "users", groups: "groups" };
|
||||
|
||||
function now() { return new Date().toISOString(); }
|
||||
|
||||
@@ -50,12 +50,14 @@ export async function openStorage(dataDir, backupsDir) {
|
||||
CREATE TABLE IF NOT EXISTS hosted_sites (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS proxy_hosts (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS redirect_hosts (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS stream_hosts (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS access_lists (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS groups (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS hosted_sites_instance ON hosted_sites(instance_id);
|
||||
CREATE INDEX IF NOT EXISTS proxy_hosts_instance ON proxy_hosts(instance_id);
|
||||
CREATE INDEX IF NOT EXISTS redirect_hosts_instance ON redirect_hosts(instance_id);
|
||||
CREATE INDEX IF NOT EXISTS stream_hosts_instance ON stream_hosts(instance_id);
|
||||
CREATE INDEX IF NOT EXISTS access_lists_instance ON access_lists(instance_id);
|
||||
CREATE INDEX IF NOT EXISTS users_instance ON users(instance_id);
|
||||
CREATE INDEX IF NOT EXISTS groups_instance ON groups(instance_id);
|
||||
@@ -105,6 +107,41 @@ export async function openStorage(dataDir, backupsDir) {
|
||||
function listActivity(limit = 100, instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT message,status,category,created_at AS at FROM activity_events WHERE instance_id=? ORDER BY id DESC LIMIT ?").all(instanceId, Math.max(1, Math.min(Number(limit) || 100, 500))); }
|
||||
function recordAccessEvents(events, instanceId = LOCAL_INSTANCE_ID) { const insert = db.prepare("INSERT OR IGNORE INTO access_events(instance_id,at,host,method,uri,status,size,duration_ms,remote_ip,source) VALUES(?,?,?,?,?,?,?,?,?,?)"); transaction(() => { for (const event of events) insert.run(instanceId, event.at || null, event.host || null, event.method || null, event.uri || null, event.status ?? null, event.size ?? null, event.durationMs ?? null, event.remoteIp || null, event.source); }); }
|
||||
function listAccessEvents(limit = 100, host = "", instanceId = LOCAL_INSTANCE_ID) { const rows = db.prepare("SELECT at,host,method,uri,status,size,duration_ms AS durationMs,remote_ip AS remoteIp FROM access_events WHERE instance_id=? AND (?='' OR host=?) ORDER BY id DESC LIMIT ?").all(instanceId, host, host, Math.max(1, Math.min(Number(limit) || 100, 500))); return rows; }
|
||||
function performanceLiveCount(windowSeconds = 60, instanceId = LOCAL_INSTANCE_ID) { const cutoff = new Date(Date.now() - Math.max(5, Number(windowSeconds) || 60) * 1000).toISOString(); return db.prepare("SELECT COUNT(*) AS count FROM access_events WHERE instance_id=? AND at>=?").get(instanceId, cutoff).count; }
|
||||
function performanceRoutes(instanceId = LOCAL_INSTANCE_ID) {
|
||||
const hourCutoff = new Date(Date.now() - 3600000).toISOString(), dayCutoff = new Date(Date.now() - 86400000).toISOString();
|
||||
return db.prepare(`
|
||||
SELECT host,
|
||||
SUM(CASE WHEN at>=? THEN 1 ELSE 0 END) AS hourRequests,
|
||||
SUM(CASE WHEN at>=? AND status>=400 THEN 1 ELSE 0 END) AS hourErrors,
|
||||
AVG(CASE WHEN at>=? THEN duration_ms END) AS hourAvgMs,
|
||||
COUNT(*) AS dayRequests,
|
||||
SUM(CASE WHEN status>=400 THEN 1 ELSE 0 END) AS dayErrors,
|
||||
AVG(duration_ms) AS dayAvgMs
|
||||
FROM access_events WHERE instance_id=? AND at>=? AND host IS NOT NULL AND host!=''
|
||||
GROUP BY host ORDER BY dayRequests DESC
|
||||
`).all(hourCutoff, hourCutoff, hourCutoff, instanceId, dayCutoff);
|
||||
}
|
||||
function performanceErrorBreakdown(instanceId = LOCAL_INSTANCE_ID) {
|
||||
const dayCutoff = new Date(Date.now() - 86400000).toISOString();
|
||||
return db.prepare(`
|
||||
SELECT host, status, COUNT(*) AS count
|
||||
FROM access_events WHERE instance_id=? AND at>=? AND status>=400 AND host IS NOT NULL AND host!=''
|
||||
GROUP BY host, status ORDER BY count DESC
|
||||
`).all(instanceId, dayCutoff);
|
||||
}
|
||||
function performanceTrend(host = "", hours = 6, bucketMinutes = 15, instanceId = LOCAL_INSTANCE_ID) {
|
||||
const bucketMs = Math.max(1, Number(bucketMinutes) || 15) * 60000;
|
||||
const windowMs = Math.max(1, Number(hours) || 6) * 3600000;
|
||||
const cutoff = new Date(Date.now() - windowMs).toISOString();
|
||||
const rows = db.prepare(`SELECT at FROM access_events WHERE instance_id=? AND at>=? AND (?='' OR host=?)`).all(instanceId, cutoff, host, host);
|
||||
const buckets = new Map();
|
||||
for (const row of rows) { const t = new Date(row.at).getTime(); if (Number.isNaN(t)) continue; const bucketStart = Math.floor(t / bucketMs) * bucketMs; buckets.set(bucketStart, (buckets.get(bucketStart) || 0) + 1); }
|
||||
const startBucket = Math.floor((Date.now() - windowMs) / bucketMs) * bucketMs, endBucket = Math.floor(Date.now() / bucketMs) * bucketMs;
|
||||
const points = [];
|
||||
for (let bucket = startBucket; bucket <= endBucket; bucket += bucketMs) points.push({ at: new Date(bucket).toISOString(), count: buckets.get(bucket) || 0 });
|
||||
return points;
|
||||
}
|
||||
function pruneEvents(policy = {}, instanceId = LOCAL_INSTANCE_ID) { const cutoff = days => new Date(Date.now() - Math.max(7, Number(days) || 30) * 86400000).toISOString(); return transaction(() => { const counts = {}; const jobs = [["access", "access_events", "at", policy.accessDays, ""], ["activity", "activity_events", "created_at", policy.activityDays, "category='activity'"], ["certificate", "activity_events", "created_at", policy.certificateDays, "category='certificate'"], ["security", "activity_events", "created_at", policy.securityDays, "category='security'"], ["audit", "audit_events", "created_at", policy.auditDays, ""]]; for (const [name, table, column, days, filter] of jobs) { const result = db.prepare(`DELETE FROM ${table} WHERE instance_id=? AND ${column} < ?${filter ? ` AND ${filter}` : ""}`).run(instanceId, cutoff(days)); counts[name] = Number(result.changes || 0); } return counts; }); }
|
||||
function previewPruneEvents(policy = {}, instanceId = LOCAL_INSTANCE_ID) { const cutoff = days => new Date(Date.now() - Math.max(7, Number(days) || 30) * 86400000).toISOString(); const counts = {}; const jobs = [["access", "access_events", "at", policy.accessDays, ""], ["activity", "activity_events", "created_at", policy.activityDays, "category='activity'"], ["certificate", "activity_events", "created_at", policy.certificateDays, "category='certificate'"], ["security", "activity_events", "created_at", policy.securityDays, "category='security'"], ["audit", "audit_events", "created_at", policy.auditDays, ""]]; for (const [name, table, column, days, filter] of jobs) counts[name] = Number(db.prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE instance_id=? AND ${column} < ?${filter ? ` AND ${filter}` : ""}`).get(instanceId, cutoff(days)).count || 0); return counts; }
|
||||
function listAudit(filters = {}, instanceId = LOCAL_INSTANCE_ID) { const rows = db.prepare("SELECT id,actor_id,action,status,details,created_at FROM audit_events WHERE instance_id=? ORDER BY id DESC LIMIT 500").all(instanceId); return rows.filter(row => (!filters.user || row.actor_id === filters.user) && (!filters.action || row.action.toLowerCase().includes(filters.action.toLowerCase())) && (!filters.status || row.status === filters.status)).map(row => ({ ...row, details: row.details ? JSON.parse(row.details) : null })); }
|
||||
@@ -133,5 +170,5 @@ export async function openStorage(dataDir, backupsDir) {
|
||||
}
|
||||
function humanizeGatewayErrors(instanceId = LOCAL_INSTANCE_ID) { const friendly = "Gateway configuration rejected: HTTP upstream cannot use HTTPS transport. Disable upstream TLS verification or change the upstream URL to HTTPS."; const activity = db.prepare("SELECT id FROM activity_events WHERE instance_id=? AND message LIKE '%upstream address scheme is HTTP but transport is configured for HTTP+TLS%'").all(instanceId); const updateActivity = db.prepare("UPDATE activity_events SET message=? WHERE id=?"); for (const row of activity) updateActivity.run(friendly, row.id); const audit = db.prepare("SELECT id FROM audit_events WHERE instance_id=? AND action LIKE '%upstream address scheme is HTTP but transport is configured for HTTP+TLS%'").all(instanceId); const updateAudit = db.prepare("UPDATE audit_events SET action=? WHERE id=?"); for (const row of audit) updateAudit.run(friendly, row.id); return activity.length + audit.length; }
|
||||
const result = integrity(); if (result.length !== 1 || result[0] !== "ok") { db.close(); throw new Error(`SQLite integrity check failed: ${result.join(", ")}`); }
|
||||
return { db, databasePath, isNew, snapshot, loadCollection, saveCollection, loadSettings, saveSettings, integrity, recordAudit, listAudit, recordActivity, listActivity, humanizeGatewayErrors, recordAccessEvents, listAccessEvents, pruneEvents, previewPruneEvents, backupTo, close: () => db.close() };
|
||||
return { db, databasePath, isNew, snapshot, loadCollection, saveCollection, loadSettings, saveSettings, integrity, recordAudit, listAudit, recordActivity, listActivity, humanizeGatewayErrors, recordAccessEvents, listAccessEvents, pruneEvents, previewPruneEvents, backupTo, performanceLiveCount, performanceRoutes, performanceErrorBreakdown, performanceTrend, close: () => db.close() };
|
||||
}
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
// Minimal RFC 4648 base32 (no padding), and RFC 6238 TOTP on top of RFC 4226 HOTP.
|
||||
// Implemented against Node's built-in crypto only — no third-party dependency.
|
||||
|
||||
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
export function base32Encode(buffer) {
|
||||
let bits = "";
|
||||
for (const byte of buffer) bits += byte.toString(2).padStart(8, "0");
|
||||
let output = "";
|
||||
for (let i = 0; i + 5 <= bits.length; i += 5) output += BASE32_ALPHABET[parseInt(bits.slice(i, i + 5), 2)];
|
||||
const remainder = bits.length % 5;
|
||||
if (remainder) output += BASE32_ALPHABET[parseInt(bits.slice(bits.length - remainder).padEnd(5, "0"), 2)];
|
||||
return output;
|
||||
}
|
||||
|
||||
export function base32Decode(value) {
|
||||
const cleaned = String(value || "").toUpperCase().replace(/[^A-Z2-7]/g, "");
|
||||
let bits = "";
|
||||
for (const char of cleaned) {
|
||||
const index = BASE32_ALPHABET.indexOf(char);
|
||||
if (index < 0) continue;
|
||||
bits += index.toString(2).padStart(5, "0");
|
||||
}
|
||||
const bytes = [];
|
||||
for (let i = 0; i + 8 <= bits.length; i += 8) bytes.push(parseInt(bits.slice(i, i + 8), 2));
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
export function generateTotpSecret() {
|
||||
return base32Encode(crypto.randomBytes(20)); // 160-bit key, standard for authenticator apps
|
||||
}
|
||||
|
||||
function hotp(secretBuffer, counter, digits = 6) {
|
||||
const counterBuffer = Buffer.alloc(8);
|
||||
counterBuffer.writeBigUInt64BE(BigInt(counter));
|
||||
const hmac = crypto.createHmac("sha1", secretBuffer).update(counterBuffer).digest();
|
||||
const offset = hmac[hmac.length - 1] & 0x0f;
|
||||
const binary = ((hmac[offset] & 0x7f) << 24) | ((hmac[offset + 1] & 0xff) << 16) | ((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff);
|
||||
return String(binary % 10 ** digits).padStart(digits, "0");
|
||||
}
|
||||
|
||||
export function totpAt(base32Secret, forTime = Date.now(), step = 30, digits = 6) {
|
||||
const counter = Math.floor(forTime / 1000 / step);
|
||||
return hotp(base32Decode(base32Secret), counter, digits);
|
||||
}
|
||||
|
||||
// Accepts a code from the current step or one step on either side, to tolerate normal clock drift.
|
||||
export function verifyTotp(base32Secret, code, { step = 30, digits = 6, window = 1, forTime = Date.now() } = {}) {
|
||||
const candidate = String(code || "").trim().replace(/\s+/g, "");
|
||||
if (!/^\d{6,8}$/.test(candidate)) return false;
|
||||
const secretBuffer = base32Decode(base32Secret);
|
||||
const baseCounter = Math.floor(forTime / 1000 / step);
|
||||
for (let offset = -window; offset <= window; offset++) {
|
||||
const expected = hotp(secretBuffer, baseCounter + offset, digits);
|
||||
if (crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(candidate.padStart(digits, "0")))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function otpauthUri({ secret, username, issuer = "Site Gateway" }) {
|
||||
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(username)}`;
|
||||
return `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
|
||||
}
|
||||
|
||||
export function generateRecoveryCodes(count = 10) {
|
||||
const codes = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const raw = crypto.randomBytes(5).toString("hex").toUpperCase(); // 10 hex chars
|
||||
codes.push(`${raw.slice(0, 5)}-${raw.slice(5, 10)}`);
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
Reference in New Issue
Block a user