Import Site Gateway app and clean up for standalone release
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.git
|
||||
.DS_Store
|
||||
data
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=replace-with-a-long-unique-password
|
||||
SESSION_SECRET=replace-with-at-least-32-random-characters
|
||||
SITE_GATEWAY_DATA=/DATA/AppData/site-gateway
|
||||
PUID=1000
|
||||
PGID=1000
|
||||
ACME_EMAIL=you@example.com
|
||||
HTTP_PORT=80
|
||||
HTTPS_PORT=443
|
||||
BACKUP_PASSWORD=
|
||||
@@ -0,0 +1,72 @@
|
||||
name: Build and publish container
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Sign in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Generate image tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=alpha,enable=${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref, '-alpha.') }}
|
||||
type=sha
|
||||
|
||||
- name: Build local smoke-test image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
load: true
|
||||
tags: site-gateway:smoke-test
|
||||
cache-from: type=gha
|
||||
|
||||
- name: Verify Node and built-in SQLite
|
||||
run: docker run --rm --entrypoint node site-gateway:smoke-test --input-type=module -e "import { DatabaseSync } from 'node:sqlite'; const db = new DatabaseSync(':memory:'); db.exec('CREATE TABLE smoke (id INTEGER)'); db.close();"
|
||||
|
||||
- name: Scan image for vulnerabilities
|
||||
uses: aquasecurity/trivy-action@0.28.0
|
||||
with:
|
||||
image-ref: site-gateway:smoke-test
|
||||
severity: CRITICAL,HIGH
|
||||
exit-code: "0"
|
||||
format: table
|
||||
|
||||
- name: Build and publish
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
data/
|
||||
.env
|
||||
.DS_Store
|
||||
*.log
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
FROM caddy:2.11.4-alpine AS caddy
|
||||
|
||||
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
|
||||
COPY src ./src
|
||||
COPY --from=caddy /usr/bin/caddy /usr/bin/caddy
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && setcap cap_net_bind_service=+ep /usr/bin/caddy && mkdir -p /data
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
ADMIN_PORT=8080 \
|
||||
DATA_DIR=/data \
|
||||
PUID=1000 \
|
||||
PGID=1000
|
||||
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 80 443 8080 9000-9099
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:'+(process.env.ADMIN_PORT||8080)+'/api/session').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
ENTRYPOINT ["tini", "--", "docker-entrypoint.sh"]
|
||||
CMD ["node", "src/server.js"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Marvin Wade
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,296 @@
|
||||
<div align="center">
|
||||
<img src="src/public/icon.png" alt="Site Gateway icon" width="180">
|
||||
<h1>Site Gateway</h1>
|
||||
<p><strong>Host. Proxy. Secure.</strong></p>
|
||||
<p>A friendly, self-hosted gateway for websites, applications, domains, and automatic HTTPS.</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">
|
||||
</p>
|
||||
<p>
|
||||
<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="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.
|
||||
|
||||
| Publish | Route | Protect | Operate |
|
||||
| --- | --- | --- | --- |
|
||||
| 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 |
|
||||
|
||||
> [!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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Quick start
|
||||
|
||||
Requirements: Docker Engine with Docker Compose.
|
||||
|
||||
1. Edit `compose.yaml` and replace `change-this-password` with a strong password.
|
||||
2. From this folder, run:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## 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 **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.
|
||||
|
||||
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.
|
||||
|
||||
## Install from the published image
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
docker compose -f compose.release.yaml pull
|
||||
docker compose -f compose.release.yaml up -d
|
||||
```
|
||||
|
||||
To upgrade later:
|
||||
|
||||
```bash
|
||||
docker compose -f compose.release.yaml pull
|
||||
docker compose -f compose.release.yaml up -d
|
||||
```
|
||||
|
||||
This recreates only the application container. Uploaded sites remain in the persistent data mount.
|
||||
|
||||
## ZIP layout
|
||||
|
||||
Preferred:
|
||||
|
||||
```text
|
||||
my-site.zip
|
||||
├── index.html
|
||||
├── styles.css
|
||||
├── app.js
|
||||
└── images/
|
||||
└── logo.png
|
||||
```
|
||||
|
||||
A ZIP containing one top-level folder is also accepted; Site Gateway unwraps that folder automatically.
|
||||
|
||||
## Unraid alpha install
|
||||
|
||||
### 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:
|
||||
|
||||
```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`.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
To rebuild after pulling a new version:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
|
||||
## Alpha 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.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
# Site Gateway product roadmap
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Recommended gateway
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Proposed creation wizard
|
||||
|
||||
### Step 1: What are you publishing?
|
||||
|
||||
- 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
|
||||
|
||||
### Step 2: Address
|
||||
|
||||
- Domain name(s)
|
||||
- Optional path such as `/photos`
|
||||
- Internal target and port for proxied applications
|
||||
- Validation that ports and domains are not duplicated
|
||||
|
||||
### Step 3: Security
|
||||
|
||||
- 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
|
||||
|
||||
### Step 4: Access
|
||||
|
||||
- Public
|
||||
- Basic username/password
|
||||
- IP allow/deny list
|
||||
- Optional security headers preset
|
||||
|
||||
### Step 5: Review and publish
|
||||
|
||||
- Plain-language configuration summary
|
||||
- DNS and router checks
|
||||
- Configuration validation before activation
|
||||
- Immediate rollback if gateway reload fails
|
||||
|
||||
## Delivery phases
|
||||
|
||||
### 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
|
||||
|
||||
## 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.
|
||||
- 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 |
|
||||
@@ -0,0 +1,27 @@
|
||||
services:
|
||||
site-gateway:
|
||||
image: ${SITE_GATEWAY_IMAGE:-ghcr.io/mfwadejr/site-gateway2:latest}
|
||||
container_name: site-gateway
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env}
|
||||
SESSION_SECRET: ${SESSION_SECRET:?Set SESSION_SECRET in .env}
|
||||
ADMIN_PORT: 8080
|
||||
SITE_PORT_MIN: 9000
|
||||
SITE_PORT_MAX: 9099
|
||||
DATA_DIR: /data
|
||||
BACKUP_PASSWORD: ${BACKUP_PASSWORD:-}
|
||||
PUID: ${PUID:-1000}
|
||||
PGID: ${PGID:-1000}
|
||||
ACME_EMAIL: ${ACME_EMAIL:-}
|
||||
ports:
|
||||
- "${HTTP_PORT:-80}:80"
|
||||
- "${HTTPS_PORT:-443}:443"
|
||||
- "${HTTPS_PORT:-443}:443/udp"
|
||||
- "8080:8080"
|
||||
- "9000-9099:9000-9099"
|
||||
volumes:
|
||||
- ${SITE_GATEWAY_DATA:-/DATA/AppData/site-gateway}:/data
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "true"
|
||||
@@ -0,0 +1,27 @@
|
||||
services:
|
||||
site-gateway:
|
||||
build: .
|
||||
container_name: site-gateway
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
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.
|
||||
# SESSION_SECRET: ""
|
||||
ADMIN_PORT: 8080
|
||||
SITE_PORT_MIN: 9000
|
||||
SITE_PORT_MAX: 9099
|
||||
DATA_DIR: /data
|
||||
BACKUP_PASSWORD: ""
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
ACME_EMAIL: ""
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp"
|
||||
- "8080:8080"
|
||||
- "9000-9099:9000-9099"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
app_uid="${PUID:-1000}"
|
||||
app_gid="${PGID:-1000}"
|
||||
|
||||
case "$app_uid:$app_gid" in
|
||||
*[!0-9:]*|:*|*:) echo "PUID and PGID must be numeric." >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
data_root="${DATA_DIR:-/data}"
|
||||
mkdir -p "$data_root/sites" "$data_root/.uploads" "$data_root/caddy/config" "$data_root/icons" "$data_root/logs" "$data_root/default-site" "$data_root/database" "$data_root/migrations" "$data_root/backups" "$data_root/certificates/custom" "$data_root/certificates/managed" "$data_root/certificates/exports"
|
||||
|
||||
# Preserve legacy certificate storage before Caddy starts using the unified location.
|
||||
if [ -d "$data_root/custom-certificates" ] && [ -z "$(find "$data_root/certificates/custom" -mindepth 1 -print -quit 2>/dev/null)" ]; then cp -a "$data_root/custom-certificates/." "$data_root/certificates/custom/"; fi
|
||||
if [ -d "$data_root/caddy/data/caddy" ] && [ -z "$(find "$data_root/certificates/managed" -mindepth 1 -print -quit 2>/dev/null)" ]; then cp -a "$data_root/caddy/data/caddy/." "$data_root/certificates/managed/"; fi
|
||||
chown -R "$app_uid:$app_gid" "${DATA_DIR:-/data}"
|
||||
chmod 700 "$data_root/database" "$data_root/backups" "$data_root/certificates/custom" "$data_root/certificates/managed"
|
||||
|
||||
caddyfile="${DATA_DIR:-/data}/caddy/Caddyfile"
|
||||
if [ ! -f "$caddyfile" ]; then
|
||||
printf '%s\n' '{' ' admin localhost:2019' ' persist_config off' " storage file_system $data_root/certificates/managed" '}' '' ':80 {' ' respond "Site Gateway is ready." 404' '}' > "$caddyfile"
|
||||
chown "$app_uid:$app_gid" "$caddyfile"
|
||||
fi
|
||||
if ! grep -q '^[[:space:]]*storage file_system ' "$caddyfile"; then
|
||||
sed -i "/^[[:space:]]*persist_config off/a\\ storage file_system $data_root/certificates/managed" "$caddyfile"
|
||||
fi
|
||||
|
||||
export XDG_DATA_HOME="${DATA_DIR:-/data}/certificates/managed"
|
||||
export XDG_CONFIG_HOME="${DATA_DIR:-/data}/caddy/config"
|
||||
|
||||
su-exec "$app_uid:$app_gid" caddy run --config "$caddyfile" --adapter caddyfile &
|
||||
caddy_pid=$!
|
||||
su-exec "$app_uid:$app_gid" "$@" &
|
||||
app_pid=$!
|
||||
|
||||
shutdown() {
|
||||
kill -TERM "$app_pid" "$caddy_pid" 2>/dev/null || true
|
||||
wait "$app_pid" "$caddy_pid" 2>/dev/null || true
|
||||
}
|
||||
trap shutdown TERM INT
|
||||
|
||||
wait "$app_pid"
|
||||
status=$?
|
||||
kill -TERM "$caddy_pid" 2>/dev/null || true
|
||||
wait "$caddy_pid" 2>/dev/null || true
|
||||
exit "$status"
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "site-gateway",
|
||||
"version": "0.11.30",
|
||||
"private": true,
|
||||
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"check": "node --check src/server.js && node --check src/storage.js && node --check src/public/app.js && node --check src/public/features.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"adm-zip": "0.5.16",
|
||||
"express": "5.1.0",
|
||||
"multer": "2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
Generated
+718
@@ -0,0 +1,718 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
adm-zip:
|
||||
specifier: 0.5.16
|
||||
version: 0.5.16
|
||||
express:
|
||||
specifier: 5.1.0
|
||||
version: 5.1.0
|
||||
multer:
|
||||
specifier: 2.0.2
|
||||
version: 2.0.2
|
||||
|
||||
packages:
|
||||
|
||||
accepts@2.0.0:
|
||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
adm-zip@0.5.16:
|
||||
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
|
||||
engines: {node: '>=12.0'}
|
||||
|
||||
append-field@1.0.0:
|
||||
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
|
||||
|
||||
body-parser@2.3.0:
|
||||
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
busboy@1.6.0:
|
||||
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
|
||||
engines: {node: '>=10.16.0'}
|
||||
|
||||
bytes@3.1.2:
|
||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
call-bound@1.0.4:
|
||||
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
concat-stream@2.0.0:
|
||||
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
|
||||
engines: {'0': node >= 6.0}
|
||||
|
||||
content-disposition@1.1.0:
|
||||
resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
content-type@1.0.5:
|
||||
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
content-type@2.1.0:
|
||||
resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cookie-signature@1.2.2:
|
||||
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
|
||||
engines: {node: '>=6.6.0'}
|
||||
|
||||
cookie@0.7.2:
|
||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
depd@2.0.0:
|
||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
|
||||
encodeurl@2.0.0:
|
||||
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
es-define-property@1.0.1:
|
||||
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-errors@1.3.0:
|
||||
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-object-atoms@1.1.2:
|
||||
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
escape-html@1.0.3:
|
||||
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
||||
|
||||
etag@1.8.1:
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
express@5.1.0:
|
||||
resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
finalhandler@2.1.1:
|
||||
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
|
||||
engines: {node: '>= 18.0.0'}
|
||||
|
||||
forwarded@0.2.0:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
fresh@2.0.0:
|
||||
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
get-proto@1.0.1:
|
||||
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
gopd@1.2.0:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
has-symbols@1.1.0:
|
||||
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hasown@2.0.4:
|
||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
http-errors@2.0.1:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
iconv-lite@0.7.3:
|
||||
resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
is-promise@4.0.0:
|
||||
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
media-typer@0.3.0:
|
||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
media-typer@1.1.1:
|
||||
resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
merge-descriptors@2.0.0:
|
||||
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
mime-db@1.52.0:
|
||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-db@1.54.0:
|
||||
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@2.1.35:
|
||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@3.0.2:
|
||||
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
mkdirp@0.5.6:
|
||||
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
|
||||
hasBin: true
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
multer@2.0.2:
|
||||
resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==}
|
||||
engines: {node: '>= 10.16.0'}
|
||||
|
||||
negotiator@1.1.0:
|
||||
resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
on-finished@2.4.1:
|
||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
parseurl@1.3.3:
|
||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
path-to-regexp@8.4.2:
|
||||
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
qs@6.16.0:
|
||||
resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
range-parser@1.3.0:
|
||||
resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
raw-body@3.0.2:
|
||||
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
readable-stream@3.6.2:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
router@2.2.0:
|
||||
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
send@1.2.1:
|
||||
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
serve-static@2.2.1:
|
||||
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-map@1.0.1:
|
||||
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-weakmap@1.0.2:
|
||||
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel@1.1.1:
|
||||
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
statuses@2.0.2:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
streamsearch@1.1.0:
|
||||
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
toidentifier@1.0.1:
|
||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
type-is@1.6.18:
|
||||
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
type-is@2.1.0:
|
||||
resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
typedarray@0.0.6:
|
||||
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
|
||||
|
||||
unpipe@1.0.0:
|
||||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
vary@1.1.2:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
xtend@4.0.2:
|
||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
|
||||
snapshots:
|
||||
|
||||
accepts@2.0.0:
|
||||
dependencies:
|
||||
mime-types: 3.0.2
|
||||
negotiator: 1.1.0
|
||||
|
||||
adm-zip@0.5.16: {}
|
||||
|
||||
append-field@1.0.0: {}
|
||||
|
||||
body-parser@2.3.0:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
content-type: 2.1.0
|
||||
debug: 4.4.3
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.3
|
||||
on-finished: 2.4.1
|
||||
qs: 6.16.0
|
||||
raw-body: 3.0.2
|
||||
type-is: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
busboy@1.6.0:
|
||||
dependencies:
|
||||
streamsearch: 1.1.0
|
||||
|
||||
bytes@3.1.2: {}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
|
||||
call-bound@1.0.4:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
concat-stream@2.0.0:
|
||||
dependencies:
|
||||
buffer-from: 1.1.2
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
typedarray: 0.0.6
|
||||
|
||||
content-disposition@1.1.0: {}
|
||||
|
||||
content-type@1.0.5: {}
|
||||
|
||||
content-type@2.1.0: {}
|
||||
|
||||
cookie-signature@1.2.2: {}
|
||||
|
||||
cookie@0.7.2: {}
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
depd@2.0.0: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
ee-first@1.1.1: {}
|
||||
|
||||
encodeurl@2.0.0: {}
|
||||
|
||||
es-define-property@1.0.1: {}
|
||||
|
||||
es-errors@1.3.0: {}
|
||||
|
||||
es-object-atoms@1.1.2:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
escape-html@1.0.3: {}
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
||||
express@5.1.0:
|
||||
dependencies:
|
||||
accepts: 2.0.0
|
||||
body-parser: 2.3.0
|
||||
content-disposition: 1.1.0
|
||||
content-type: 1.0.5
|
||||
cookie: 0.7.2
|
||||
cookie-signature: 1.2.2
|
||||
debug: 4.4.3
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
finalhandler: 2.1.1
|
||||
fresh: 2.0.0
|
||||
http-errors: 2.0.1
|
||||
merge-descriptors: 2.0.0
|
||||
mime-types: 3.0.2
|
||||
on-finished: 2.4.1
|
||||
once: 1.4.0
|
||||
parseurl: 1.3.3
|
||||
proxy-addr: 2.0.7
|
||||
qs: 6.16.0
|
||||
range-parser: 1.3.0
|
||||
router: 2.2.0
|
||||
send: 1.2.1
|
||||
serve-static: 2.2.1
|
||||
statuses: 2.0.2
|
||||
type-is: 2.1.0
|
||||
vary: 1.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
finalhandler@2.1.1:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
on-finished: 2.4.1
|
||||
parseurl: 1.3.3
|
||||
statuses: 2.0.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
fresh@2.0.0: {}
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-define-property: 1.0.1
|
||||
es-errors: 1.3.0
|
||||
es-object-atoms: 1.1.2
|
||||
function-bind: 1.1.2
|
||||
get-proto: 1.0.1
|
||||
gopd: 1.2.0
|
||||
has-symbols: 1.1.0
|
||||
hasown: 2.0.4
|
||||
math-intrinsics: 1.1.0
|
||||
|
||||
get-proto@1.0.1:
|
||||
dependencies:
|
||||
dunder-proto: 1.0.1
|
||||
es-object-atoms: 1.1.2
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
||||
hasown@2.0.4:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
http-errors@2.0.1:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
inherits: 2.0.4
|
||||
setprototypeof: 1.2.0
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
iconv-lite@0.7.3:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
is-promise@4.0.0: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
media-typer@0.3.0: {}
|
||||
|
||||
media-typer@1.1.1: {}
|
||||
|
||||
merge-descriptors@2.0.0: {}
|
||||
|
||||
mime-db@1.52.0: {}
|
||||
|
||||
mime-db@1.54.0: {}
|
||||
|
||||
mime-types@2.1.35:
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
|
||||
mime-types@3.0.2:
|
||||
dependencies:
|
||||
mime-db: 1.54.0
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
mkdirp@0.5.6:
|
||||
dependencies:
|
||||
minimist: 1.2.8
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
multer@2.0.2:
|
||||
dependencies:
|
||||
append-field: 1.0.0
|
||||
busboy: 1.6.0
|
||||
concat-stream: 2.0.0
|
||||
mkdirp: 0.5.6
|
||||
object-assign: 4.1.1
|
||||
type-is: 1.6.18
|
||||
xtend: 4.0.2
|
||||
|
||||
negotiator@1.1.0:
|
||||
dependencies:
|
||||
content-type: 2.1.0
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
on-finished@2.4.1:
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
|
||||
once@1.4.0:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
parseurl@1.3.3: {}
|
||||
|
||||
path-to-regexp@8.4.2: {}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
dependencies:
|
||||
forwarded: 0.2.0
|
||||
ipaddr.js: 1.9.1
|
||||
|
||||
qs@6.16.0:
|
||||
dependencies:
|
||||
es-define-property: 1.0.1
|
||||
side-channel: 1.1.1
|
||||
|
||||
range-parser@1.3.0: {}
|
||||
|
||||
raw-body@3.0.2:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.3
|
||||
unpipe: 1.0.0
|
||||
|
||||
readable-stream@3.6.2:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
string_decoder: 1.3.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
router@2.2.0:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
depd: 2.0.0
|
||||
is-promise: 4.0.0
|
||||
parseurl: 1.3.3
|
||||
path-to-regexp: 8.4.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
send@1.2.1:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
fresh: 2.0.0
|
||||
http-errors: 2.0.1
|
||||
mime-types: 3.0.2
|
||||
ms: 2.1.3
|
||||
on-finished: 2.4.1
|
||||
range-parser: 1.3.0
|
||||
statuses: 2.0.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
serve-static@2.2.1:
|
||||
dependencies:
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
parseurl: 1.3.3
|
||||
send: 1.2.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
|
||||
side-channel-map@1.0.1:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
|
||||
side-channel-weakmap@1.0.2:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
side-channel-map: 1.0.1
|
||||
|
||||
side-channel@1.1.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
side-channel-list: 1.0.1
|
||||
side-channel-map: 1.0.1
|
||||
side-channel-weakmap: 1.0.2
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
streamsearch@1.1.0: {}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
type-is@1.6.18:
|
||||
dependencies:
|
||||
media-typer: 0.3.0
|
||||
mime-types: 2.1.35
|
||||
|
||||
type-is@2.1.0:
|
||||
dependencies:
|
||||
content-type: 2.1.0
|
||||
media-typer: 1.1.1
|
||||
mime-types: 3.0.2
|
||||
|
||||
typedarray@0.0.6: {}
|
||||
|
||||
unpipe@1.0.0: {}
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
@@ -0,0 +1,464 @@
|
||||
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: [], 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 };
|
||||
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();
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
function applyTheme(preference) {
|
||||
const effective = preference === "system" ? (systemTheme.matches ? "dark" : "light") : preference;
|
||||
document.documentElement.dataset.theme = effective;
|
||||
document.querySelector('meta[name="theme-color"]').content = effective === "dark" ? "#08101d" : "#f3f6fa";
|
||||
}
|
||||
const savedTheme = localStorage.getItem("webserver-theme") || "system";
|
||||
$("#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"); });
|
||||
|
||||
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; }
|
||||
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`;
|
||||
const units = ["KB", "MB", "GB", "TB"]; let size = value / 1024, unit = units[0];
|
||||
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 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); }
|
||||
function scopedValue(form, scope, name, fallback = "") { return form.querySelector(`${scope} [name="${name}"]`)?.value || fallback; }
|
||||
function advancedFormBody(form, body) {
|
||||
body.domains = String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean);
|
||||
body.hsts = form.has("hsts"); body.hstsSubdomains = form.has("hstsSubdomains"); body.healthEnabled = body.healthEnabled === true || body.healthEnabled === "on"; body.upstreamTlsInsecure = form.has("upstreamTlsInsecure");
|
||||
body.requestHeaders = parseHeaderLines(form.get("requestHeadersText")); body.responseHeaders = parseHeaderLines(form.get("responseHeadersText")); body.compression = form.get("compression") || "automatic"; body.customConfig = form.get("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.healthPath = form.get("healthPath") || "/"; body.healthMethod = form.get("healthMethod") || "GET"; body.healthExpected = form.get("healthExpected") || "200-499"; body.healthTimeoutSeconds = Number(form.get("healthTimeoutSeconds") || 4); body.healthRetries = Number(form.get("healthRetries") || 0);
|
||||
delete body.requestHeadersText; delete body.responseHeadersText; delete body.customLocationsText;
|
||||
return body;
|
||||
}
|
||||
// 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 = event.submitter;
|
||||
let body = Object.fromEntries(form); delete body.certificateFile; delete body.privateKeyFile;
|
||||
if (state.editing.kind === "proxy") body = advancedFormBody(form, body);
|
||||
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") }; }
|
||||
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."); }
|
||||
catch (error) { $("#settings-error").textContent = error.message; }
|
||||
finally { button.disabled = false; }
|
||||
}, true);
|
||||
|
||||
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`;
|
||||
if (group.running) return `${group.running} running${group.disabled ? ` · ${group.disabled} disabled` : ""}`;
|
||||
return `${group.disabled} disabled`;
|
||||
}
|
||||
|
||||
function probeClass(service) { return service.status === "ready" ? "running" : service.status === "error" ? "error" : service.status === "checking" ? "idle" : "inactive"; }
|
||||
function probeCopy(service, ready, error, unconfigured = "Not configured") {
|
||||
if (service.status === "checking") return "Checking again before reporting a problem";
|
||||
if (service.status === "unconfigured") return unconfigured;
|
||||
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() {
|
||||
const data = state.dashboard; if (!data) return;
|
||||
if (data.system) renderDashboardJobsSafe(data.system);
|
||||
$("#dash-hosted-total").textContent = data.hosted.total;
|
||||
$("#dash-hosted-detail").textContent = healthCopy(data.hosted, "sites");
|
||||
$("#dash-proxy-total").textContent = data.proxies.total;
|
||||
$("#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-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";
|
||||
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";
|
||||
$("#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)}`;
|
||||
$("#http-health-copy").textContent = probeCopy(data.services.http, "Ready and responding", "Not responding");
|
||||
$("#https-health-dot").className = `status-dot ${probeClass(data.services.https)}`;
|
||||
$("#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)}`;
|
||||
updateDashboardUptime(data.system.uptimeSeconds);
|
||||
$("#system-memory").textContent = formatBytes(data.system.memoryBytes);
|
||||
$("#system-data").textContent = formatBytes(data.system.dataBytes);
|
||||
$("#system-disk").textContent = formatBytes(data.system.diskFreeBytes);
|
||||
$("#system-disk").title = `${formatBytes(data.system.diskFreeBytes)} available of ${formatBytes(data.system.diskTotalBytes)} on the /data volume`;
|
||||
$("#system-app-version").textContent = `v${data.system.appVersion}`;
|
||||
$("#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>';
|
||||
}
|
||||
setInterval(() => { if (!document.querySelector("#dashboard-view.hidden")) updateDashboardUptime(); }, 1000);
|
||||
|
||||
function initials(name) {
|
||||
const words = String(name || "").trim().split(/\s+/).map(word => word.replace(/[^a-z0-9]/gi, "")).filter(Boolean);
|
||||
if (!words.length) return "??";
|
||||
return (words.length > 1 ? words[0][0] + words[1][0] : words[0].slice(0, 2).padEnd(2, words[0][0])).toUpperCase();
|
||||
}
|
||||
function iconMarkup(item) { return item.icon ? `<img src="${escapeHtml(item.icon)}" alt="">` : escapeHtml(initials(item.name)); }
|
||||
document.addEventListener('error', event => { const image = event.target; if (!(image instanceof HTMLImageElement) || !image.closest('.site-icon') || image.dataset.fallback) return; image.dataset.fallback = 'true'; const fallback = document.createElement('span'); fallback.textContent = initials(image.closest('[data-id]')?.querySelector('h2')?.textContent || '?'); image.replaceWith(fallback); }, true);
|
||||
function canManage() { return ["administrator", "standard"].includes(state.user?.role); }
|
||||
function canAdmin() { return state.user?.role === "administrator"; }
|
||||
|
||||
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")}`;
|
||||
const menu = canManage() ? `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="Site options" aria-expanded="false">•••</button><div class="menu"><button data-action="settings">Domain & TLS</button><button data-action="icon">Change icon</button><button data-action="replace">Replace files</button><button data-action="delete" class="danger-text">Delete site</button></div></div>` : "";
|
||||
const toggle = canManage() ? `<button class="toggle ${site.enabled ? "on" : ""}" data-action="toggle" aria-label="${site.enabled ? "Disable" : "Enable"} ${escapeHtml(site.name)}"><span></span></button>` : "";
|
||||
return `<article class="site-card" data-id="${site.id}" data-kind="hosted"><div class="card-top"><div class="site-icon">${iconMarkup(site)}</div>${menu}</div><h2>${escapeHtml(site.name)}</h2><p class="address">${escapeHtml(site.domain || `Port ${site.port}`)}</p>${site.domain ? `<p class="gateway-address ${site.tls !== "http" ? "secure" : ""}">${escapeHtml(publicUrl(site))}</p>` : ""}<p class="upstream-copy ${site.upstream?.status === "unhealthy" ? "bad" : ""}">${upstream}</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(site)}" target="_blank" rel="noopener" aria-label="Open ${escapeHtml(site.name)}">↗</a></div></div></article>`;
|
||||
}
|
||||
function proxyCard(proxy) {
|
||||
const status = proxy.status === "running" ? "running" : proxy.status === "error" ? "error" : "disabled";
|
||||
const upstream = !proxy.enabled || proxy.upstream?.status === "unmonitored" ? "Monitoring paused" : !proxy.upstream || proxy.upstream.status === "pending" ? "Upstream check pending" : proxy.upstream.status === "healthy" ? `Upstream ${proxy.upstream.httpStatus} · ${proxy.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(proxy.upstream.error || "check failed")}`;
|
||||
const menu = canManage() ? `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="Proxy options" aria-expanded="false">•••</button><div class="menu"><button data-action="settings">Edit proxy</button><button data-action="icon">Change icon</button><button data-action="delete" class="danger-text">Delete proxy</button></div></div>` : "";
|
||||
const toggle = canManage() ? `<button class="toggle ${proxy.enabled ? "on" : ""}" data-action="toggle" aria-label="${proxy.enabled ? "Disable" : "Enable"} ${escapeHtml(proxy.name)}"><span></span></button>` : "";
|
||||
const access = proxy.accessListId ? (state.accessLists.find(item => item.id === proxy.accessListId)?.name || "Access List") : "Public · no Access List";
|
||||
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>`;
|
||||
}
|
||||
|
||||
function renderCertificates() {
|
||||
const data = state.certificates; if (!data) return;
|
||||
$("#certificate-count").textContent = data.summary.total;
|
||||
$("#cert-healthy").textContent = data.summary.healthy; $("#cert-30").textContent = data.summary.within30Days; $("#cert-7").textContent = data.summary.within7Days; $("#cert-warning").textContent = data.summary.warning + data.summary.critical + data.summary.expired + data.summary.mismatch; $("#cert-pending").textContent = data.summary.pending;
|
||||
const ageMinutes = (Date.now() - new Date(data.checkedAt).getTime()) / 60000, stale = ageMinutes > (data.thresholds?.staleMinutes || 10);
|
||||
$("#cert-last-checked").textContent = `Last checked ${formatTime(data.checkedAt)} · ${stale ? "data may be stale" : "current"}`;
|
||||
$("#certificate-list").innerHTML = data.certificates.length ? data.certificates.map(cert => `<details class="certificate-row"><summary><span class="status-dot ${cert.status === "healthy" ? "running" : cert.status === "pending" ? "idle" : "error"}"></span><span><strong>${escapeHtml(cert.domain)}</strong><small>${escapeHtml(cert.kind)} · ${escapeHtml(cert.name)} · ${escapeHtml(cert.source)}</small></span><span><strong>${cert.expiresAt ? `${cert.daysRemaining} days remaining` : cert.status === "mismatch" ? "Domain mismatch" : "Not detected"}</strong><small>${cert.expiresAt ? `Expires ${formatTime(cert.expiresAt)}` : cert.mismatch ? `Covers: ${(cert.coveredNames || []).map(escapeHtml).join(", ") || "no DNS names"}` : "No stored certificate was found"}</small></span></summary><dl class="certificate-details"><div><dt>Status</dt><dd>${escapeHtml(cert.status)}</dd></div><div><dt>Valid from</dt><dd>${cert.validFrom ? escapeHtml(formatTime(cert.validFrom)) : "—"}</dd></div><div><dt>Issuer</dt><dd>${escapeHtml(cert.issuer || "—")}</dd></div><div><dt>Covered domains</dt><dd>${escapeHtml((cert.coveredNames || []).join(", ") || "—")}</dd></div><div><dt>Serial number</dt><dd>${escapeHtml(cert.serialNumber || "—")}</dd></div><div><dt>SHA-256 fingerprint</dt><dd>${escapeHtml(cert.fingerprint || "—")}</dd></div><div><dt>Last detected update</dt><dd>${cert.updatedAt ? escapeHtml(formatTime(cert.updatedAt)) : "—"}</dd></div></dl></details>`).join("") : '<p class="quiet-state padded">No HTTPS domains are configured.</p>';
|
||||
renderReadiness();
|
||||
}
|
||||
|
||||
function renderReadiness() {
|
||||
const routes = state.readiness?.routes || [];
|
||||
$("#readiness-list").innerHTML = routes.length ? routes.map(item => {
|
||||
const dnsOk = item.dns.healthy, portsOk = item.ports.http && item.ports.https !== false;
|
||||
const tlsOk = ["healthy", "warning", "critical", "not-configured"].includes(item.tls.status);
|
||||
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>`;
|
||||
}).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(); } });
|
||||
|
||||
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;
|
||||
const statusClass = $("#log-status").value, entries = statusClass ? data.entries.filter(entry => String(entry.status || "").startsWith(statusClass)) : data.entries;
|
||||
const errors = entries.filter(entry => entry.status >= 400).length, measured = entries.filter(entry => entry.durationMs != null), average = measured.length ? Math.round(measured.reduce((sum,entry) => sum + entry.durationMs,0) / measured.length) : null;
|
||||
$("#log-summary").innerHTML = `${entries.length} request${entries.length === 1 ? "" : "s"} · ${errors} error response${errors === 1 ? "" : "s"} · ${average == null ? "no latency data" : `${average} ms average`} · <span id="log-last-checked">Checked ${escapeHtml(formatTime(new Date().toISOString()))}</span>`;
|
||||
$("#log-rows").innerHTML = entries.length ? entries.map(entry => `<tr><td>${escapeHtml(formatTime(entry.at))}</td><td>${escapeHtml(entry.host || "—")}</td><td><code>${escapeHtml(entry.method || "")} ${escapeHtml(entry.uri || "")}</code></td><td><span class="http-status ${entry.status >= 500 ? "bad" : ""}">${entry.status ?? "—"}</span></td><td>${entry.durationMs == null ? "—" : `${entry.durationMs} ms`}</td></tr>`).join("") : '<tr><td colspan="5" class="quiet-state">No matching requests have been logged yet.</td></tr>';
|
||||
const categoryOf = message => /cert|tls|https/i.test(message) ? "certificate" : /health|upstream|response|fetch/i.test(message) ? "health" : /login|user|password|access/i.test(message) ? "authentication" : /backup|restore/i.test(message) ? "backup" : /config|route|host|gateway|reload/i.test(message) ? "configuration" : "system";
|
||||
const severity = $("#event-severity").value, category = $("#event-category").value;
|
||||
const activity = data.activity.filter(item => (!severity || item.status === severity) && (!category || categoryOf(item.message) === category));
|
||||
$("#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>';
|
||||
}
|
||||
|
||||
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; });
|
||||
const summary = $("#user-summary");
|
||||
if (summary) summary.innerHTML = [["Administrators", counts.administrator, "#62e6a7"], ["Standard Users", counts.standard, "#6ea8ff"], ["Viewers", counts.viewer, "#b58cff"], ["Disabled", counts.disabled, "#ff7185"], ["Archived", counts.archived, "#e6a04f"]].map(([label, count, color]) => `<div><span class="status-dot" style="${count ? `background:${color}` : ""}"></span><strong>${count}</strong><span>${label}</span></div>`).join("");
|
||||
$("#user-list").innerHTML = state.users.length ? state.users.map(user => {
|
||||
const isSelf = user.id === state.user?.id;
|
||||
const statusClass = user.status === "active" ? "running" : user.status === "disabled" ? "disabled" : "inactive";
|
||||
const roleAction = user.role === "administrator" ? "standard" : user.role === "standard" ? "viewer" : "administrator";
|
||||
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>`;
|
||||
}).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); });
|
||||
}
|
||||
|
||||
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 === "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();
|
||||
}
|
||||
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}`);
|
||||
$("#hosted-count").textContent = state.sites.length; $("#proxy-count").textContent = state.proxies.length; $("#streaming-count").textContent = "0"; $("#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" || state.view === "streaming";
|
||||
$("#management-view").classList.toggle("hidden", !management); $("#management-summary").classList.toggle("hidden", !(management || 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");
|
||||
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)); }
|
||||
$("#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";
|
||||
$("#open-create").classList.toggle("hidden", !(management || adminUsersActive || ["redirects","access"].includes(state.view)) || !canManage()); $("#check-health").classList.toggle("hidden", state.view !== "certificates"); $("#refresh-logs").classList.toggle("hidden", state.view !== "logs");
|
||||
if (overview) {
|
||||
$("#page-title").textContent = "Dashboard";
|
||||
$("#page-subtitle").textContent = "Health, activity, and system status at a glance.";
|
||||
renderDashboard();
|
||||
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."], 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 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 === "redirects" ? "+ New redirect host" : state.view === "access" ? "+ New Access List" : $("#open-create").textContent;
|
||||
if (state.view === "redirects") $("#redirect-empty .create-trigger").textContent = "Create a redirect host";
|
||||
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();
|
||||
return;
|
||||
}
|
||||
const items = state.view === "hosted" ? state.sites : state.view === "proxies" ? state.proxies : [];
|
||||
$("#site-grid").innerHTML = items.map(state.view === "hosted" ? hostedCard : proxyCard).join("");
|
||||
$("#empty").classList.toggle("hidden", !state.loaded || items.length > 0);
|
||||
$("#empty h2").textContent = state.view === "hosted" ? "Publish your first site" : state.view === "proxies" ? "Create your first proxy host" : "Create your first streaming host";
|
||||
$("#empty p").textContent = state.view === "hosted" ? "Upload a ZIP and optionally connect a domain with automatic HTTPS." : state.view === "proxies" ? "Connect a domain to another container, application, or LAN service." : "Streaming host management is coming soon.";
|
||||
$("#page-title").textContent = state.view === "hosted" ? "Hosted sites" : state.view === "proxies" ? "Proxy hosts" : "Streaming hosts";
|
||||
$("#page-subtitle").textContent = state.view === "hosted" ? "Upload and publish websites on a port or domain." : state.view === "proxies" ? "Route domains securely to applications and containers." : "Prepare and monitor streaming services from one place.";
|
||||
$("#open-create").textContent = state.view === "hosted" ? "+ New hosted site" : "+ New proxy host";
|
||||
$("#open-create").classList.toggle("hidden", state.view === "streaming" || !canManage());
|
||||
$("#empty .create-trigger").textContent = state.view === "hosted" ? "Create a hosted site" : state.view === "proxies" ? "Create a proxy host" : "Streaming hosts coming soon";
|
||||
$("#empty .create-trigger").disabled = state.view === "streaming";
|
||||
$(".port-note").classList.toggle("hidden", state.view === "proxies");
|
||||
const running = items.filter(item => item.status === "running").length, disabled = items.filter(item => item.status === "disabled").length, errors = items.filter(item => item.status === "error").length;
|
||||
$("#running-count").textContent = running; $("#disabled-count").textContent = disabled; $("#error-count").textContent = errors;
|
||||
$("#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"}`;
|
||||
}
|
||||
async function refresh() { const requests = [api("/api/sites"), api("/api/proxies"), api("/api/redirects"), 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", "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));
|
||||
for (const delay of [1000, 2000, 3000]) {
|
||||
if (!pending.size) return;
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
await refresh();
|
||||
for (const proxy of state.proxies) if (pending.has(String(proxy.id)) && proxy.upstream) pending.delete(String(proxy.id));
|
||||
}
|
||||
}
|
||||
async function refreshDashboard() {
|
||||
const button = $("#refresh-health"); button.disabled = true; button.classList.add("spinning"); $("#health-checked").textContent = "Checking services…";
|
||||
try { state.dashboard = await api("/api/dashboard"); renderDashboard(); }
|
||||
finally { button.disabled = false; button.classList.remove("spinning"); }
|
||||
}
|
||||
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`);
|
||||
const session = await fetch("/api/session").then(response => response.json());
|
||||
$("#login-title").textContent = session.installationSetupPending ? "Welcome to Site Gateway" : "Welcome back";
|
||||
$("#login-copy").textContent = session.installationSetupPending ? "Sign in using the administrator credentials you configured during installation." : "Sign in to manage your sites.";
|
||||
if (!session.authenticated) return showLogin();
|
||||
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"}`;
|
||||
$("#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);
|
||||
}
|
||||
|
||||
$("#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; } });
|
||||
$("#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(); });
|
||||
$("#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)); } });
|
||||
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)); } }));
|
||||
$("#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)); });
|
||||
$("#refresh-logs").addEventListener("click", () => loadFeatureView().catch(error => toast(error.message)));
|
||||
$("#log-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message)));
|
||||
$("#log-status").addEventListener("change", renderLogs);
|
||||
$("#event-severity").addEventListener("change", renderLogs);
|
||||
$("#event-category").addEventListener("change", renderLogs);
|
||||
function openCreate() {
|
||||
if (state.view === "streaming") return toast("Streaming host management is coming soon.");
|
||||
if (state.view === "administration") { $("#user-form").reset(); $("#user-error").textContent = ""; return $("#user-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();
|
||||
}
|
||||
$("#open-create").addEventListener("click", openCreate);
|
||||
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 = ""); }));
|
||||
$("#refresh-health").addEventListener("click", () => refreshDashboard().catch(error => toast(error.message)));
|
||||
$("#create-form").addEventListener("submit", async event => { event.preventDefault(); const button = event.submitter; 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 = event.submitter; 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"; } });
|
||||
|
||||
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);
|
||||
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");
|
||||
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") { form.elements.accessListId.value = item.accessListId || ""; form.elements.healthPath.value = item.healthPath || "/"; form.elements.healthExpected.value = item.healthExpected || "200-499"; form.elements.healthTimeoutSeconds.value = item.healthTimeoutSeconds || 4; form.elements.healthEnabled.checked = item.healthEnabled !== false; form.elements.compression.value = item.compression || "automatic"; form.elements.customLocationsText.value = (item.locations || []).map(location => `${location.path} | ${location.target} | ${location.stripPrefix ? "strip" : "preserve"}`).join("\n"); form.elements.requestHeadersText.value = (item.requestHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n"); form.elements.responseHeadersText.value = (item.responseHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n"); form.elements.upstreamTlsServerName.value = item.upstreamTlsServerName || ""; form.elements.upstreamTlsInsecure.checked = Boolean(item.upstreamTlsInsecure); form.elements.hstsSubdomains.checked = Boolean(item.hstsSubdomains); form.elements.customConfig.value = item.customConfig || ""; }
|
||||
if (kind === "proxy") form.elements.healthMethod.value = item.healthMethod || "GET";
|
||||
if (kind === "site") { form.elements.healthPath.value = item.healthPath || "/"; form.elements.healthMethod.value = item.healthMethod || "GET"; form.elements.healthExpected.value = item.healthExpected || "200-499"; form.elements.healthTimeoutSeconds.value = item.healthTimeoutSeconds || 4; form.elements.healthRetries.value = item.healthRetries || 0; form.elements.healthEnabled.checked = item.healthEnabled !== false; }
|
||||
$("#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();
|
||||
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 = event.submitter; 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"; } });
|
||||
|
||||
$("#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 === "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);
|
||||
});
|
||||
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); }
|
||||
});
|
||||
$("#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; });
|
||||
|
||||
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);
|
||||
}
|
||||
let iconSearchTimer;
|
||||
$("#icon-search").addEventListener("input", event => {
|
||||
clearTimeout(iconSearchTimer); const query = event.target.value.trim(); $("#icon-error").textContent = "";
|
||||
if (query.length < 2) { $("#icon-results").innerHTML = '<p class="quiet-state">Enter at least two characters to search.</p>'; return; }
|
||||
$("#icon-results").innerHTML = '<p class="quiet-state">Searching…</p>';
|
||||
iconSearchTimer = setTimeout(async () => {
|
||||
try {
|
||||
const results = await api(`/api/icons/search?q=${encodeURIComponent(query)}`);
|
||||
$("#icon-results").innerHTML = results.length ? results.map(icon => `<button type="button" class="icon-choice" data-slug="${escapeHtml(icon.slug)}"><img src="${escapeHtml(icon.preview)}" alt=""><span>${escapeHtml(icon.label)}</span></button>`).join("") : '<p class="quiet-state">No matching icons found.</p>';
|
||||
} catch (error) { $("#icon-results").innerHTML = ""; $("#icon-error").textContent = error.message; }
|
||||
}, 280);
|
||||
});
|
||||
async function saveIcon(slug) {
|
||||
if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites";
|
||||
$("#icon-error").textContent = "";
|
||||
try {
|
||||
await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug }) });
|
||||
$("#icon-dialog").close(); await refresh(); toast(slug ? "Icon saved locally." : "Two-letter fallback restored.");
|
||||
} catch (error) { $("#icon-error").textContent = error.message; }
|
||||
}
|
||||
$("#icon-results").addEventListener("click", event => { const choice = event.target.closest("[data-slug]"); if (choice) saveIcon(choice.dataset.slug); });
|
||||
$("#reset-icon").addEventListener("click", event => { event.preventDefault(); saveIcon(""); });
|
||||
$("#icon-upload").addEventListener("change", async event => {
|
||||
const file = event.target.files[0]; if (!file || !state.iconTarget) return;
|
||||
const data = new FormData(); data.append("icon", file); $("#icon-error").textContent = "";
|
||||
try { const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites"; await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "POST", body: data }); $("#icon-dialog").close(); await refresh(); toast("Custom icon saved locally."); }
|
||||
catch (error) { $("#icon-error").textContent = error.message; }
|
||||
});
|
||||
$("#save-icon-url").addEventListener("click", async () => {
|
||||
const value = $("#icon-url").value.trim(); if (!/^https:\/\//i.test(value)) { $("#icon-error").textContent = "Enter a trusted HTTPS image URL."; return; }
|
||||
if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites";
|
||||
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-form").addEventListener("submit", async event => {
|
||||
event.preventDefault(); const button = event.submitter; button.disabled = true; $("#user-error").textContent = "";
|
||||
try {
|
||||
await api("/api/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) });
|
||||
$("#user-dialog").close(); await loadFeatureView(); toast("User created.");
|
||||
} 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 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 === "password") {
|
||||
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") {
|
||||
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; }
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
try {
|
||||
const body = button.dataset.userAction === "role" ? { role: button.dataset.value } : { status: button.dataset.value };
|
||||
await api(`/api/users/${user.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
await loadFeatureView(); toast("User updated.");
|
||||
} catch (error) { toast(error.message); }
|
||||
finally { button.disabled = false; }
|
||||
});
|
||||
$("#password-form").addEventListener("submit", async event => {
|
||||
event.preventDefault(); const button = event.submitter; button.disabled = true; $("#password-error").textContent = "";
|
||||
try {
|
||||
await api(`/api/users/${state.passwordTarget}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: new FormData(event.target).get("password") }) });
|
||||
$("#password-dialog").close(); state.passwordTarget = null; await loadFeatureView(); toast("Password reset.");
|
||||
} catch (error) { $("#password-error").textContent = error.message; }
|
||||
finally { button.disabled = false; }
|
||||
});
|
||||
boot().catch(error => toast(error.message));
|
||||
|
||||
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);
|
||||
const https = targets.length > 0 && targets.every(value => /^https:\/\//i.test(value));
|
||||
const tlsName = form.elements.upstreamTlsServerName, tlsSkip = form.elements.upstreamTlsInsecure;
|
||||
[tlsName, tlsSkip].forEach(input => { if (!input) return; input.disabled = !https; input.closest("label")?.classList.toggle("control-disabled", !https); });
|
||||
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.";
|
||||
}
|
||||
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));
|
||||
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); });
|
||||
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 = event.submitter; 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);
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,276 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="theme-color" content="#0b1220">
|
||||
<title>Site Gateway</title>
|
||||
<meta name="description" content="Host sites, proxy services, and manage HTTPS from one simple dashboard.">
|
||||
<link rel="icon" type="image/png" href="/site-gateway-icon-approved.png">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="login" class="login-shell hidden">
|
||||
<form id="login-form" class="login-card">
|
||||
<img class="brand-mark product-icon" src="/site-gateway-lockup-approved.png" alt="Site Gateway">
|
||||
<p class="eyebrow">Host. Proxy. Secure.</p>
|
||||
<h1 id="login-title">Welcome back</h1>
|
||||
<p id="login-copy" class="muted">Sign in to manage your sites.</p>
|
||||
<label>Username<input name="username" autocomplete="username" required></label>
|
||||
<label>Password<input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<p id="login-error" class="error" role="alert"></p>
|
||||
<button class="button primary wide">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<dialog id="setup-dialog" class="setup-dialog">
|
||||
<form id="setup-form" class="dialog-card setup-card">
|
||||
<img class="brand-mark product-icon" src="/site-gateway-lockup-approved.png" alt="Site Gateway">
|
||||
<p class="eyebrow">First-time setup</p>
|
||||
<h1>Secure your administrator account</h1>
|
||||
<p class="muted">Confirm or change the administrator details below. The credentials supplied during installation were used only to bootstrap this account.</p>
|
||||
<label>Display name<input name="displayName" value="Administrator" maxlength="80" autocomplete="name" required></label>
|
||||
<label>Administrator username<input name="username" minlength="3" maxlength="64" pattern="[A-Za-z0-9][A-Za-z0-9._-]{2,63}" autocomplete="username" required></label>
|
||||
<label>New password<input name="password" type="password" minlength="8" autocomplete="new-password" required><small>Use at least 8 characters and a password unique to Site Gateway.</small></label>
|
||||
<label>Confirm password<input name="confirmPassword" type="password" minlength="8" autocomplete="new-password" required></label>
|
||||
<p id="setup-error" class="error" role="alert"></p>
|
||||
<button class="button primary wide">Save administrator account</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<div id="dashboard" class="app-shell hidden">
|
||||
<aside>
|
||||
<div class="brand"><img class="brand-mark small product-icon" src="/site-gateway-icon-approved.png" alt=""><span>Site Gateway</span></div>
|
||||
<nav aria-label="Publishing types">
|
||||
<button class="nav-active" data-view="overview">Dashboard</button>
|
||||
<button data-view="hosted">Hosted sites <span id="hosted-count">0</span></button>
|
||||
<button data-view="proxies">Proxy hosts <span id="proxy-count">0</span></button>
|
||||
<button data-view="streaming">Streaming hosts <span id="streaming-count">0</span></button>
|
||||
<button data-view="redirects">Redirect hosts <span id="redirect-count">0</span></button>
|
||||
<button data-view="certificates">Certificates <span id="certificate-count">0</span></button>
|
||||
<button data-view="access">Access Lists <span id="access-count">0</span></button>
|
||||
<button data-view="logs">Logs</button>
|
||||
</nav>
|
||||
<div class="aside-utilities"><button class="admin-only" data-view="administration">Administration</button><button data-view="documentation">Documentation</button></div>
|
||||
<div class="aside-footer"><span>Installed version</span><strong id="version-label">v—</strong></div>
|
||||
</aside>
|
||||
<main>
|
||||
<div class="utility-bar" aria-label="Account and appearance">
|
||||
<label class="theme-control" for="theme-select"><span>Theme</span><select id="theme-select" aria-label="Color theme"><option value="system">System</option><option value="dark">Dark</option><option value="light">Light</option></select></label>
|
||||
<div class="account-control"><span>Signed in as <strong id="user-label">admin</strong></span><button id="logout" class="text-button">Sign out</button></div>
|
||||
</div>
|
||||
<nav class="mobile-nav" aria-label="Dashboard sections">
|
||||
<button class="nav-active" data-view="overview">Dashboard</button>
|
||||
<button data-view="hosted">Hosted</button>
|
||||
<button data-view="proxies">Proxies</button>
|
||||
<button data-view="redirects">Redirects</button>
|
||||
<button data-view="certificates">TLS</button>
|
||||
<button data-view="logs">Logs</button>
|
||||
<button class="admin-only" data-view="administration">Admin</button><button data-view="documentation">Docs</button>
|
||||
</nav>
|
||||
<header>
|
||||
<div><p class="eyebrow">Gateway control</p><h1 id="page-title">Dashboard</h1><p id="page-subtitle" class="muted">Health, activity, and system status at a glance.</p></div>
|
||||
<button id="open-create" class="button primary">+ New hosted site</button><button id="check-health" class="button primary hidden">Run certificate check</button><button id="refresh-logs" class="button primary hidden">Refresh logs</button>
|
||||
</header>
|
||||
<section id="dashboard-view" class="dashboard-view" aria-label="Gateway dashboard">
|
||||
<div class="metric-grid">
|
||||
<button class="metric-card" data-target="hosted"><span class="metric-label">Hosted sites</span><strong id="dash-hosted-total">0</strong><span id="dash-hosted-detail">None configured</span></button>
|
||||
<button class="metric-card" data-target="proxies"><span class="metric-label">Proxy hosts</span><strong id="dash-proxy-total">0</strong><span id="dash-proxy-detail">None configured</span></button>
|
||||
<button class="metric-card" data-target="certificates"><span class="metric-label">Certificates</span><strong id="dash-tls-total">0</strong><span id="dash-tls-detail">No TLS domains</span></button>
|
||||
<div class="metric-card attention"><span class="metric-label">Needs attention</span><strong id="dash-attention-total">0</strong><span id="dash-attention-detail">No current issues</span></div>
|
||||
</div>
|
||||
<div class="dashboard-columns">
|
||||
<section class="dashboard-panel">
|
||||
<div class="panel-heading"><div><p class="eyebrow">Live health</p><h2>Services</h2></div><div class="health-actions"><span id="overall-health" class="health-badge healthy">Healthy</span><button id="refresh-health" class="icon-button" aria-label="Refresh health checks" title="Refresh health checks">↻</button></div></div>
|
||||
<div class="health-list">
|
||||
<div><span id="gateway-health-dot" class="status-dot running"></span><span><strong>Gateway</strong><small id="gateway-health-copy">Configuration valid</small></span></div>
|
||||
<div><span id="http-health-dot" class="status-dot running"></span><span><strong>HTTP · Port 80</strong><small id="http-health-copy">Ready and responding</small></span></div>
|
||||
<div><span id="https-health-dot" class="status-dot inactive"></span><span><strong>HTTPS · Port 443</strong><small id="https-health-copy">Not configured</small></span></div>
|
||||
<div><span id="storage-health-dot" class="status-dot running"></span><span><strong>Persistent storage</strong><small id="storage-health-copy">Data directory writable</small></span></div>
|
||||
</div>
|
||||
<p id="health-checked" class="checked-time">Last checked —</p>
|
||||
</section>
|
||||
<section class="dashboard-panel">
|
||||
<div class="panel-heading"><div><p class="eyebrow">Runtime</p><h2>System</h2></div></div>
|
||||
<dl class="system-grid">
|
||||
<div><dt>Uptime</dt><dd id="system-uptime">—</dd></div>
|
||||
<div><dt>Memory</dt><dd id="system-memory">—</dd></div>
|
||||
<div><dt>Site Gateway data</dt><dd id="system-data">—</dd><small>Used by sites and configuration</small></div>
|
||||
<div><dt>Storage available</dt><dd id="system-disk">—</dd><small>Available on the /data volume</small></div>
|
||||
<div><dt>Site Gateway</dt><dd id="system-app-version">—</dd></div>
|
||||
<div><dt>Caddy</dt><dd id="system-caddy-version">—</dd></div>
|
||||
<div><dt>Database</dt><dd id="system-database">—</dd><small id="system-database-detail">SQLite storage</small></div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
<div class="dashboard-columns lower">
|
||||
<section class="dashboard-panel">
|
||||
<div class="panel-heading"><div><p class="eyebrow">Action required</p><h2>Needs attention</h2></div></div>
|
||||
<div id="attention-list" class="dashboard-list"><p class="quiet-state">Everything looks good.</p></div>
|
||||
</section>
|
||||
<section class="dashboard-panel">
|
||||
<div class="panel-heading"><div><p class="eyebrow">Recent activity</p><h2>Recent activity</h2></div><button class="text-button" data-view="logs">View all logs →</button></div>
|
||||
<div id="activity-list" class="dashboard-list"><p class="quiet-state">No recent activity.</p></div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<section id="certificates-view" class="feature-view hidden">
|
||||
<p id="cert-last-checked" class="certificate-status muted checked-time">Last checked —</p>
|
||||
<div class="feature-summary">
|
||||
<div><strong id="cert-healthy">0</strong><span>Healthy</span></div><div><strong id="cert-30">0</strong><span>Within 30 days</span></div><div><strong id="cert-7">0</strong><span>Within 7 days</span></div><div><strong id="cert-warning">0</strong><span>Needs attention</span></div><div><strong id="cert-pending">0</strong><span>Not detected</span></div>
|
||||
</div>
|
||||
<div class="diagnostic-section-heading"><p class="eyebrow">Certificate inventory</p><h2>Certificates</h2><p class="muted">Managed and uploaded certificates assigned to configured domains.</p></div><div id="certificate-list" class="data-list diagnostic-list"><p class="quiet-state">Loading certificates…</p></div>
|
||||
<section class="dashboard-panel readiness-panel"><div class="panel-heading"><div><p class="eyebrow">Guided diagnostics</p><h2>Domain readiness</h2><p class="muted">DNS, listener, TLS, and upstream checks for every configured domain.</p></div></div><div id="readiness-list" class="dashboard-list diagnostic-list"><p class="quiet-state">Run a check to inspect configured domains.</p></div></section>
|
||||
</section>
|
||||
<section id="logs-view" class="feature-view hidden">
|
||||
<div class="log-toolbar"><div class="log-filters"><label>Domain<select id="log-host"><option value="">All domains</option></select></label><label>Response status<select id="log-status"><option value="">All responses</option><option value="2">Successful · 2xx</option><option value="3">Redirects · 3xx</option><option value="4">Client errors · 4xx</option><option value="5">Server errors · 5xx</option></select></label></div></div>
|
||||
<p id="log-summary" class="muted feature-note">No requests in the current view. <span id="log-last-checked">Not checked yet.</span></p>
|
||||
<p class="muted feature-note">Recent requests handled by Caddy. Sensitive request headers are never displayed.</p>
|
||||
<div class="log-section-heading diagnostic-section-heading"><p class="eyebrow">Access logs</p><h2>Access requests</h2><p class="muted">Requests handled by configured domains. Sensitive headers are never displayed.</p></div><div class="table-wrap log-table-wrap diagnostic-list"><table class="log-table"><thead><tr><th>Time</th><th>Domain</th><th>Request</th><th>Status</th><th>Duration</th></tr></thead><tbody id="log-rows"></tbody></table></div>
|
||||
<section class="dashboard-panel log-activity"><div class="panel-heading"><div><p class="eyebrow">Gateway events</p><h2>Activity and errors</h2><p class="muted">Configuration, certificate, and health events recorded by Site Gateway.</p></div></div><div class="event-filters"><label>Severity<select id="event-severity"><option value="">All severities</option><option value="ok">Normal</option><option value="warning">Warnings</option><option value="error">Errors</option></select></label><label>Category<select id="event-category"><option value="">All categories</option><option value="configuration">Configuration</option><option value="certificate">Certificates / TLS</option><option value="health">Upstream health</option><option value="authentication">Authentication</option><option value="backup">Backups</option><option value="system">System</option></select></label></div><div id="gateway-log-list" class="dashboard-list event-list diagnostic-list"></div></section>
|
||||
</section>
|
||||
<section id="users-view" class="feature-view hidden">
|
||||
<div class="admin-tabs"><button class="tab-active" data-admin-tab="users">Users</button><button data-admin-tab="defaults">Gateway defaults</button><button data-admin-tab="backups">Backup & restore</button><button data-admin-tab="security">Security & updates</button><button data-admin-tab="danger" class="danger-tab">Danger Zone</button></div>
|
||||
<section data-admin-panel="users">
|
||||
<div id="user-summary" class="summary user-summary" aria-label="User summary"></div>
|
||||
<div id="user-list" class="user-grid"><p class="quiet-state">Loading users…</p></div>
|
||||
</section>
|
||||
<section data-admin-panel="defaults" class="hidden settings-panel">
|
||||
<h2>Default site</h2><p class="muted">Choose what visitors receive when no configured host matches their request.</p>
|
||||
<div class="callout"><strong>HTTP fallback</strong><span>This response is used for unknown HTTP hostnames. Unknown HTTPS hostnames are rejected unless a matching certificate and route exist, preventing misleading certificate warnings.</span></div>
|
||||
<form id="default-site-form" class="settings-form"><div class="form-section"><p class="eyebrow">Response</p><label>Response<select name="mode"><option value="themed404">Themed route-not-found page (404)</option><option value="welcome">Gateway ready page (200)</option><option value="abort">No response — close connection</option><option value="redirect">Redirect elsewhere</option><option value="custom">Custom HTML</option></select></label></div><div class="form-section"><p class="eyebrow">Page content</p><label>Page heading<input name="title" maxlength="100" placeholder="Route not found"></label><label>Explanation<textarea name="message" maxlength="500" placeholder="The gateway is responding, but this address has not been configured."></textarea></label></div><div class="form-section"><p class="eyebrow">Redirect behavior</p><label>Redirect destination<input name="redirectUrl" type="url" placeholder="https://www.example.com"></label><label>Redirect code<select name="redirectCode"><option>302</option><option>301</option><option>307</option><option>308</option></select></label><label class="check-control"><input name="preservePath" type="checkbox" checked><span>Preserve the requested path and query</span></label></div><div class="form-section form-section-wide"><p class="eyebrow">Custom response</p><label>Custom HTML<textarea name="customHtml" class="code-input" placeholder="<!doctype html>..."></textarea><small>Administrator-authored HTML only. Used when Custom HTML is selected.</small></label></div><div class="dialog-actions"><button class="button primary">Save & apply</button></div><p id="default-error" class="error"></p></form>
|
||||
</section>
|
||||
<section data-admin-panel="backups" class="hidden settings-panel"><div class="panel-heading"><div><h2>Backup & restore</h2><p class="muted">Create a copy, restore a previous version, or schedule automatic backups.</p></div><div class="row-actions"><button id="import-backup" class="button secondary">Import backup</button><button id="create-backup" class="button primary">Create backup</button></div></div><input id="backup-upload" type="file" accept=".sgbackup,application/zip" hidden><form id="backup-settings-form" class="settings-form backup-settings"><div class="form-section form-section-wide"><p class="eyebrow">Scheduled backups</p><div class="form-grid"><label class="check-control"><input name="enabled" type="checkbox"><span>Enable scheduled backups</span></label><label>Backup type<select name="type"><option value="configuration">Configuration only</option><option value="complete">Complete — configuration + hosted files</option></select><small id="backup-type-help">Configuration only includes settings and metadata, not uploaded Hosted Site files.</small></label><label>Schedule<select name="frequency"><option value="daily">Daily</option><option value="weekly">Weekly</option><option value="monthly">Monthly</option></select></label><label>Hour<select name="hour"></select></label><label>Keep<input name="retention" type="number" min="1" max="100" value="7"></label><label class="check-control"><input name="includeLogs" type="checkbox"><span>Include logs</span></label></div></div><div class="form-section form-section-wide"><p class="eyebrow">Encryption</p><div class="form-grid encryption-grid"><label class="backup-password-field"><span class="field-label">Backup encryption password <span class="optional">Optional</span></span><input name="backupPassword" type="password" autocomplete="new-password"><small>Used for manually created backups and required when restoring an encrypted archive. It is not stored by Site Gateway.</small></label><label class="check-control encryption-toggle"><input name="encrypt" type="checkbox"><span>Encrypt scheduled backups<small>Uses the container’s <code>BACKUP_PASSWORD</code> value. Enable only after configuring that value.</small></span></label></div></div><div class="dialog-actions"><button class="button secondary">Save schedule</button></div></form><div class="callout"><strong>Storage guidance</strong><span id="backup-path">Backups are stored in /data/backups. Mount /backups separately to protect against appdata disk failure.</span></div><div id="backup-list" class="data-list"></div></section>
|
||||
<section data-admin-panel="security" class="hidden settings-panel"><h2>Security, health & updates</h2><div class="role-callout"><strong>Configuration safety</strong><span>Site Gateway validates generated Caddy configuration before every reload and retains the active configuration when validation fails.</span><strong>Container updates</strong><span>Updates are installed by pulling a new pinned image. Create a backup before changing versions.</span></div><section class="support-panel"><div><p class="eyebrow">Troubleshooting & support</p><h3>Gateway diagnostics</h3><p class="muted">Run checks and download a redacted report when you need to investigate a gateway issue.</p></div><div class="row-actions"><button id="download-support" class="button secondary admin-only">Download support report</button></div><p class="muted support-note">The report includes version, configuration health, certificate readiness, upstream checks, and recent events. Passwords, private keys, session secrets, cookies, and certificate contents are excluded.</p></section><form id="health-settings-form" class="settings-form"><label>Renewing-soon warning<input name="warningDays" type="number" min="8" max="120" value="30"><small>Days remaining before a certificate is highlighted.</small></label><label>Critical warning<input name="criticalDays" type="number" min="1" max="119" value="7"><small>Must be lower than the renewing-soon threshold.</small></label><label>Stale health data<input name="staleMinutes" type="number" min="2" max="1440" value="10"><small>Minutes before a displayed check is considered old.</small></label><div class="dialog-actions"><button class="button primary">Save health settings</button></div></form></section>
|
||||
<section data-admin-panel="danger" class="hidden settings-panel danger-zone"><h2>Danger Zone</h2><p class="muted">These actions can permanently remove Site Gateway data. Review each warning carefully before continuing.</p><div class="danger-card"><p class="eyebrow">Restore defaults</p><h3>Reset gateway preferences</h3><p>Restore default site behavior, backup scheduling, certificate thresholds, and interface preferences. Your users, routes, certificates, logs, and backups remain intact.</p><button id="restore-defaults" class="button secondary">Restore default settings</button></div><div class="danger-card destructive"><p class="eyebrow">Permanent action</p><h3>Factory reset</h3><p>Deletes all Site Gateway data under <code>/data</code>, including users, routes, certificates, logs, backups, and settings. Docker-mounted files outside <code>/data</code> are not affected. The container restarts at first-install setup.</p><form id="factory-reset-form" class="danger-form"><label>Administrator username<input name="username" autocomplete="username" required></label><label>Administrator password<input name="password" type="password" autocomplete="current-password" required></label><label>Type <strong>FACTORY RESET</strong> to confirm<input name="confirmation" required autocomplete="off"></label><p id="factory-reset-error" class="error"></p><div class="danger-actions"><button class="button secondary" type="button" id="factory-reset-cancel">Cancel</button><button class="button danger" type="submit">Erase all data and reset</button></div></form></div></section>
|
||||
</section>
|
||||
<section id="redirects-view" class="feature-view hidden"><div id="redirect-list" class="site-grid"></div><section id="redirect-empty" class="empty"><div class="empty-icon">↪</div><h2>Create your first redirect</h2><p>Send an old domain to a new destination while preserving its path if you choose.</p><button class="button primary create-trigger">Create a redirect host</button></section></section>
|
||||
<section id="access-view" class="feature-view hidden"><div id="access-list" class="data-list"></div></section>
|
||||
<section id="documentation-view" class="feature-view hidden docs"><div class="docs-intro"><p class="eyebrow">Site Gateway manual</p><h2>Simple routing for homelabs and small teams</h2><p>A complete guide to publishing sites, routing applications, securing domains, and recovering safely. Start with the defaults, then use the advanced controls when you understand the trade-offs.</p><div class="docs-search-panel"><label class="doc-search"><span>Search the complete manual</span><input id="doc-search" type="search" placeholder="Search “upstream TLS”, “Plex”, “CIDR”, “backup”, or any field name"></label><small>Searches purpose, fields, examples, troubleshooting, and expert notes.</small></div></div><div class="docs-layout"><aside class="docs-nav" aria-label="Documentation sections"><p class="eyebrow">Contents</p><button data-doc-jump="introduction">Introduction</button><button data-doc-jump="dashboard">Dashboard</button><button data-doc-jump="hosted">Hosted Sites</button><button data-doc-jump="proxy">Proxy Hosts</button><button data-doc-jump="redirect">Redirect Hosts</button><button data-doc-jump="certificate">Certificates</button><button data-doc-jump="logs">Logs</button><button data-doc-jump="access">Access Lists</button><button data-doc-jump="administration">Administration</button><button data-doc-jump="backup">Backup & Restore</button><button data-doc-jump="danger">Danger Zone</button><button data-doc-jump="common">Common Controls</button></aside><div id="docs-content">
|
||||
<article data-doc="introduction why built philosophy caddy novice expert"><p class="eyebrow">Introduction</p><h2>Why Site Gateway exists</h2><p>Reverse proxies often expose powerful settings without explaining what they change. Site Gateway provides a visual, Caddy-powered control plane for static sites, proxy routes, redirects, HTTPS, health checks, access control, and recovery.</p><h3>Novice path</h3><p>Create one route, test it locally, then add a domain and TLS. Keep defaults until you have a reason to change them.</p><h3>Expert note</h3><p>Configuration is stored in SQLite under <code>/data</code> and generated Caddy configuration is validated before reload.</p><h3>Example</h3><p>Publish a ZIP on a direct port first, then add <code>www.example.com</code> after DNS and port forwarding are ready.</p></article>
|
||||
<article data-doc="getting started install first login"><p class="eyebrow">Getting started</p><h2>From installation to your first route</h2><p>Install the container with persistent <code>/data</code> storage, open port 8080, and sign in with the administrator credentials supplied to Docker. Before publishing public domains, make sure DNS points to this server and ports 80 and 443 are free.</p></article>
|
||||
<article data-doc="hosted static zip index upload"><p class="eyebrow">Hosted sites</p><h2>Publish a static website</h2><ol><li>Open Hosted Sites and choose New hosted site.</li><li>Give the site a name and unused direct-access port.</li><li>Upload an index.html or ZIP whose root contains index.html.</li><li>Add a domain only when DNS is ready; choose Automatic HTTPS for public service.</li></ol><p><strong>Expected behavior:</strong> the files are served immediately on the chosen port and, when configured, through the domain.</p></article>
|
||||
<article data-doc="proxy jellyfin vaultwarden plex forward upstream"><p class="eyebrow">Proxy hosts</p><h2>Connect a local application</h2><p>For Jellyfin at <code>192.168.1.20:8096</code>, use domain <code>jellyfin.example.com</code> and forward target <code>http://192.168.1.20:8096</code>. Site Gateway checks the upstream and Caddy manages eligible public HTTPS certificates automatically.</p></article>
|
||||
<article data-doc="certificate tls dns ports pending"><p class="eyebrow">Certificates</p><h2>Automatic HTTPS prerequisites</h2><p>The domain must resolve to your public address, inbound ports 80 and 443 must reach Site Gateway, and another proxy cannot own those ports. “Not detected” means Caddy has not yet stored a certificate; review gateway events and DNS before retrying.</p></article>
|
||||
<article data-doc="diagnostics readiness check now support report expiration"><p class="eyebrow">Health diagnostics</p><h2>Understand what failed</h2><p>Open Certificates and choose <strong>Check now</strong> to test DNS resolution, the gateway listeners, stored certificate coverage, and proxy upstream health. Expand a certificate for its non-secret details. Administrators can download a redacted support report when asking for help; it intentionally excludes credentials, cookies, private keys, secrets, and raw expert configuration.</p></article>
|
||||
<article data-doc="access list lan authentication"><p class="eyebrow">Access Lists</p><h2>Protect a route</h2><p>Use <code>private_ranges</code> to allow standard LAN address ranges, or enter exact IP/CIDR values one per line. Add a login when the visitor must also authenticate. Assign the saved list from the Proxy Host’s Advanced section.</p></article>
|
||||
<article data-doc="redirect host permanent temporary path query"><p class="eyebrow">Redirect Hosts</p><h2>Move an address safely</h2><p>Use 301 or 308 only when the move is intended to be permanent; browsers can cache them. Use 302 or 307 while testing. Enable Preserve path and query when <code>old.example.com/library?id=2</code> should become <code>new.example.com/library?id=2</code>.</p></article>
|
||||
<article data-doc="default site welcome 404 no response custom html"><p class="eyebrow">Default site</p><h2>Handle unknown addresses</h2><p>The themed 404 is the safest public default. Gateway ready confirms HTTP routing during setup, No response closes unmatched HTTP connections, Redirect sends visitors elsewhere, and Custom HTML serves administrator-provided markup. Unknown HTTPS names still require their own valid route and certificate.</p></article>
|
||||
<article data-doc="backup restore update rollback sqlite certificates"><p class="eyebrow">Backups</p><h2>Back up before an update</h2><p>A configuration backup contains a consistent SQLite snapshot and portable recovery data. A complete backup also contains hosted files, local assets, and certificate storage. Backups are stored in <code>/data/backups</code>; advanced installations can mount separate storage directly at that path.</p></article>
|
||||
<article data-doc="backup encryption schedule retention restore troubleshooting"><p class="eyebrow">Restore checklist</p><h2>Recover with confidence</h2><ol><li>Download or import the <code>.sgbackup</code> archive.</li><li>Supply its password if it is encrypted.</li><li>Choose Restore and allow validation to finish.</li><li>Confirm hosts, certificates, and upstream health.</li></ol><p>Site Gateway verifies file checksums and creates a pre-restore safety backup. If the restored Caddy configuration is invalid, it attempts to recover the previous state automatically.</p></article>
|
||||
<article data-doc="logs access logs gateway events requests response status domain filters"><p class="eyebrow">Logs</p><h2>Investigate requests and gateway events</h2><p>Access Logs show domains, paths, response status, latency, and upstream outcomes. Gateway Events record configuration and operational changes.</p><h3>Example</h3><p>Filter for a 502 or failed upstream event, then compare the target address with a direct LAN request.</p></article>
|
||||
<article data-doc="users roles administrator standard viewer groups permissions audit"><p class="eyebrow">Administration · Users & Groups</p><h2>Control who can change the gateway</h2><p>Administrators manage users, roles, groups, and audit history. Standard Users perform permitted management tasks; Viewers are read-only.</p><h3>Example</h3><p>Create a Viewer for monitoring and a Standard User for routine route changes.</p></article>
|
||||
<article data-doc="gateway defaults default site restore factory reset danger zone"><p class="eyebrow">Administration · Gateway Defaults & Danger Zone</p><h2>Preferences and destructive actions</h2><p>Gateway Defaults control unknown HTTP responses, backup scheduling, and certificate thresholds. Restore Defaults changes preferences only. Factory Reset deletes all data under <code>/data</code> and returns to initial setup.</p><h3>Example</h3><p>Keep the themed 404 in production and create a complete backup before any factory reset.</p></article>
|
||||
<article data-doc="proxy advanced access list health expected status compression custom locations headers upstream tls server name hsts caddy configuration five ws"><p class="eyebrow">Proxy Hosts · Advanced options</p><h2>Why the advanced controls exist</h2><p>Most applications work with only a domain, Forward to target, and TLS choice. Advanced options are for applications with unusual paths, authentication boundaries, response codes, headers, certificates, or performance needs.</p><h3>What each control changes</h3><ul><li><strong>Access List:</strong> applies reusable login and network rules before the upstream is reached.</li><li><strong>Health-check path and method:</strong> tells Site Gateway what request to make when checking the application.</li><li><strong>Expected status:</strong> accepts a code, list, or range such as <code>200</code>, <code>200,204</code>, or <code>200-399</code>.</li><li><strong>Timeout and retries:</strong> control how long a check waits and how many additional attempts are made.</li><li><strong>Compression:</strong> controls whether Caddy negotiates gzip or zstd for responses.</li><li><strong>Custom Locations:</strong> sends paths such as <code>/api/*</code> to a different upstream and can strip or preserve the path.</li><li><strong>Request and response headers:</strong> add metadata required by an application or browser.</li><li><strong>Upstream TLS server name:</strong> supplies the SNI name when the upstream certificate expects a hostname.</li><li><strong>Trust an unverified upstream certificate:</strong> permits internal HTTPS with an untrusted certificate; use only on a trusted network.</li><li><strong>HSTS:</strong> tells browsers to use HTTPS for future requests; enable only after HTTPS is reliable.</li><li><strong>Custom Caddy configuration:</strong> an expert escape hatch for supported Caddy directives, validated before reload.</li></ul><h3>Who, where, when, and why</h3><p><strong>Who:</strong> experts operating applications with documented proxy requirements. <strong>Where:</strong> the Advanced options panel for one Proxy Host. <strong>When:</strong> only after the basic route works. <strong>Why:</strong> to solve a known requirement rather than guessing at settings.</p><h3>Practical example: Jellyfin</h3><p>Use <code>http://192.168.1.20:8096</code> as the upstream, leave the health path at <code>/</code>, keep the default expected range, and enable HSTS only after public HTTPS works. If an internal HTTPS service uses a private certificate, set its upstream SNI name and consider the unverified-certificate option only when the LAN is trusted.</p><h3>How to verify</h3><p>Save one change at a time, watch the card’s upstream status, inspect Access Logs, and compare the result with a direct request to the application. If Caddy rejects a custom configuration, Site Gateway retains the last known-good configuration.</p></article>
|
||||
<article data-doc="danger zone restore defaults factory reset credentials yes countdown setup recovery complete guide"><p class="eyebrow">Administration · Danger Zone</p><h2>Reset preferences or rebuild from zero</h2><p>This page contains the two actions with the greatest impact in Site Gateway. They are intentionally separate so a routine preference correction cannot be confused with a destructive rebuild.</p><h3>Restore Defaults: what it is for</h3><p>Restore Defaults returns gateway preferences to their known starting values: the Default Site response, page heading and explanation, redirect behavior, backup schedule, and certificate-health thresholds. It does not remove hosts, uploaded files, users, groups, Access Lists, certificates, logs, or saved backups.</p><h3>Factory Reset: what it is for</h3><p>Factory Reset removes Site Gateway data under <code>/data</code>, including routes, hosted content, users, groups, Access Lists, certificates, logs, backups, icons, and settings. Files mounted outside <code>/data</code> are not touched. Use it for a lab rebuild, a clean handoff, or recovery from an intentionally abandoned configuration—not to undo one route.</p><h3>Who should use these actions</h3><p>Only an Administrator should use them. Standard Users and Viewers should not see or operate destructive controls. The server verifies the signed-in administrator, the entered username, and the password before showing the final confirmation.</p><h3>What happens when you click the button</h3><p>Validation happens in order: username, password, confirmation phrase, then a second themed dialog requiring <code>YES</code>. Cancel clears every field and changes nothing. Restore Defaults applies immediately and refreshes the page. Factory Reset clears the data, recreates the initial bootstrap state, shows a countdown, and returns to the first-install login/setup flow without requiring a manual container restart.</p><h3>When to use a backup instead</h3><p>If you want to undo a recent change while keeping the rest of the installation, create or restore a complete backup. Factory Reset is not a rollback tool; it intentionally removes the recovery material stored under <code>/data/backups</code>.</p><h3>Practical examples</h3><ul><li>Your Default Site explanation is confusing: use Restore Defaults.</li><li>You are moving the container to a new owner: create a complete backup, verify it, then use Factory Reset.</li><li>A route stopped working: inspect Logs and restore the route or backup; do not factory-reset first.</li></ul><h3>After a Factory Reset</h3><p>Open the management URL, sign in with the installation administrator credentials, and complete the initial administrator setup. Recreate or restore your hosts, certificates, users, groups, and Access Lists only after confirming the empty gateway responds correctly.</p></article>
|
||||
<article data-doc="common interface controls menus three dots edit disable delete enable icons dashboard icons custom upload initials roles cards"><p class="eyebrow">Common Interface Controls</p><h2>Menus, status controls, and icons</h2><p>The same card language is used throughout Hosted Sites, Proxy Hosts, Redirect Hosts, Access Lists, Groups, and Users so that learning one area transfers to the next.</p><h3>What the three-dot menu is for</h3><p>The three-dot menu contains actions that change or inspect a card. <strong>Edit</strong> opens the full form. <strong>Enable/Disable</strong> changes whether the route or control is active without deleting its saved configuration. <strong>Assignments</strong> shows which hosts use an Access List. <strong>Delete</strong> removes the record after a confirmation.</p><h3>Who can use each action</h3><p>Administrators can manage all cards. Standard Users see only actions allowed by their capability set. Viewers can inspect information but cannot create, edit, disable, assign, or delete configuration. Authorization is enforced by the server, not only by hiding buttons.</p><h3>When to disable instead of delete</h3><p>Disable a route during maintenance or testing when you expect to reuse its settings. Delete only when the route, assignments, and its configuration are no longer needed.</p><h3>Changing a card icon</h3><p>Select the card’s icon or choose Icon from its menu to open the icon picker. Search by service name, such as <code>Jellyfin</code>, then select a result. You can also upload a custom PNG, JPEG, WebP, or SVG when the service is not in the catalog. The interface scales icons into the same two-letter tile size and preserves the current initials as a fallback if an icon is removed or unavailable.</p><h3>Practical examples</h3><ul><li>Disable a Proxy Host while upgrading Plex, then enable it after the upstream responds.</li><li>Assign one Access List to several hosts and inspect Assignments before changing its rules.</li><li>Choose a Jellyfin icon for a Proxy Host; if the icon catalog is unavailable, its initials remain visible.</li></ul><h3>Backup and troubleshooting</h3><p>Icons and assignments are included in complete backups. If a custom icon does not appear, verify the upload completed, refresh the card list, and confirm the file format is supported. Changing an icon never changes routing, TLS, or access behavior.</p></article>
|
||||
<article data-doc="hosted site field reference name primary additional domains upload port tls hsts icon"><p class="eyebrow">Hosted Sites · Field reference</p><h2>What each Hosted Site field means</h2><p><strong>Name</strong> is the label you see in Site Gateway; it does not have to match the domain. <strong>Primary domain</strong> is the main hostname. <strong>Additional domains</strong> are aliases that serve the same files. <strong>Upload</strong> accepts a site folder or ZIP and expects <code>index.html</code> at the web root. <strong>Port</strong> is the direct LAN port and must be inside the configured range. <strong>TLS</strong> controls whether the domain uses automatic public HTTPS. <strong>HSTS</strong> should be enabled only after HTTPS has been tested on every intended client.</p><h3>Novice example</h3><p>Name the route “Family landing page,” use port 9100, upload the ZIP, browse to the LAN address, and add a domain later.</p><h3>Expert example</h3><p>Use additional domains for a canonical and legacy hostname while keeping one file tree. Complete backups preserve both the route metadata and uploaded files.</p></article>
|
||||
<article data-doc="certificates field reference domain readiness check issuer expiration custom certificate acme"><p class="eyebrow">Certificates · Field reference</p><h2>Read certificate health correctly</h2><p>Each configured hostname receives its own readiness result. DNS shows whether the name resolves, HTTP and HTTPS show listener reachability, and TLS shows certificate coverage and status. Issuer identifies the authority, expiration shows remaining lifetime, and “Waiting for Caddy” means issuance has not completed—not that a certificate was already created.</p><h3>Novice workflow</h3><p>Confirm DNS, forward ports 80 and 443, stop competing proxies, then run the certificate check. Do not troubleshoot an upstream application until the domain and HTTPS checks are healthy.</p><h3>Expert workflow</h3><p>Use custom certificates for externally purchased or wildcard material under the custom certificate area. Keep Caddy-managed ACME material separate and protect private keys.</p></article>
|
||||
<article data-doc="advanced caddy custom locations headers compression health upstream tls"><p class="eyebrow">Advanced proxy settings</p><h2>Start simple, expand only when needed</h2><p>Custom Locations route selected paths to different upstreams. Request headers are sent upstream; response headers are returned to visitors. Health checks accept individual codes or ranges. Unverified upstream TLS and custom Caddy configuration are expert controls—change one item at a time and rely on validation feedback.</p></article>
|
||||
<article data-doc="troubleshooting dns ports certificate caddy nginx conflict"><p class="eyebrow">Troubleshooting</p><h2>When HTTPS is not detected</h2><p>Confirm public DNS points to this server, router forwarding reaches ports 80 and 443, and NGINX Proxy Manager or another service is not still using those ports. Then review Certificates and Logs → Gateway events. Site Gateway cannot request a public certificate while another gateway receives the challenge.</p></article>
|
||||
</div></div><p id="doc-empty" class="quiet-state hidden">No guide matched that search.</p></section>
|
||||
<section id="management-summary" class="summary hidden" aria-label="Site summary"><div><span id="running-dot" class="status-dot inactive"></span><strong id="running-count">0</strong><span id="running-label">No sites running</span></div><div><span id="disabled-dot" class="status-dot inactive"></span><strong id="disabled-count">0</strong><span id="disabled-label">No disabled sites</span></div><div><span id="error-dot" class="status-dot inactive"></span><strong id="error-count">0</strong><span id="error-label">No issues</span></div><div class="port-note">Ports <strong id="port-range">9000–9099</strong></div></section>
|
||||
<div id="management-view" class="hidden">
|
||||
<section id="empty" class="empty hidden">
|
||||
<div class="empty-icon">↗</div><h2>Publish your first site</h2>
|
||||
<p>Drop in a ZIP containing an <code>index.html</code> and choose a port. That’s it.</p>
|
||||
<button class="button primary create-trigger">Create a site</button>
|
||||
</section>
|
||||
<section id="site-grid" class="site-grid" aria-live="polite"></section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<dialog id="create-dialog">
|
||||
<form id="create-form" class="dialog-card">
|
||||
<div class="dialog-heading"><div><p class="eyebrow">New destination</p><h2>Create a site</h2></div><button type="button" class="icon-button close-dialog" aria-label="Close">×</button></div>
|
||||
<label>Site name<input name="name" placeholder="Portfolio" maxlength="80" required></label>
|
||||
<label>Port<input name="port" type="number" required><small id="port-help"></small></label>
|
||||
<label>Domain <span class="optional">Optional</span><input name="domain" placeholder="www.example.com"><small>Leave blank for port-only LAN access.</small></label><label>Additional domains <span class="optional">Optional</span><textarea name="domains" placeholder="www.example.com example.net"></textarea><small>One alias per line. All domains use the same hosted files and TLS settings.</small></label>
|
||||
<label>TLS<select name="tls"><option value="automatic">Automatic public HTTPS</option><option value="internal">Internal HTTPS for trusted local devices</option><option value="http">HTTP only</option></select></label>
|
||||
<label class="check-control"><input name="hsts" type="checkbox" value="true"><span>Enable HSTS after HTTPS is verified</span></label>
|
||||
<details><summary>Advanced options</summary><div class="details-body"><label>Access List<select name="accessListId"><option value="">Public — no Access List</option></select><small>Reusable network or login protection.</small></label><label>Compression<select name="compression"><option value="automatic">Automatic zstd + gzip</option><option value="gzip">gzip only</option><option value="off">Off</option></select></label><label>Request headers<textarea name="requestHeadersText" placeholder="X-Robots-Tag: noindex"></textarea><small>One Name: value pair per line.</small></label><label>Response headers<textarea name="responseHeadersText" placeholder="X-Frame-Options: SAMEORIGIN"></textarea><small>One Name: value pair per line.</small></label><label class="check-control"><input name="hstsSubdomains" type="checkbox"><span>Apply HSTS to subdomains</span></label><label>Custom Caddy configuration<textarea name="customConfig" class="code-input" placeholder="# Expert use only"></textarea><small>Validated before Caddy reload.</small></label></div></details>
|
||||
<label class="dropzone">Website files<input name="files" type="file" accept=".zip,.html,text/html,application/zip" required><span class="upload-icon">⇧</span><strong>Choose a ZIP or index.html</strong><small>ZIP files must contain index.html · Up to 250 MB</small></label>
|
||||
<p id="create-error" class="error" role="alert"></p>
|
||||
<div class="dialog-actions"><button type="button" class="button secondary close-dialog">Cancel</button><button class="button primary">Create & publish</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="proxy-dialog">
|
||||
<form id="proxy-form" class="dialog-card">
|
||||
<div class="dialog-heading"><div><p class="eyebrow">New route</p><h2>Create a proxy host</h2></div><button type="button" class="icon-button close-dialog" aria-label="Close">×</button></div>
|
||||
<label>Name<input name="name" placeholder="Home Assistant" maxlength="80" required></label>
|
||||
<label>Primary domain<input name="domain" placeholder="home.example.com" required></label>
|
||||
<label>Additional domains <span class="optional">Optional</span><textarea name="domainsText" placeholder="www.home.example.com home.example.net"></textarea><small>One alias per line. All domains use this proxy host’s upstream and TLS settings.</small></label>
|
||||
<label>Forward to<input name="target" type="url" placeholder="http://192.168.1.20:8123" required><small>Use the container name, LAN address, or application URL.</small></label>
|
||||
<label>Upstream pool <span class="optional">Optional</span><textarea name="upstreamsText" placeholder="http://192.168.1.20:53 http://192.168.1.21:53"></textarea><small>One HTTP/HTTPS target per line. Caddy distributes requests across healthy targets.</small></label>
|
||||
<label>TLS<select name="tls"><option value="automatic">Automatic public HTTPS</option><option value="internal">Internal HTTPS for trusted local devices</option><option value="custom">Custom uploaded certificate</option><option value="http">HTTP only</option></select></label>
|
||||
<label class="check-control"><input name="hsts" type="checkbox"><span>Enable HSTS after HTTPS is verified</span></label>
|
||||
<div id="custom-certificate-fields"><label>Certificate PEM<input name="certificateFile" type="file" accept=".pem,.crt,application/x-pem-file"></label><label>Private key PEM<input name="privateKeyFile" type="file" accept=".pem,.key,application/x-pem-file"></label><small>Both files are required when installing or replacing a custom certificate.</small></div>
|
||||
<details><summary>Advanced options</summary><div class="details-body"><label>Access List<select name="accessListId"><option value="">Public — no Access List</option></select><small>Reusable network or login protection.</small></label><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 class="check-control"><input name="healthEnabled" type="checkbox" checked><span>Monitor this upstream</span></label><label>Compression<select name="compression"><option value="automatic">Automatic zstd + gzip</option><option value="gzip">gzip only</option><option value="off">Off</option></select></label><h3>Custom locations <span class="optional">Optional</span></h3><label>Locations<textarea name="customLocationsText" placeholder="/api/* | http://192.168.1.20:3001 | strip /media/* | http://192.168.1.21:8080 | preserve"></textarea><small>One per line: path | destination | strip or preserve.</small></label><h3>Headers and upstream TLS</h3><label>Request headers<textarea name="requestHeadersText" placeholder="X-Forwarded-Host: {host}"></textarea><small>One Name: value pair per line.</small></label><label>Response headers<textarea name="responseHeadersText" placeholder="X-Frame-Options: SAMEORIGIN"></textarea></label><label>Upstream TLS server name<input name="upstreamTlsServerName" placeholder="service.internal"><small>Optional SNI name expected by the upstream certificate.</small></label><label class="check-control"><input name="upstreamTlsInsecure" type="checkbox"><span>Ignore upstream TLS certificate errors</span><small>Use only for a trusted internal HTTPS service with a self-signed or hostname-mismatched certificate.</small></label><label class="check-control"><input name="hstsSubdomains" type="checkbox"><span>Apply HSTS to subdomains</span></label><label>Custom Caddy configuration<textarea name="customConfig" class="code-input" placeholder="# Expert use only"></textarea><small>Validated before Caddy reload. NGINX syntax is not supported.</small></label></div></details>
|
||||
<p id="proxy-error" class="error" role="alert"></p>
|
||||
<div class="dialog-actions"><button type="button" class="button secondary close-dialog">Cancel</button><button class="button primary">Create & publish</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="redirect-dialog"><form id="redirect-form" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">New route</p><h2>Create a redirect host</h2></div><button type="button" class="icon-button close-dialog">×</button></div><label>Name<input name="name" required placeholder="Old website"></label><label>Source domain<input name="domain" required placeholder="old.example.com"></label><label>Destination<input name="target" type="url" required placeholder="https://new.example.com"></label><label>Redirect type<select name="code"><option value="302">302 · Temporary</option><option value="301">301 · Permanent</option><option value="307">307 · Temporary, preserve method</option><option value="308">308 · Permanent, preserve method</option></select></label><label>TLS<select name="tls"><option value="automatic">Automatic HTTPS</option><option value="http">HTTP only</option><option value="internal">Internal HTTPS</option></select></label><label class="check-control"><input name="preservePath" type="checkbox" checked><span>Preserve path and query</span></label><p id="redirect-error" class="error"></p><div class="dialog-actions"><button type="button" class="button secondary close-dialog">Cancel</button><button class="button primary">Create redirect</button></div></form></dialog>
|
||||
<dialog id="access-dialog"><form id="access-form" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">Reusable protection</p><h2>Create an Access List</h2></div><button type="button" class="icon-button close-dialog">×</button></div><label>Name<input name="name" required placeholder="LAN and family"></label><label>Allowed networks<textarea name="networks" placeholder="private_ranges 192.168.50.0/24"></textarea><small>When supplied, every other network is denied. Use one IP, CIDR range, or private_ranges per line.</small></label><label>Denied networks <span class="optional">Optional</span><textarea name="deniedNetworks" placeholder="203.0.113.0/24"></textarea><small>These rules are evaluated before allowed networks and logins.</small></label><div id="access-credential-editor" class="credential-editor"></div><div id="access-assignment-summary" class="callout hidden"></div><p id="access-error" class="error"></p><div class="dialog-actions"><button type="button" class="button secondary close-dialog">Cancel</button><button class="button primary">Save Access List</button></div></form></dialog>
|
||||
|
||||
<dialog id="settings-dialog">
|
||||
<form id="settings-form" class="dialog-card">
|
||||
<div class="dialog-heading"><div><p class="eyebrow">Gateway settings</p><h2 id="settings-title">Edit route</h2></div><button type="button" class="icon-button close-dialog" aria-label="Close">×</button></div>
|
||||
<label id="settings-name-wrap">Name<input name="name" maxlength="80"></label>
|
||||
<label>Primary domain<input name="domain" placeholder="www.example.com"></label>
|
||||
<label>Additional domains <span class="optional">Optional</span><textarea name="domainsText" placeholder="www.example.com example.net"></textarea><small>One alias per line. All domains use the same route and TLS settings.</small></label>
|
||||
<label id="settings-target-wrap">Forward to<input name="target" type="url" placeholder="http://192.168.1.20:3000"></label>
|
||||
<label>TLS<select name="tls"><option value="automatic">Automatic public HTTPS</option><option value="internal">Internal HTTPS for trusted local devices</option><option value="custom">Custom uploaded certificate</option><option value="http">HTTP only</option></select></label>
|
||||
<label class="check-control"><input name="hsts" type="checkbox"><span>Enable HSTS after HTTPS is verified</span></label>
|
||||
<div class="custom-certificate-fields"><label>Certificate PEM<input name="certificateFile" type="file" accept=".pem,.crt,application/x-pem-file"></label><label>Private key PEM<input name="privateKeyFile" type="file" accept=".pem,.key,application/x-pem-file"></label><small>Both files are required when installing or replacing a custom certificate.</small></div>
|
||||
<details id="settings-hosted-advanced"><summary>Advanced options</summary><div class="details-body"><label>Access List<select name="accessListId"><option value="">Public — no Access List</option></select><small>Reusable network or login protection.</small></label><label>Compression<select name="compression"><option value="automatic">Automatic zstd + gzip</option><option value="gzip">gzip only</option><option value="off">Off</option></select></label><label>Request headers<textarea name="requestHeadersText" placeholder="Name: value"></textarea></label><label>Response headers<textarea name="responseHeadersText" placeholder="Name: value"></textarea></label><label class="check-control"><input name="hstsSubdomains" type="checkbox"><span>Apply HSTS to subdomains</span></label><label>Custom Caddy configuration<textarea name="customConfig" class="code-input"></textarea></label></div></details>
|
||||
<details id="settings-advanced"><summary>Advanced options</summary><div class="details-body"><label>Access List<select name="accessListId"><option value="">Public — no Access List</option></select></label><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"></label><label>Timeout in seconds<input name="healthTimeoutSeconds" type="number" min="1" max="60" value="4"></label><label class="check-control"><input name="healthEnabled" type="checkbox" checked><span>Monitor this upstream</span></label><label>Compression<select name="compression"><option value="automatic">Automatic zstd + gzip</option><option value="gzip">gzip only</option><option value="off">Off</option></select></label><label>Custom locations<textarea name="customLocationsText" placeholder="/api/* | http://192.168.1.20:3001 | strip"></textarea><small>One per line: path | destination | strip or preserve.</small></label><label>Request headers<textarea name="requestHeadersText" placeholder="Name: value"></textarea></label><label>Response headers<textarea name="responseHeadersText" placeholder="Name: value"></textarea></label><label>Upstream TLS server name<input name="upstreamTlsServerName"><small>Optional SNI name expected by the upstream certificate.</small></label><label class="check-control"><input name="upstreamTlsInsecure" type="checkbox"><span>Ignore upstream TLS certificate errors</span><small>Use only for a trusted internal HTTPS service with a self-signed or hostname-mismatched certificate.</small></label><label class="check-control"><input name="hstsSubdomains" type="checkbox"><span>Apply HSTS to subdomains</span></label><label>Custom Caddy configuration<textarea name="customConfig" class="code-input"></textarea></label></div></details>
|
||||
<p id="settings-error" class="error" role="alert"></p>
|
||||
<div class="dialog-actions"><button type="button" class="button secondary close-dialog">Cancel</button><button class="button primary">Save & apply</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="confirm-dialog">
|
||||
<form method="dialog" class="dialog-card compact"><h2 id="confirm-title">Delete this site?</h2><p id="confirm-copy" class="muted">Its uploaded files will be permanently removed.</p><div class="dialog-actions"><button value="cancel" class="button secondary">Cancel</button><button value="confirm" class="button danger">Delete</button></div></form>
|
||||
</dialog>
|
||||
<dialog id="readiness-dialog">
|
||||
<form method="dialog" class="dialog-card readiness-dialog-card"><div class="dialog-heading"><div><p class="eyebrow">Domain readiness</p><h2 id="readiness-title">Diagnostics</h2></div><button value="cancel" class="icon-button" aria-label="Close">×</button></div><div id="readiness-detail-content"></div><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>
|
||||
</dialog>
|
||||
<dialog id="icon-dialog" class="icon-dialog">
|
||||
<form method="dialog" class="dialog-card icon-picker">
|
||||
<div class="dialog-heading"><div><p class="eyebrow">Appearance</p><h2>Choose an icon</h2></div><button value="cancel" class="icon-button" aria-label="Close">×</button></div>
|
||||
<p class="muted">Search Dashboard Icons. Selected icons are validated and stored locally in <code>/data/icons</code>.</p>
|
||||
<label>Search icons<input id="icon-search" type="search" placeholder="Jellyfin" autocomplete="off"></label>
|
||||
<label>Upload a custom icon<input id="icon-upload" type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml"><small>PNG, JPEG, WebP, GIF, or SVG · up to 2 MB. Stored locally in <code>/data/icons</code>.</small></label>
|
||||
<label>Or use an image URL <span class="optional">Optional</span><input id="icon-url" type="url" placeholder="https://example.com/icon.png"><small>Use a trusted HTTPS URL. The two-letter fallback remains available.</small></label>
|
||||
<div id="icon-results" class="icon-results" aria-live="polite"><p class="quiet-state">Enter at least two characters to search.</p></div>
|
||||
<p id="icon-error" class="error" role="alert"></p>
|
||||
<div class="dialog-actions"><button id="save-icon-url" type="button" class="button secondary">Save URL</button><button id="reset-icon" value="none" class="button secondary">Use two-letter fallback</button><button value="cancel" class="button secondary">Cancel</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="user-dialog">
|
||||
<form id="user-form" class="dialog-card">
|
||||
<div class="dialog-heading"><div><p class="eyebrow">Administration</p><h2>Create a user</h2></div><button type="button" class="icon-button close-dialog" aria-label="Close">×</button></div>
|
||||
<label>Display name<input name="displayName" placeholder="Marvin Wade" maxlength="80" required></label>
|
||||
<label>Username<input name="username" placeholder="marvin" minlength="3" maxlength="64" pattern="[A-Za-z0-9][A-Za-z0-9._-]{2,63}" autocomplete="off" required></label>
|
||||
<label>Role<select name="role"><option value="standard">Standard User</option><option value="viewer">Viewer</option><option value="administrator">Administrator</option></select><small>Viewer accounts can inspect gateway data. Standard Users and Administrators retain their assigned management capabilities.</small></label>
|
||||
<label>Temporary password<input name="password" type="password" minlength="8" autocomplete="new-password" required><small>At least 8 characters. Share it securely.</small></label>
|
||||
<p id="user-error" class="error" role="alert"></p>
|
||||
<div class="dialog-actions"><button type="button" class="button secondary close-dialog">Cancel</button><button class="button primary">Create user</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="password-dialog">
|
||||
<form id="password-form" class="dialog-card">
|
||||
<div class="dialog-heading"><div><p class="eyebrow">Credentials</p><h2 id="password-title">Reset password</h2></div><button type="button" class="icon-button close-dialog" aria-label="Close">×</button></div>
|
||||
<label>New password<input name="password" type="password" minlength="8" autocomplete="new-password" required><small>At least 8 characters.</small></label>
|
||||
<p id="password-error" class="error" role="alert"></p>
|
||||
<div class="dialog-actions"><button type="button" class="button secondary close-dialog">Cancel</button><button class="button primary">Save password</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<input id="replace-files" type="file" accept=".zip,.html,text/html,application/zip" hidden>
|
||||
<div id="toast" class="toast" role="status"></div>
|
||||
<script src="/app.js?v=0.11.28" defer></script><script src="/features.js?v=0.11.28" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Not found</title><style>body{font:16px system-ui;background:#08101d;color:#f4f7fb;min-height:100vh;display:grid;place-items:center;margin:0;text-align:center}h1{font-size:4rem;margin:0;color:#62e6a7}p{color:#91a0b6}</style></head><body><main><h1>404</h1><p>This file doesn’t exist on this site.</p></main></body></html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 429 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1800 320" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Site Gateway</title>
|
||||
<desc id="desc">Site Gateway wordmark with a green and blue gateway icon and colored routing nodes.</desc>
|
||||
<defs><filter id="glow" x="-30%" y="-50%" width="160%" height="200%"><feGaussianBlur stdDeviation="14" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>
|
||||
<g opacity=".8" stroke="#9bb2ca" stroke-width="2"><path d="M30 160h330"/><path d="M1470 160h300"/></g>
|
||||
<g fill="#62e6a7" stroke="#0b1525" stroke-width="3"><circle cx="150" cy="160" r="14"/><circle cx="1650" cy="160" r="14"/></g>
|
||||
<g fill="#1686ff" stroke="#0b1525" stroke-width="3"><circle cx="215" cy="160" r="14"/><circle cx="1585" cy="160" r="14"/></g>
|
||||
<g fill="#ffbf69" stroke="#0b1525" stroke-width="3"><circle cx="280" cy="160" r="14"/><circle cx="1520" cy="160" r="14"/></g>
|
||||
<g transform="translate(405 54)" filter="url(#glow)">
|
||||
<path d="M92 0 172 47v83l-51-30V76L92 59 63 76v24l-51 30V47z" fill="#62e6a7"/>
|
||||
<path d="m92 59 29 17v83l51-30v58l-80 47-80-47v-58l51 30V76z" fill="#1686ff"/>
|
||||
<path d="m92 59 29 17v83l-29 17-29-17V76z" fill="#172a47"/>
|
||||
</g>
|
||||
<text x="650" y="202" fill="#f4f7fb" font-family="Arial,sans-serif" font-size="112" font-weight="800" letter-spacing="-5">Site</text>
|
||||
<text x="985" y="202" fill="#62e6a7" font-family="Arial,sans-serif" font-size="112" font-weight="800" letter-spacing="-5">Gateway</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
File diff suppressed because one or more lines are too long
+1463
File diff suppressed because it is too large
Load Diff
+137
@@ -0,0 +1,137 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
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" };
|
||||
|
||||
function now() { return new Date().toISOString(); }
|
||||
|
||||
async function migrationSnapshot(dataDir, backupsDir, migrationsDir) {
|
||||
const present = Object.values(legacyFiles).filter(name => fs.existsSync(path.join(dataDir, name)));
|
||||
if (!present.length) return null;
|
||||
const stamp = now().replace(/[:.]/g, "-");
|
||||
const snapshotDir = path.join(migrationsDir, `json-backup-${stamp}`);
|
||||
await fsp.mkdir(snapshotDir, { recursive: true });
|
||||
const zip = new AdmZip();
|
||||
const manifest = { format: 1, product: "Site Gateway", purpose: "pre-sqlite-migration", type: "complete", includeLogs: false, createdAt: now(), files: [], checksums: {} };
|
||||
for (const name of [...present, "settings.json"].filter(name => fs.existsSync(path.join(dataDir, name)))) {
|
||||
const value = await fsp.readFile(path.join(dataDir, name));
|
||||
await fsp.writeFile(path.join(snapshotDir, name), value, { mode: name === "users.json" ? 0o600 : 0o640 });
|
||||
zip.addFile(`legacy-json/${name}`, value); manifest.files.push(`legacy-json/${name}`); manifest.checksums[`legacy-json/${name}`] = crypto.createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
for (const [directory, archive] of [["sites", "sites"], ["icons", "icons"], ["default-site", "default-site"], ["certificates", "certificates"]]) {
|
||||
const source = path.join(dataDir, directory); if (fs.existsSync(source)) zip.addLocalFolder(source, archive);
|
||||
}
|
||||
manifest.files = zip.getEntries().filter(entry => !entry.isDirectory).map(entry => entry.entryName);
|
||||
manifest.checksums = Object.fromEntries(zip.getEntries().filter(entry => !entry.isDirectory).map(entry => [entry.entryName, crypto.createHash("sha256").update(entry.getData()).digest("hex")]));
|
||||
zip.addFile("manifest.json", Buffer.from(JSON.stringify(manifest, null, 2)));
|
||||
const filename = `pre-sqlite-migration-${stamp}.sgbackup`;
|
||||
await fsp.writeFile(path.join(backupsDir, filename), zip.toBuffer(), { mode: 0o600 });
|
||||
return { filename, snapshotDir };
|
||||
}
|
||||
|
||||
export async function openStorage(dataDir, backupsDir) {
|
||||
const databaseDir = path.join(dataDir, "database"), migrationsDir = path.join(dataDir, "migrations"), databasePath = path.join(databaseDir, "site-gateway.sqlite");
|
||||
await Promise.all([fsp.mkdir(databaseDir, { recursive: true }), fsp.mkdir(migrationsDir, { recursive: true }), fsp.mkdir(backupsDir, { recursive: true })]);
|
||||
const isNew = !fs.existsSync(databasePath);
|
||||
const snapshot = isNew ? await migrationSnapshot(dataDir, backupsDir, migrationsDir) : null;
|
||||
const db = new DatabaseSync(databasePath);
|
||||
await fsp.chmod(databasePath, 0o600);
|
||||
db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000;");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS instances (id TEXT PRIMARY KEY, name TEXT NOT NULL, kind TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||
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 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 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);
|
||||
CREATE TABLE IF NOT EXISTS access_assignments (instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, route_kind TEXT NOT NULL, route_id TEXT NOT NULL, access_list_id TEXT NOT NULL REFERENCES access_lists(id) ON DELETE RESTRICT, created_at TEXT NOT NULL, PRIMARY KEY(route_kind,route_id));
|
||||
CREATE TABLE IF NOT EXISTS settings (instance_id TEXT PRIMARY KEY REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), updated_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS audit_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), actor_id TEXT, action TEXT NOT NULL, status TEXT NOT NULL, details TEXT, created_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS activity_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), message TEXT NOT NULL, status TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'activity', created_at TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS activity_events_instance_created ON activity_events(instance_id,created_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS access_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), at TEXT, host TEXT, method TEXT, uri TEXT, status INTEGER, size INTEGER, duration_ms INTEGER, remote_ip TEXT, source TEXT, UNIQUE(instance_id,source));
|
||||
CREATE INDEX IF NOT EXISTS access_events_instance_at ON access_events(instance_id,at DESC);
|
||||
`);
|
||||
try { db.exec("ALTER TABLE activity_events ADD COLUMN category TEXT NOT NULL DEFAULT 'activity'"); } catch { /* Column already exists. */ }
|
||||
const timestamp = now();
|
||||
db.prepare("INSERT OR IGNORE INTO instances(id,name,kind,status,created_at,updated_at) VALUES(?,?,?,?,?,?)").run(LOCAL_INSTANCE_ID, "Local Gateway", "local", "active", timestamp, timestamp);
|
||||
db.prepare("INSERT OR IGNORE INTO schema_migrations(version,applied_at) VALUES(1,?)").run(timestamp);
|
||||
|
||||
function transaction(work) { db.exec("BEGIN IMMEDIATE"); try { const result = work(); db.exec("COMMIT"); return result; } catch (error) { db.exec("ROLLBACK"); throw error; } }
|
||||
function loadCollection(kind, instanceId = LOCAL_INSTANCE_ID) { const table = entityTables[kind]; if (!table) throw new Error(`Unsupported collection ${kind}`); return db.prepare(`SELECT payload FROM ${table} WHERE instance_id=? ORDER BY created_at,id`).all(instanceId).map(row => JSON.parse(row.payload)); }
|
||||
function refreshAssignments(instanceId = LOCAL_INSTANCE_ID) {
|
||||
db.prepare("DELETE FROM access_assignments WHERE instance_id=?").run(instanceId);
|
||||
const insert = db.prepare("INSERT INTO access_assignments(instance_id,route_kind,route_id,access_list_id,created_at) VALUES(?,?,?,?,?)");
|
||||
for (const [kind, table] of [["hosted", "hosted_sites"], ["proxy", "proxy_hosts"], ["redirect", "redirect_hosts"]]) for (const row of db.prepare(`SELECT id,payload FROM ${table} WHERE instance_id=?`).all(instanceId)) { const value = JSON.parse(row.payload); if (value.accessListId) insert.run(instanceId, kind, row.id, value.accessListId, now()); }
|
||||
}
|
||||
function saveCollection(kind, values, instanceId = LOCAL_INSTANCE_ID) {
|
||||
const table = entityTables[kind]; if (!table) throw new Error(`Unsupported collection ${kind}`);
|
||||
transaction(() => {
|
||||
if (["sites","proxies","redirects"].includes(kind)) db.prepare("DELETE FROM access_assignments WHERE instance_id=? AND route_kind=?").run(instanceId, kind === "sites" ? "hosted" : kind === "proxies" ? "proxy" : "redirect");
|
||||
if (kind !== "access_lists") db.prepare(`DELETE FROM ${table} WHERE instance_id=?`).run(instanceId);
|
||||
const insert = db.prepare(`INSERT INTO ${table}(id,instance_id,payload,created_at,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at`);
|
||||
for (const value of values) {
|
||||
const created = value.createdAt || now(), stored = { ...value, instanceId };
|
||||
if (kind === "proxies") for (const key of ["certificatePath", "keyPath"]) if (stored[key]) stored[key] = String(stored[key]).replace(path.join(dataDir, "custom-certificates"), path.join(dataDir, "certificates", "custom"));
|
||||
insert.run(value.id, instanceId, JSON.stringify(stored), created, now());
|
||||
}
|
||||
if (kind === "access_lists") {
|
||||
const keep = new Set(values.map(value => value.id));
|
||||
for (const row of db.prepare("SELECT id FROM access_lists WHERE instance_id=?").all(instanceId)) if (!keep.has(row.id)) db.prepare("DELETE FROM access_lists WHERE id=?").run(row.id);
|
||||
}
|
||||
if (["sites","proxies","redirects"].includes(kind)) refreshAssignments(instanceId);
|
||||
});
|
||||
}
|
||||
function loadSettings(instanceId = LOCAL_INSTANCE_ID) { const row = db.prepare("SELECT payload FROM settings WHERE instance_id=?").get(instanceId); return row ? JSON.parse(row.payload) : null; }
|
||||
function saveSettings(value, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO settings(instance_id,payload,updated_at) VALUES(?,?,?) ON CONFLICT(instance_id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at").run(instanceId, JSON.stringify(value), now()); }
|
||||
function integrity() { return db.prepare("PRAGMA integrity_check").all().map(row => Object.values(row)[0]); }
|
||||
function recordAudit(action, status = "ok", details = null, actorId = null, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO audit_events(instance_id,actor_id,action,status,details,created_at) VALUES(?,?,?,?,?,?)").run(instanceId, actorId, action, status, details ? JSON.stringify(details) : null, now()); }
|
||||
function recordActivity(message, status = "ok", instanceId = LOCAL_INSTANCE_ID) { const text = String(message); const category = /cert|tls|acme|certificate/i.test(text) ? "certificate" : /login|password|security|access list|credential/i.test(text) ? "security" : "activity"; db.prepare("INSERT INTO activity_events(instance_id,message,status,category,created_at) VALUES(?,?,?,?,?)").run(instanceId, text, status, category, now()); }
|
||||
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 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 })); }
|
||||
function backupTo(filename) { try { fs.rmSync(filename, { force: true }); db.exec(`VACUUM INTO '${String(filename).replaceAll("'", "''")}'`); } catch (error) { throw new Error(`Could not create a consistent SQLite backup: ${error.message}`); } }
|
||||
|
||||
if (isNew) {
|
||||
try { transaction(() => {
|
||||
for (const [kind, filename] of Object.entries(legacyFiles)) {
|
||||
const source = path.join(dataDir, filename); if (!fs.existsSync(source)) continue;
|
||||
const values = JSON.parse(fs.readFileSync(source, "utf8"));
|
||||
const table = entityTables[kind], insert = db.prepare(`INSERT INTO ${table}(id,instance_id,payload,created_at,updated_at) VALUES(?,?,?,?,?)`);
|
||||
for (const value of values) {
|
||||
const migrated = { ...value, instanceId: LOCAL_INSTANCE_ID };
|
||||
if (kind === "proxies") for (const key of ["certificatePath", "keyPath"]) if (migrated[key]) migrated[key] = String(migrated[key]).replace(path.join(dataDir, "custom-certificates"), path.join(dataDir, "certificates", "custom"));
|
||||
insert.run(value.id, LOCAL_INSTANCE_ID, JSON.stringify(migrated), value.createdAt || timestamp, timestamp);
|
||||
}
|
||||
}
|
||||
const settingsFile = path.join(dataDir, "settings.json");
|
||||
if (fs.existsSync(settingsFile)) db.prepare("INSERT OR REPLACE INTO settings(instance_id,payload,updated_at) VALUES(?,?,?)").run(LOCAL_INSTANCE_ID, fs.readFileSync(settingsFile, "utf8"), timestamp);
|
||||
refreshAssignments(LOCAL_INSTANCE_ID);
|
||||
}); } catch (error) {
|
||||
db.close();
|
||||
await Promise.all([fsp.rm(databasePath, { force: true }), fsp.rm(`${databasePath}-wal`, { force: true }), fsp.rm(`${databasePath}-shm`, { force: true })]);
|
||||
throw new Error(`Legacy JSON migration failed and was rolled back: ${error.message}`);
|
||||
}
|
||||
}
|
||||
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() };
|
||||
}
|
||||
Reference in New Issue
Block a user